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
349 lines (309 loc) • 10.5 kB
JavaScript
import { logger } from '../../logger.js';
/**
* Enhanced TokenUtils class with better error handling and reduced duplication
*/
export class TokenUtils {
constructor(config) {
if (!config) {
throw new Error('TokenUtils requires a configuration object');
}
this.config = config;
}
/**
* Get tokens from request object (server-side version)
* @param {Request} req - The request object
* @returns {object} { accessToken, refreshToken }
*/
getTokensFromRequest(req) {
if (!req) {
logger.error('Request object is required for server-side token retrieval');
return { accessToken: null, refreshToken: null };
}
try {
const accessToken = this.getTokenFromRequest(req);
const refreshToken = this.getRefreshTokenFromRequest(req);
return { accessToken, refreshToken };
} catch (error) {
logger.error('Error retrieving tokens from request:', error.message);
return { accessToken: null, refreshToken: null };
}
}
/**
* Get access token from cookies or localStorage (via headers)
* @param {Request} req - Request object
* @returns {string|null} Access token
*/
getTokenFromRequest(req) {
return this.getTokenFromSource(req, {
cookieName: this.config.COOKIE_ACCESS_TOKEN_NAME,
headerName: this.config.LOCAL_STORAGE_ACCESS_TOKEN_KEY,
tokenType: 'access'
});
}
/**
* Get refresh token from cookies or localStorage (via headers)
* @param {Request} req - Request object
* @returns {string|null} Refresh token
*/
getRefreshTokenFromRequest(req) {
return this.getTokenFromSource(req, {
cookieName: this.config.COOKIE_REFRESH_TOKEN_NAME,
headerName: this.config.LOCAL_STORAGE_REFRESH_TOKEN_KEY,
tokenType: 'refresh'
});
}
/**
* Generic method to get token from various sources
* @private
* @param {Request} req - Request object
* @param {object} options - Token source options
* @returns {string|null} Token value
*/
getTokenFromSource(req, { cookieName, headerName, tokenType }) {
try {
// First try cookies
const cookieToken = this.getTokenFromCookies(req, cookieName);
if (cookieToken) {
logger.debug(`Found ${tokenType} token in cookies`);
return cookieToken;
}
// Then try localStorage via custom header
const headerToken = this.getTokenFromHeaders(req, headerName);
if (headerToken) {
logger.debug(`Found ${tokenType} token in localStorage header`);
return headerToken;
}
logger.debug(`No ${tokenType} token found`);
return null;
} catch (error) {
logger.error(`Error getting ${tokenType} token:`, error.message);
return null;
}
}
/**
* Get token from cookies
* @private
* @param {Request} req - Request object
* @param {string} cookieName - Cookie name
* @returns {string|null} Token from cookies
*/
getTokenFromCookies(req, cookieName) {
try {
return req.cookies?.get(cookieName)?.value || null;
} catch (error) {
logger.debug('Error reading from cookies:', error.message);
return null;
}
}
/**
* Get token from headers
* @private
* @param {Request} req - Request object
* @param {string} headerName - Header name
* @returns {string|null} Token from headers
*/
getTokenFromHeaders(req, headerName) {
try {
return req.headers?.get(headerName) || null;
} catch (error) {
logger.debug('Error reading from headers:', error.message);
return null;
}
}
/**
* Set tokens in response cookies and headers with enhanced error handling
* @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 }) {
if (!response || !accessToken || !refreshToken) {
logger.error('Missing required parameters for setting tokens');
return;
}
logger.info('Setting tokens in response');
try {
const cookieOptions = this.getCookieOptions();
// Set access token cookie (30 minutes)
this.setTokenCookie(response, {
name: this.config.COOKIE_ACCESS_TOKEN_NAME,
value: accessToken,
options: { ...cookieOptions, maxAge: 30 * 60 }
});
// Set refresh token cookie (6 hours)
this.setTokenCookie(response, {
name: this.config.COOKIE_REFRESH_TOKEN_NAME,
value: refreshToken,
options: { ...cookieOptions, maxAge: 6 * 60 * 60 }
});
// Set headers for client-side handling
this.setTokenHeaders(response, accessToken, refreshToken);
logger.info('Tokens set successfully in response');
} catch (error) {
logger.error('Error setting tokens in response:', error.message);
this.setFallbackHeaders(response, accessToken, refreshToken);
}
}
/**
* Set individual token cookie with error handling
* @private
* @param {NextResponse} response - Response object
* @param {object} params - Cookie parameters
*/
setTokenCookie(response, { name, value, options }) {
try {
response.cookies.set(name, value, options);
logger.debug(`${name} cookie set successfully`);
} catch (error) {
logger.error(`Error setting ${name} cookie:`, error.message);
throw error;
}
}
/**
* Set token headers for client-side handling
* @private
* @param {NextResponse} response - Response object
* @param {string} accessToken - Access token
* @param {string} refreshToken - Refresh token
*/
setTokenHeaders(response, accessToken, refreshToken) {
const headers = {
[this.config.NEXT_PUBLIC_COOKIE_ACCESS_TOKEN_NAME]: accessToken,
[this.config.NEXT_PUBLIC_COOKIE_REFRESH_TOKEN_NAME]: refreshToken,
[this.config.NEXT_PUBLIC_COOKIE_DOMAIN]: this.config.COOKIE_DOMAIN,
[this.config.LOCAL_STORAGE_ACCESS_TOKEN_KEY]: accessToken,
[this.config.LOCAL_STORAGE_REFRESH_TOKEN_KEY]: refreshToken
};
Object.entries(headers).forEach(([key, value]) => {
if (value) {
response.headers.set(key, value);
}
});
}
/**
* Set fallback headers when cookie setting fails
* @private
* @param {NextResponse} response - Response object
* @param {string} accessToken - Access token
* @param {string} refreshToken - Refresh token
*/
setFallbackHeaders(response, accessToken, refreshToken) {
logger.warn('Using fallback headers for token storage');
const fallbackHeaders = {
'x-set-access-token': accessToken,
'x-set-refresh-token': refreshToken,
'x-localstorage-access-key': this.config.LOCAL_STORAGE_ACCESS_TOKEN_KEY,
'x-localstorage-refresh-key': this.config.LOCAL_STORAGE_REFRESH_TOKEN_KEY
};
Object.entries(fallbackHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
}
/**
* Get cookie options with domain validation
* @private
* @returns {object} Cookie options
*/
getCookieOptions() {
const validatedDomain = this.config.validateCookieDomain(this.config.COOKIE_DOMAIN);
const options = {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/"
};
if (validatedDomain) {
options.domain = validatedDomain;
}
return options;
}
/**
* Decode JWT from middleware (NextRequest) with enhanced error handling
* @param {Request} req - Request object
* @returns {object|null} Decoded payload
*/
async getPayload(req) {
try {
const token = this.getTokenFromRequest(req);
if (!token) {
logger.debug('No token found for payload extraction');
return null;
}
return await this.verifyAndDecodeToken(token);
} catch (error) {
logger.error('Error getting payload from request:', error.message);
return null;
}
}
/**
* Decode JWT from headers server-side with enhanced error handling
* @param {Headers|IncomingHttpHeaders} headers - Headers object
* @returns {object|null} Decoded payload
*/
async getPayloadFromHeaders(headers) {
try {
const token = this.extractTokenFromHeaders(headers);
if (!token) {
logger.debug('No token found in headers for payload extraction');
return null;
}
return await this.verifyAndDecodeToken(token);
} catch (error) {
logger.error('Error getting payload from headers:', error.message);
return null;
}
}
/**
* Extract token from headers object
* @private
* @param {Headers|IncomingHttpHeaders} headers - Headers object
* @returns {string|null} Extracted token
*/
extractTokenFromHeaders(headers) {
// Try cookies first
const cookieHeader = headers.get ? headers.get('cookie') : headers.cookie;
if (cookieHeader) {
const token = this.parseTokenFromCookieHeader(cookieHeader);
if (token) return token;
}
// Try localStorage header
const headerToken = headers.get
? headers.get(this.config.LOCAL_STORAGE_ACCESS_TOKEN_KEY)
: headers[this.config.LOCAL_STORAGE_ACCESS_TOKEN_KEY];
return headerToken || null;
}
/**
* Parse token from cookie header string
* @private
* @param {string} cookieHeader - Cookie header string
* @returns {string|null} Parsed token
*/
parseTokenFromCookieHeader(cookieHeader) {
try {
const tokenCookie = cookieHeader
.split(';')
.find(c => c.trim().startsWith(`${this.config.COOKIE_ACCESS_TOKEN_NAME}=`));
return tokenCookie ? tokenCookie.split('=')[1] : null;
} catch (error) {
logger.debug('Error parsing cookie header:', error.message);
return null;
}
}
/**
* Verify and decode JWT token
* @private
* @param {string} token - JWT token
* @returns {object|null} Decoded payload
*/
async verifyAndDecodeToken(token) {
try {
const { jwtVerify } = await import('jose');
const payload = await jwtVerify(token, this.config.JWT_SECRET);
return payload?.payload || null;
} catch (error) {
logger.debug('JWT verification failed:', error.message);
return null;
}
}
}