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
JavaScript
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*',
]
};