saget-auth-middleware
Version:
SSO Middleware dengan dukungan localStorage untuk validasi authentifikasi domain malinau.go.id dan semua subdomain pada aplikasi Next.js 14 & 15
540 lines (466 loc) • 15.8 kB
JavaScript
import { SSOConfig } from '../config/sso-config.js';
import { TokenUtils } from '../utils/token-utils.js';
import { logger } from '../../logger.js';
/**
* Enhanced SSO Server-side middleware with improved error handling and maintainability
*/
export class SSOServerSide extends SSOConfig {
constructor(options = {}) {
super(options);
this.tokenUtils = new TokenUtils(this);
this.initializeServerSide();
}
/**
* Initialize server-side specific configurations
* @private
*/
initializeServerSide() {
// Validate server-side specific requirements
this.validateServerRequirements();
logger.info('SSO Server-side middleware initialized');
}
/**
* Validate server-side requirements
* @private
*/
validateServerRequirements() {
// Add any server-specific validation here
if (!this.JWT_SECRET || !this.JWT_REFRESH_SECRET) {
throw new Error('JWT secrets are required for server-side operations');
}
}
/**
* Get tokens from request object (server-side version)
* @param {Request} req - The request object
* @returns {object} { accessToken, refreshToken }
*/
getTokensFromRequest(req) {
return this.tokenUtils.getTokensFromRequest(req);
}
/**
* Get access token from cookies or localStorage (via headers)
* @param {Request} req - Request object
* @returns {string|null} Access token
*/
getTokenFromRequest(req) {
return this.tokenUtils.getTokenFromRequest(req);
}
/**
* Get refresh token from cookies or localStorage (via headers)
* @param {Request} req - Request object
* @returns {string|null} Refresh token
*/
getRefreshTokenFromRequest(req) {
return this.tokenUtils.getRefreshTokenFromRequest(req);
}
/**
* Set tokens in response cookies and headers
* @param {object} params - Parameters object
* @param {NextResponse} params.response - Response object
* @param {string} params.accessToken - Access token
* @param {string} params.refreshToken - Refresh token
*/
setTokensInResponse({ response, accessToken, refreshToken }) {
return this.tokenUtils.setTokensInResponse({ response, accessToken, refreshToken });
}
/**
* Main middleware for SSO validation with enhanced error handling
* @param {Function} handler - Next.js middleware handler
* @returns {Function} Enhanced middleware function
*/
withSSOValidation(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
return async (req) => {
try {
const url = new URL(req.url);
const { accessToken, refreshToken } = this.getTokensFromRequest(req);
logger.debug('SSO validation started for:', url.pathname);
logger.debug('Access token present:', !!accessToken);
logger.debug('Refresh token present:', !!refreshToken);
// Handle missing access token
if (!accessToken) {
logger.info('No access token found, redirecting to SSO');
return this.redirectToSSO(url);
}
// Validate and process token
const validationResult = await this.validateAccessToken(accessToken);
if (validationResult.success) {
// Token is valid, set user context and proceed
return this.handleValidToken(req, handler, validationResult.payload);
} else if (validationResult.expired && refreshToken) {
// Token expired, attempt refresh
logger.info('Access token expired, attempting refresh');
return await this.handleTokenRefresh(req);
} else {
// Token invalid or no refresh token available
logger.warn('Token validation failed:', validationResult.error);
return this.redirectToSSO(url);
}
} catch (error) {
logger.error('SSO middleware error:', error.message);
return this.handleMiddlewareError(error);
}
};
}
/**
* Validate access token with detailed error information
* @private
* @param {string} accessToken - Access token to validate
* @returns {object} Validation result
*/
async validateAccessToken(accessToken) {
try {
const { jwtVerify } = await import('jose');
const payload = await jwtVerify(accessToken, this.JWT_SECRET);
return {
success: true,
payload: payload.payload,
error: null,
expired: false
};
} catch (jwtError) {
const isExpired = jwtError.code === 'ERR_JWT_EXPIRED' ||
jwtError.message.includes('exp') ||
jwtError.message.includes('expired');
return {
success: false,
payload: null,
error: jwtError.message,
expired: isExpired
};
}
}
/**
* Handle valid token and set user context
* @private
* @param {Request} req - Request object
* @param {Function} handler - Original handler
* @param {object} payload - JWT payload
* @returns {Response} Handler response
*/
async handleValidToken(req, handler, payload) {
try {
const user = payload.user;
if (!user) {
logger.warn('No user found in JWT payload');
return this.redirectToSSO();
}
const { app, statusAccess } = this.findUserApplication(user);
if (!app) {
logger.warn(`Application with key '${this.APP_KEY}' not found in user applications`);
return this.redirectToSSO();
}
// Set user context on request
this.setUserContext(req, user, app, statusAccess);
logger.debug('User authenticated successfully:', user.id || user.email);
return await handler(req);
} catch (error) {
logger.error('Error handling valid token:', error.message);
return this.redirectToSSO();
}
}
/**
* Set user context on request object
* @private
* @param {Request} req - Request object
* @param {object} user - User object
* @param {object} app - Application object
* @param {boolean} statusAccess - Access status
*/
setUserContext(req, user, app, statusAccess) {
req.user = user;
if (statusAccess) {
req.role = app.role || 'USER';
req.subrole = app.subrole || 'DEFAULT';
} else {
req.role = 'GUEST';
req.subrole = 'GUEST';
}
req.app = app;
req.statusAccess = statusAccess;
logger.debug('User context set:', {
userId: user.id || user.email,
role: req.role,
subrole: req.subrole,
appKey: this.APP_KEY
});
}
/**
* Redirect to SSO login page with proper redirect_uri handling
* @param {URL} [currentUrl] - Current URL for redirect
* @returns {Response} Redirect response
*/
async redirectToSSO(currentUrl = null) {
try {
const { NextResponse } = await import('next/server');
const loginUrl = this.buildLoginUrl(currentUrl);
logger.info('Redirecting to SSO:', loginUrl.toString());
const response = NextResponse.redirect(loginUrl);
response.headers.set('x-clear-localstorage', 'true');
response.headers.set('x-sso-redirect', 'true');
return response;
} catch (error) {
logger.error('Error creating SSO redirect:', error.message);
throw error;
}
}
/**
* Build login URL with proper parameters
* @private
* @param {URL} [currentUrl] - Current URL
* @returns {URL} Login URL
*/
buildLoginUrl(currentUrl = null) {
const loginUrl = new URL(this.SSO_LOGIN_URL);
// Determine redirect URI with priority order
const redirectUri = this.determineRedirectUri(currentUrl);
if (redirectUri) {
loginUrl.searchParams.set('redirect_uri', redirectUri);
logger.debug('Using redirect URI:', redirectUri);
}
loginUrl.searchParams.set('app_key', this.APP_KEY);
return loginUrl;
}
/**
* Determine redirect URI with priority order
* @private
* @param {URL} [currentUrl] - Current URL
* @returns {string} Redirect URI
*/
determineRedirectUri(currentUrl = null) {
// Priority order for redirect URI
if (this.NEXT_PUBLIC_REDIRECT_URL) {
return this.NEXT_PUBLIC_REDIRECT_URL;
}
if (this.NEXT_REDIRECT_URL && !this.NEXT_REDIRECT_URL.includes('sso.malinau.go.id')) {
return this.NEXT_REDIRECT_URL;
}
if (currentUrl) {
return currentUrl.origin;
}
return '';
}
/**
* Handle token refresh logic with enhanced error handling
* @param {Request} req - Request object
* @returns {Response} Refresh response or redirect
*/
async handleTokenRefresh(req) {
try {
const refreshToken = this.getRefreshTokenFromRequest(req);
if (!refreshToken) {
logger.warn('No refresh token available for refresh attempt');
return this.redirectToSSO();
}
const refreshResult = await this.performTokenRefresh(refreshToken);
if (refreshResult.success) {
return this.handleSuccessfulRefresh(req, refreshResult.tokens);
} else {
logger.warn('Token refresh failed:', refreshResult.error);
return this.redirectToSSO();
}
} catch (error) {
logger.error('Token refresh error:', error.message);
return this.redirectToSSO();
}
}
/**
* Perform token refresh API call
* @private
* @param {string} refreshToken - Refresh token
* @returns {object} Refresh result
*/
async performTokenRefresh(refreshToken) {
try {
const refreshRes = await fetch(
`${this.SSO_API_URL}/auth/refresh-token`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken }),
}
);
if (refreshRes.status === 200) {
const resJson = await refreshRes.json();
logger.debug('Refresh token API response received');
const accessToken = resJson.result?.accessToken;
const newRefreshToken = resJson.result?.refreshToken;
if (!accessToken || !newRefreshToken) {
return {
success: false,
error: 'Missing tokens in refresh response',
tokens: null
};
}
return {
success: true,
error: null,
tokens: { accessToken, refreshToken: newRefreshToken }
};
} else {
const errorText = await refreshRes.text();
return {
success: false,
error: `Refresh API returned ${refreshRes.status}: ${errorText}`,
tokens: null
};
}
} catch (fetchError) {
return {
success: false,
error: `Refresh API call failed: ${fetchError.message}`,
tokens: null
};
}
}
/**
* Handle successful token refresh
* @private
* @param {Request} req - Request object
* @param {object} tokens - New tokens
* @returns {Response} Redirect response with new tokens
*/
async handleSuccessfulRefresh(req, tokens) {
try {
const { NextResponse } = await import('next/server');
const response = NextResponse.redirect(new URL(req.url));
this.setTokensInResponse({
response,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken
});
logger.info('Token refreshed successfully');
return response;
} catch (error) {
logger.error('Error handling successful refresh:', error.message);
return this.redirectToSSO();
}
}
/**
* Find user application by APP_KEY with enhanced validation
* @param {object} user - User object
* @returns {object} { app, statusAccess }
*/
findUserApplication(user) {
try {
if (!user) {
logger.warn('No user provided for application lookup');
return { app: null, statusAccess: false };
}
const apps = user.applications;
let app = null;
let statusAccess = false;
if (Array.isArray(apps)) {
app = apps.find((a) => a.applicationKey === this.APP_KEY);
statusAccess = !!app;
} else if (user.application && user.application.applicationKey === this.APP_KEY) {
app = user.application;
statusAccess = true;
}
logger.debug('Application lookup result:', {
appKey: this.APP_KEY,
found: !!app,
statusAccess
});
return { app, statusAccess };
} catch (error) {
logger.error('Error finding user application:', error.message);
return { app: null, statusAccess: false };
}
}
/**
* Handle middleware errors gracefully
* @private
* @param {Error} error - Error object
* @returns {Response} Error response
*/
async handleMiddlewareError(error) {
try {
logger.error('Middleware error occurred:', error.message);
// In development, provide more detailed error information
if (process.env.NODE_ENV === 'development') {
logger.error('Error stack:', error.stack);
}
return this.redirectToSSO();
} catch (redirectError) {
logger.error('Failed to handle middleware error:', redirectError.message);
// Fallback response if even redirect fails
const { NextResponse } = await import('next/server');
return new NextResponse('Authentication Error', { status: 500 });
}
}
/**
* Decode JWT from middleware (NextRequest)
* @param {Request} req - Request object
* @returns {object|null} Decoded payload
*/
async getPayload(req) {
return this.tokenUtils.getPayload(req);
}
/**
* Decode JWT from headers server-side (API Route, getServerSideProps, etc.)
* @param {Headers|IncomingHttpHeaders} headers - Headers object
* @returns {object|null} Decoded payload
*/
async getPayloadFromHeaders(headers) {
return this.tokenUtils.getPayloadFromHeaders(headers);
}
/**
* Get cookie options for server-side with enhanced configuration
* @returns {object} Cookie options
*/
getCookieOptions() {
const validatedDomain = this.validateCookieDomain(this.COOKIE_DOMAIN);
const options = {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 30 * 60, // 30 minutes
};
if (validatedDomain) {
options.domain = validatedDomain;
}
return options;
}
/**
* Check if user has specific role
* @param {Request} req - Request object with user context
* @param {string} requiredRole - Required role
* @returns {boolean} True if user has role
*/
hasRole(req, requiredRole) {
return req.role === requiredRole;
}
/**
* Check if user has specific subrole
* @param {Request} req - Request object with user context
* @param {string} requiredSubrole - Required subrole
* @returns {boolean} True if user has subrole
*/
hasSubrole(req, requiredSubrole) {
return req.subrole === requiredSubrole;
}
/**
* Create role-based middleware
* @param {string|string[]} allowedRoles - Allowed roles
* @returns {Function} Role-based middleware
*/
requireRole(allowedRoles) {
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
return (handler) => {
return this.withSSOValidation(async (req) => {
if (!roles.includes(req.role)) {
logger.warn(`Access denied. Required roles: ${roles.join(', ')}, User role: ${req.role}`);
const { NextResponse } = await import('next/server');
return new NextResponse('Forbidden', { status: 403 });
}
return handler(req);
});
};
}
}