UNPKG

saget-auth-middleware

Version:

A comprehensive authentication middleware for Node.js applications with SSO integration, JWT validation, and role-based access control

416 lines (345 loc) 14.7 kB
import { NextResponse } from "next/server"; import { jwtVerify } from "jose"; // Default configuration - can be overridden const DEFAULT_CONFIG = { PUBLIC_PATHS: ['/', '/login', '/register', '/docs', '/login/verify-otp'], // Role-based access control configuration ACCESS_CONTROL: { '/dashboard': ['SUPER ADMIN'], '/account': ['SUPER ADMIN', 'GUEST', 'USER', 'ADMIN', 'APPLICANT'] // Allow all authenticated users }, // Default cookie names COOKIE_NAMES: { ACCESS_TOKEN: 'accessToken-malinau.go.id', REFRESH_TOKEN: 'refreshToken-malinau.go.id', APPLICATION_TOKEN: 'applicationToken-malinau.go.id' }, // Default SSO URLs SSO_LOGIN_URL: 'http://sso.localhost/login', // Debug logging (disabled by default in production) DEBUG: process.env.NODE_ENV === 'development' || process.env.SAGET_DEBUG === 'true' }; // Global configuration object let MIDDLEWARE_CONFIG = { ...DEFAULT_CONFIG }; /** * Debug logging helper - only logs when debug mode is enabled * @param {string} message - The message to log * @param {...any} args - Additional arguments to log */ const debugLog = (message, ...args) => { if (MIDDLEWARE_CONFIG.DEBUG) { console.log(`[SAGET-AUTH] ${message}`, ...args); } }; /** * Warning logging helper - always logs warnings * @param {string} message - The warning message to log * @param {...any} args - Additional arguments to log */ const warnLog = (message, ...args) => { console.warn(`[SAGET-AUTH] ${message}`, ...args); }; /** * Error logging helper - always logs errors * @param {string} message - The error message to log * @param {...any} args - Additional arguments to log */ const errorLog = (message, ...args) => { console.error(`[SAGET-AUTH] ${message}`, ...args); }; /** * Configure the middleware with custom settings * @param {object} config - Configuration object */ export function configureMiddleware(config = {}) { MIDDLEWARE_CONFIG = { ...DEFAULT_CONFIG, ...config, PUBLIC_PATHS: config.PUBLIC_PATHS || DEFAULT_CONFIG.PUBLIC_PATHS, ACCESS_CONTROL: { ...DEFAULT_CONFIG.ACCESS_CONTROL, ...(config.ACCESS_CONTROL || {}) }, COOKIE_NAMES: { ...DEFAULT_CONFIG.COOKIE_NAMES, ...(config.COOKIE_NAMES || {}) } }; return middleware; } /** * Decode JWT token safely * @param {string} token - The JWT token to decode * @returns {object|null} - Decoded token or null if invalid */ const decodeJwtToken = async (token) => { try { const jwtSecret = process.env.SSO_JWT_SECRET; if (!jwtSecret) { errorLog('SSO_JWT_SECRET environment variable is not set'); return null; } const decoded = await jwtVerify(token, new TextEncoder().encode(jwtSecret)); return decoded; } catch (error) { if (error.name === 'JWTInvalid' || error.name === 'JWTExpired' || error.name === 'JWTMalformed') { warnLog('JWT verification failed:', error.message); return null; } errorLog('Unexpected error during JWT verification:', error); return null; } } /** * Check user authorization for the requested path * @param {Request} req - The request object * @returns {boolean} - True if authorized, false otherwise */ const checkOtorisasi = async (req) => { try { const applicationTokenName = process.env.NEXT_PUBLIC_COOKIE_APPLICATION_TOKEN_NAME || process.env.COOKIE_APPLICATION_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.APPLICATION_TOKEN; const applicationToken = req.cookies.get(applicationTokenName)?.value; if (!applicationToken) { debugLog('No application token found'); return false; } const {payload: {applications}} = await decodeJwtToken(applicationToken); if (!applications) { warnLog('Failed to decode application token'); return false; } const pathname = req.nextUrl.pathname; const requiredRoles = MIDDLEWARE_CONFIG.ACCESS_CONTROL[pathname] || []; // If no specific roles required, allow access for any authenticated user if (requiredRoles.length === 0) { return true; } if (!requiredRoles.includes(applications.role)) { warnLog(`User role '${applications.role}' not authorized for path '${pathname}'. Required roles: ${requiredRoles.join(', ')}`); return false; } return true; } catch (error) { errorLog('Error checking authorization:', error); return false; } } /** * Refresh authentication tokens using refresh token * @param {string} refreshToken - The refresh token * @returns {Promise<NextResponse|null>} - Response with new cookies or null if failed */ const setCookieFromReauth = async (refreshToken) => { try { // Validate environment variables - prioritize server-side URL for Docker network const ssoApiUrl = process.env.NEXT_PUBLIC_API_URL_SERVER_SIDE || process.env.SSO_API_URL || process.env.NEXT_PUBLIC_SSO_API_URL || process.env.NEXT_PUBLIC_API_URL; if (!ssoApiUrl) { errorLog('SSO_API_URL environment variable is not set. Please set one of: NEXT_PUBLIC_API_URL_SERVER_SIDE, SSO_API_URL, NEXT_PUBLIC_SSO_API_URL, or NEXT_PUBLIC_API_URL'); return null; } if (!refreshToken) { errorLog('Refresh token is required but not provided'); return null; } debugLog(`Attempting to refresh tokens using API: ${ssoApiUrl}/auth/refresh-token`); const res = await fetch(`${ssoApiUrl}/auth/refresh-token`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${refreshToken}` }, body: JSON.stringify({ refreshToken }) }); if (res.ok) { const data = await res.json(); // Validate response data if (!data.accessToken || !data.refreshToken || !data.applicationToken) { errorLog('Invalid response from refresh token API - missing required tokens'); return null; } // Create response with updated cookies const response = NextResponse.next(); // Set cookies const accessTokenName = process.env.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME || process.env.COOKIE_ACCESS_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.ACCESS_TOKEN; const refreshTokenName = process.env.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME || process.env.COOKIE_REFRESH_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.REFRESH_TOKEN; const applicationTokenName = process.env.NEXT_PUBLIC_COOKIE_APPLICATION_TOKEN_NAME || process.env.COOKIE_APPLICATION_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.APPLICATION_TOKEN; const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 60 * 60 * 24 * 7 // 7 days }; response.cookies.set(accessTokenName, data.accessToken, cookieOptions); response.cookies.set(refreshTokenName, data.refreshToken, cookieOptions); response.cookies.set(applicationTokenName, data.applicationToken, cookieOptions); debugLog('Successfully refreshed authentication tokens'); return response; } else { warnLog(`Failed to refresh tokens - server response: ${res.status} ${res.statusText}`); // Try to get error details from response try { const errorData = await res.text(); warnLog('Error response body:', errorData); } catch (e) { warnLog('Could not read error response body'); } return null; } } catch (error) { errorLog('Error refreshing authentication tokens:', error.message); // Provide more specific error information if (error.name === 'TypeError' && error.message.includes('fetch failed')) { errorLog('Network error: Unable to connect to SSO API. Please check:'); errorLog('1. SSO_API_URL environment variable is correct'); errorLog('2. SSO API server is running and accessible'); errorLog('3. Network connectivity from middleware context'); } return null; } } /** * Attempt to reauthenticate using refresh token * @param {RequestCookies} cookies - Request cookies * @returns {Promise<NextResponse|null>} - Response with new cookies or null if failed */ async function reauthUsingRefreshToken(cookies) { const refreshTokenName = process.env.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME || process.env.COOKIE_REFRESH_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.REFRESH_TOKEN; const refreshToken = cookies.get(refreshTokenName)?.value; if (!refreshToken) { debugLog('No refresh token found for reauthentication'); return null; } // Attempt to refresh tokens const refreshResult = await setCookieFromReauth(refreshToken); if (refreshResult === null) { warnLog('Token refresh failed - clearing refresh token cookie to prevent retry loops'); // Create a response that clears the invalid refresh token const response = NextResponse.next(); response.cookies.delete(refreshTokenName); // Also clear other auth cookies since they're likely invalid too const accessTokenName = process.env.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME || process.env.COOKIE_ACCESS_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.ACCESS_TOKEN; const applicationTokenName = process.env.NEXT_PUBLIC_COOKIE_APPLICATION_TOKEN_NAME || process.env.COOKIE_APPLICATION_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.APPLICATION_TOKEN; response.cookies.delete(accessTokenName); response.cookies.delete(applicationTokenName); return null; // Still return null to trigger SSO redirect } return refreshResult; } /** * Check if authentication cookies exist and are valid * @param {Request} req - The request object * @returns {Promise<NextResponse|boolean>} - NextResponse with refreshed cookies, true if valid, false if invalid */ async function hasAuthCookies(req) { const cookies = req.cookies; // Check for access token cookie (primary authentication check) const accessTokenName = process.env.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME || process.env.COOKIE_ACCESS_TOKEN_NAME || MIDDLEWARE_CONFIG.COOKIE_NAMES.ACCESS_TOKEN; const accessToken = cookies.get(accessTokenName)?.value; if (accessToken) { // Verify the access token is valid const decoded = await decodeJwtToken(accessToken); if (decoded) { return true; // Valid access token found } } // No valid access token, try to refresh using refresh token debugLog('No valid access token found, attempting to refresh'); return await reauthUsingRefreshToken(cookies); } /** * Create SSO login redirect URL * @param {Request} req - The request object * @returns {URL} - The SSO login URL with parameters */ function createSSOLoginUrl(req) { const ssoAppKey = process.env.SSO_APP_KEY || process.env.NEXT_PUBLIC_APP_KEY; const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URL || process.env.NEXT_REDIRECT_URL; const ssoLoginUrl = process.env.SSO_LOGIN_URL || MIDDLEWARE_CONFIG.SSO_LOGIN_URL; const loginUrl = new URL(ssoLoginUrl); loginUrl.searchParams.set('app_key', ssoAppKey); loginUrl.searchParams.set('redirect_uri', redirectUri); return loginUrl; } /** * Main middleware function - exactly matches the original SSO middleware flow * @param {Request} req - The request object * @returns {Promise<NextResponse>} - The response */ export async function middleware(req) { const { pathname } = req.nextUrl; // Allow access to public paths if (MIDDLEWARE_CONFIG.PUBLIC_PATHS.includes(pathname)) { return NextResponse.next(); } // Check if path requires authentication if (pathname.startsWith('/dashboard') || pathname.startsWith('/account')) { try { // Check if authentication cookies exist and are valid const authResult = await hasAuthCookies(req); if (authResult === false || authResult === null) { debugLog(`No valid auth cookies found for path: ${pathname}`); // Redirect to SSO login with proper parameters const ssoLoginUrl = createSSOLoginUrl(req); debugLog(`Redirecting to SSO login: ${ssoLoginUrl.toString()}`); return NextResponse.redirect(ssoLoginUrl); } // If authResult is a NextResponse (from token refresh), use it if (authResult instanceof NextResponse) { debugLog(`Tokens refreshed for path: ${pathname}`); // Still need to check authorization with the refreshed tokens // Create a new request object with the updated cookies for authorization check const tempReq = { ...req, cookies: authResult.cookies }; if (!await checkOtorisasi(tempReq)) { debugLog(`User not authorized for path: ${pathname}`); // Redirect to SSO login for authorization const ssoLoginUrl = createSSOLoginUrl(req); return NextResponse.redirect(ssoLoginUrl); } return authResult; // Return the response with refreshed cookies } // If authResult is true, check authorization if (authResult === true) { if (!await checkOtorisasi(req)) { debugLog(`User not authorized for path: ${pathname}`); // Redirect to SSO login for authorization const ssoLoginUrl = createSSOLoginUrl(req); return NextResponse.redirect(ssoLoginUrl); } debugLog(`User authenticated and authorized for path: ${pathname}`); return NextResponse.next(); } } catch (error) { errorLog('Error in middleware authentication check:', error); // On error, redirect to SSO login const ssoLoginUrl = createSSOLoginUrl(req); return NextResponse.redirect(ssoLoginUrl); } } return NextResponse.next(); } // Default export for easier importing export default middleware; // Export the default config for reference export const config = { matcher: [ "/((?!api|_next|static|favicon.ico|register|bantuan).*)", '/dashboard/:path*', '/account/:path*', ] };