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

577 lines (499 loc) 18.3 kB
import { SSOConfig } from '../config/sso-config.js'; import { logger } from '../../logger.js'; /** * Enhanced SSO Client-side utilities with improved browser compatibility and error handling */ export class SSOClientSide extends SSOConfig { constructor(options = {}) { super(options); this.initializeClientSide(); } /** * Initialize client-side specific configurations * @private */ initializeClientSide() { this.isBrowser = typeof window !== 'undefined'; this.supportsLocalStorage = this.checkLocalStorageSupport(); this.supportsCookies = this.checkCookieSupport(); logger.debug('Client-side environment:', { isBrowser: this.isBrowser, supportsLocalStorage: this.supportsLocalStorage, supportsCookies: this.supportsCookies }); } /** * Check if localStorage is supported and available * @private * @returns {boolean} True if localStorage is supported */ checkLocalStorageSupport() { if (!this.isBrowser) return false; try { const testKey = '__sso_test__'; localStorage.setItem(testKey, 'test'); localStorage.removeItem(testKey); return true; } catch (error) { logger.warn('localStorage not available:', error.message); return false; } } /** * Check if cookies are supported and available * @private * @returns {boolean} True if cookies are supported */ checkCookieSupport() { if (!this.isBrowser) return false; try { document.cookie = '__sso_test__=test; path=/'; const supported = document.cookie.includes('__sso_test__=test'); document.cookie = '__sso_test__=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; return supported; } catch (error) { logger.warn('Cookies not available:', error.message); return false; } } /** * Get localStorage utilities for client-side with enhanced error handling * @returns {object} localStorage utilities */ getLocalStorageUtils() { const accessTokenKey = this.LOCAL_STORAGE_ACCESS_TOKEN_KEY; const refreshTokenKey = this.LOCAL_STORAGE_REFRESH_TOKEN_KEY; return { /** * Set tokens in localStorage * @param {object} params - Token parameters * @param {string} params.accessToken - Access token * @param {string} params.refreshToken - Refresh token */ setTokens: ({ accessToken, refreshToken }) => { if (!this.supportsLocalStorage) { logger.warn('localStorage not supported, tokens not saved'); return false; } try { logger.debug('Setting tokens in localStorage'); localStorage.setItem(accessTokenKey, accessToken); localStorage.setItem(refreshTokenKey, refreshToken); logger.debug('Tokens saved to localStorage successfully'); return true; } catch (error) { logger.error('Failed to save tokens to localStorage:', error.message); return false; } }, /** * Get access token from localStorage * @returns {string|null} Access token */ getAccessToken: () => { if (!this.supportsLocalStorage) return null; try { return localStorage.getItem(accessTokenKey); } catch (error) { logger.error('Failed to get access token from localStorage:', error.message); return null; } }, /** * Get refresh token from localStorage * @returns {string|null} Refresh token */ getRefreshToken: () => { if (!this.supportsLocalStorage) return null; try { return localStorage.getItem(refreshTokenKey); } catch (error) { logger.error('Failed to get refresh token from localStorage:', error.message); return null; } }, /** * Clear tokens from localStorage */ clearTokens: () => { if (!this.supportsLocalStorage) return; try { logger.debug('Clearing tokens from localStorage'); localStorage.removeItem(accessTokenKey); localStorage.removeItem(refreshTokenKey); logger.debug('Tokens cleared from localStorage successfully'); } catch (error) { logger.error('Failed to clear tokens from localStorage:', error.message); } }, /** * Add tokens to headers for API requests * @param {object} headers - Existing headers * @returns {object} Headers with tokens */ addTokensToHeaders: (headers = {}) => { if (!this.supportsLocalStorage) return headers; try { const accessToken = localStorage.getItem(accessTokenKey); const refreshToken = localStorage.getItem(refreshTokenKey); const newHeaders = { ...headers }; if (accessToken) { newHeaders[accessTokenKey] = accessToken; } if (refreshToken) { newHeaders[refreshTokenKey] = refreshToken; } return newHeaders; } catch (error) { logger.error('Failed to add tokens to headers:', error.message); return headers; } } }; } /** * Get cookie utilities for client-side with enhanced error handling * @returns {object} Cookie utilities */ getCookieUtils() { const accessTokenKey = this.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME.split(':')[0]; const refreshTokenKey = this.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME.split(':')[0]; return { /** * Set tokens in cookies and localStorage * @param {object} params - Token parameters * @param {string} params.accessToken - Access token * @param {string} params.refreshToken - Refresh token */ setTokens: ({ accessToken, refreshToken }) => { let success = false; // Try to set cookies first if (this.supportsCookies) { success = this.setClientCookies({ accessToken, refreshToken }); } // Always try localStorage as backup const localStorageSuccess = this.getLocalStorageUtils().setTokens({ accessToken, refreshToken }); return success || localStorageSuccess; }, /** * Get access token from cookies or localStorage * @returns {string|null} Access token */ getAccessToken: () => { // Try cookies first if (this.supportsCookies) { const cookieToken = this.getTokenFromCookies(accessTokenKey); if (cookieToken) return cookieToken; } // Fallback to localStorage return this.getLocalStorageUtils().getAccessToken(); }, /** * Get refresh token from cookies or localStorage * @returns {string|null} Refresh token */ getRefreshToken: () => { // Try cookies first if (this.supportsCookies) { const cookieToken = this.getTokenFromCookies(refreshTokenKey); if (cookieToken) return cookieToken; } // Fallback to localStorage return this.getLocalStorageUtils().getRefreshToken(); }, /** * Clear tokens from cookies and localStorage */ clearTokens: () => { if (this.supportsCookies) { this.clearClientCookies(); } this.getLocalStorageUtils().clearTokens(); }, /** * Add tokens to headers for API requests * @param {object} headers - Existing headers * @returns {object} Headers with tokens */ addTokensToHeaders: (headers = {}) => { const newHeaders = { ...headers }; // Try cookies first if (this.supportsCookies) { const accessToken = this.getTokenFromCookies(accessTokenKey); const refreshToken = this.getTokenFromCookies(refreshTokenKey); if (accessToken) newHeaders[accessTokenKey] = accessToken; if (refreshToken) newHeaders[refreshTokenKey] = refreshToken; } // Add localStorage tokens (will override if present) return this.getLocalStorageUtils().addTokensToHeaders(newHeaders); } }; } /** * Get token from cookies with error handling * @private * @param {string} tokenName - Token name * @returns {string|null} Token value */ getTokenFromCookies(tokenName) { if (!this.isBrowser || !this.supportsCookies) return null; try { const cookies = document.cookie.split('; '); const tokenCookie = cookies.find(row => row.startsWith(`${tokenName}=`)); return tokenCookie ? tokenCookie.split('=')[1] : null; } catch (error) { logger.error(`Failed to get ${tokenName} from cookies:`, error.message); return null; } } /** * Set tokens in client-side cookies with enhanced error handling * @param {object} params - Token parameters * @param {string} params.accessToken - Access token * @param {string} params.refreshToken - Refresh token * @returns {boolean} True if successful */ setClientCookies({ accessToken, refreshToken }) { if (!this.isBrowser || !this.supportsCookies) { logger.warn('Cannot set cookies: browser environment not available or cookies not supported'); return false; } try { logger.debug('Setting client-side cookies'); const domain = this.NEXT_PUBLIC_COOKIE_DOMAIN; const validatedDomain = this.validateCookieDomain(domain); const cookieOptions = this.buildCookieOptions(validatedDomain); const accessTokenName = this.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME.split(':')[0]; const refreshTokenName = this.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME.split(':')[0]; // Set access token (30 minutes) const accessExpiry = new Date(Date.now() + 30 * 60 * 1000); const accessCookie = this.buildCookieString(accessTokenName, accessToken, accessExpiry, cookieOptions); document.cookie = accessCookie; // Set refresh token (6 hours) const refreshExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000); const refreshCookie = this.buildCookieString(refreshTokenName, refreshToken, refreshExpiry, cookieOptions); document.cookie = refreshCookie; logger.debug('Client-side cookies set successfully'); return true; } catch (error) { logger.error('Failed to set client-side cookies:', error.message); return false; } } /** * Build cookie options object * @private * @param {string} validatedDomain - Validated domain * @returns {object} Cookie options */ buildCookieOptions(validatedDomain) { const options = { secure: this.isBrowser && window.location.protocol === 'https:', sameSite: 'Lax', path: '/' }; if (validatedDomain) { options.domain = validatedDomain; } return options; } /** * Build cookie string * @private * @param {string} name - Cookie name * @param {string} value - Cookie value * @param {Date} expiry - Expiry date * @param {object} options - Cookie options * @returns {string} Cookie string */ buildCookieString(name, value, expiry, options) { const parts = [ `${name}=${value}`, `Expires=${expiry.toUTCString()}`, `Path=${options.path}` ]; if (options.secure) parts.push('Secure'); if (options.sameSite) parts.push(`SameSite=${options.sameSite}`); if (options.domain) parts.push(`Domain=${options.domain}`); return parts.join('; '); } /** * Clear client-side cookies with enhanced error handling * @returns {boolean} True if successful */ clearClientCookies() { if (!this.isBrowser || !this.supportsCookies) { logger.warn('Cannot clear cookies: browser environment not available or cookies not supported'); return false; } try { logger.debug('Clearing client-side cookies'); const domain = this.NEXT_PUBLIC_COOKIE_DOMAIN; const validatedDomain = this.validateCookieDomain(domain); const path = '; Path=/'; const domainStr = validatedDomain ? `; Domain=${validatedDomain}` : ''; const pastDate = '; Expires=Thu, 01 Jan 1970 00:00:00 GMT'; const accessTokenName = this.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME.split(':')[0]; const refreshTokenName = this.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME.split(':')[0]; document.cookie = `${accessTokenName}=${pastDate}${path}${domainStr}`; document.cookie = `${refreshTokenName}=${pastDate}${path}${domainStr}`; logger.debug('Client-side cookies cleared successfully'); return true; } catch (error) { logger.error('Failed to clear client-side cookies:', error.message); return false; } } /** * Handle SSO callback and extract tokens with enhanced validation * @param {URLSearchParams} searchParams - URL search parameters * @returns {object|null} Extracted tokens or null */ handleSSOCallback(searchParams) { try { if (!searchParams) { logger.warn('No search parameters provided for SSO callback'); return null; } const accessToken = searchParams.get('access_token'); const refreshToken = searchParams.get('refresh_token'); if (!accessToken || !refreshToken) { logger.warn('Missing tokens in SSO callback parameters'); return null; } // Validate token format (basic check) if (!this.isValidTokenFormat(accessToken) || !this.isValidTokenFormat(refreshToken)) { logger.warn('Invalid token format in SSO callback'); return null; } // Store tokens using available methods const cookieUtils = this.getCookieUtils(); const success = cookieUtils.setTokens({ accessToken, refreshToken }); if (success) { logger.info('SSO callback tokens processed successfully'); return { accessToken, refreshToken }; } else { logger.error('Failed to store tokens from SSO callback'); return null; } } catch (error) { logger.error('Error handling SSO callback:', error.message); return null; } } /** * Basic token format validation * @private * @param {string} token - Token to validate * @returns {boolean} True if format appears valid */ isValidTokenFormat(token) { if (!token || typeof token !== 'string') return false; // Basic JWT format check (three parts separated by dots) const parts = token.split('.'); return parts.length === 3 && parts.every(part => part.length > 0); } /** * Logout and clear all tokens with enhanced cleanup * @param {object} options - Logout options * @param {boolean} options.redirectToSSO - Whether to redirect to SSO logout * @param {string} options.returnUrl - Return URL after logout */ logout(options = {}) { try { logger.info('Initiating logout process'); // Clear all stored tokens this.getCookieUtils().clearTokens(); // Clear any additional client-side data this.clearAdditionalClientData(); logger.info('Client-side logout completed'); // Redirect to SSO logout if requested if (options.redirectToSSO !== false && this.isBrowser) { this.redirectToSSOLogout(options.returnUrl); } } catch (error) { logger.error('Error during logout:', error.message); // Force redirect even if cleanup failed if (this.isBrowser && options.redirectToSSO !== false) { this.redirectToSSOLogout(options.returnUrl); } } } /** * Clear additional client-side data * @private */ clearAdditionalClientData() { // Clear any session storage items if (this.isBrowser && typeof sessionStorage !== 'undefined') { try { // Clear SSO-related session storage items const keysToRemove = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (key && (key.includes('sso') || key.includes('auth'))) { keysToRemove.push(key); } } keysToRemove.forEach(key => sessionStorage.removeItem(key)); } catch (error) { logger.debug('Error clearing session storage:', error.message); } } } /** * Redirect to SSO logout * @private * @param {string} returnUrl - Return URL after logout */ redirectToSSOLogout(returnUrl) { try { const logoutUrl = new URL(this.SSO_LOGIN_URL); logoutUrl.searchParams.set('logout', 'true'); logoutUrl.searchParams.set('app_key', this.APP_KEY); if (returnUrl) { logoutUrl.searchParams.set('return_url', returnUrl); } logger.info('Redirecting to SSO logout:', logoutUrl.toString()); window.location.href = logoutUrl.toString(); } catch (error) { logger.error('Error redirecting to SSO logout:', error.message); // Fallback: redirect to login page window.location.href = this.SSO_LOGIN_URL; } } /** * Check if user is authenticated (has valid tokens) * @returns {boolean} True if authenticated */ isAuthenticated() { const cookieUtils = this.getCookieUtils(); const accessToken = cookieUtils.getAccessToken(); return !!accessToken && this.isValidTokenFormat(accessToken); } /** * Get current user tokens * @returns {object} Current tokens */ getCurrentTokens() { const cookieUtils = this.getCookieUtils(); return { accessToken: cookieUtils.getAccessToken(), refreshToken: cookieUtils.getRefreshToken() }; } /** * Create authenticated fetch wrapper * @param {string} url - Request URL * @param {object} options - Fetch options * @returns {Promise<Response>} Fetch response */ async authenticatedFetch(url, options = {}) { const cookieUtils = this.getCookieUtils(); const headers = cookieUtils.addTokensToHeaders(options.headers || {}); return fetch(url, { ...options, headers }); } }