UNPKG

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
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); }); }; } }