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