import jwt, { SignOptions } from 'jsonwebtoken'; import { Request, Response } from 'express'; import { UserState } from '../../Domain/User/UserAggregate'; export interface TokenPayload { userId: string; authLevel: 0 | 1; userStatus: UserState; orgId: string; type?: 'access'; iat?: number; exp?: number; } export interface RefreshTokenPayload { userId: string; type: 'refresh'; orgId?: string; tokenId?: string; // For token rotation/revocation iat?: number; exp?: number; } export interface TokenPair { accessToken: string; refreshToken: string; } export class JWTService { private readonly secretKey: string; private readonly refreshSecretKey: string; private readonly tokenExpiry: number; private readonly refreshTokenExpiry: number; private readonly cookieName: string; private readonly refreshCookieName: string; constructor() { this.secretKey = process.env.JWT_SECRET || 'your-secret-key'; this.refreshSecretKey = process.env.JWT_REFRESH_SECRET || this.secretKey + '_refresh'; // Access token expiry (short-lived) let expiry = 1800; // Default 30 minutes for better security if (process.env.JWT_EXPIRY) { expiry = parseInt(process.env.JWT_EXPIRY); } else if (process.env.JWT_EXPIRATION) { expiry = this.parseDuration(process.env.JWT_EXPIRATION); } // Refresh token expiry (long-lived) let refreshExpiry = 604800; // Default 7 days if (process.env.JWT_REFRESH_EXPIRATION) { refreshExpiry = this.parseDuration(process.env.JWT_REFRESH_EXPIRATION); } this.tokenExpiry = expiry; this.refreshTokenExpiry = refreshExpiry; this.cookieName = 'auth_token'; this.refreshCookieName = 'refresh_token'; if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) { throw new Error('JWT_SECRET environment variable must be set in production'); } } /** * Create a pair of access and refresh tokens */ public createTokenPair(payload: Omit): TokenPair { const now = Math.floor(Date.now() / 1000); // Create access token const accessTokenPayload: TokenPayload = { ...payload, type: 'access', iat: now, exp: now + this.tokenExpiry }; const accessToken = jwt.sign(accessTokenPayload, this.secretKey); // Create refresh token const refreshTokenPayload: RefreshTokenPayload = { userId: payload.userId, type: 'refresh', orgId: payload.orgId, iat: now, exp: now + this.refreshTokenExpiry }; const refreshToken = jwt.sign(refreshTokenPayload, this.refreshSecretKey); return { accessToken, refreshToken }; } /** * Create access and refresh tokens and set cookies (for cookie-based auth) */ create(payload: Omit, res: Response): TokenPair { const tokenPair = this.createTokenPair(payload); this.setTokenCookies(res, tokenPair); return tokenPair; } /** * Check if the request is using Bearer token authentication */ private isUsingBearerAuth(req: Request): boolean { // No cookie but has Authorization header return !req.cookies?.[this.cookieName] && !!req.headers.authorization && req.headers.authorization.startsWith('Bearer '); } /** * Verify a refresh token */ public verifyRefreshToken(token: string): RefreshTokenPayload | null { try { const decoded = jwt.verify(token, this.refreshSecretKey) as RefreshTokenPayload; if (decoded.type !== 'refresh') { return null; } return decoded; } catch (error) { return null; } } /** * Attempt to refresh tokens using refresh token from cookies or headers */ public attemptTokenRefresh(req: Request, res: Response): TokenPair | null { try { // Try to get refresh token from cookie first let refreshToken = req.cookies[this.refreshCookieName]; // If no cookie, try X-Refresh-Token header if (!refreshToken) { refreshToken = req.headers['x-refresh-token'] as string; } if (!refreshToken) { return null; } const refreshPayload = this.verifyRefreshToken(refreshToken); if (!refreshPayload) { return null; } // Create new token pair const newTokenPair = this.createTokenPair({ userId: refreshPayload.userId, authLevel: 0, // Default auth level, should be fetched from user data userStatus: UserState.VERIFIED_REGULAR, // Default status, should be fetched from user data orgId: refreshPayload.orgId || '' }); // Set new tokens based on authentication method if (req.cookies[this.cookieName] || req.cookies[this.refreshCookieName]) { // Cookie-based auth: set new cookies this.setTokenCookies(res, newTokenPair); } else { // Header-based auth: send tokens in response headers res.setHeader('X-New-Access-Token', newTokenPair.accessToken); res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken); res.setHeader('X-Token-Refreshed', 'true'); } return newTokenPair; } catch (error) { return null; } } /** * Set token cookies for cookie-based authentication */ private setTokenCookies(res: Response, tokenPair: TokenPair): void { // Set access token cookie res.cookie(this.cookieName, tokenPair.accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: this.tokenExpiry * 1000, }); // Set refresh token cookie res.cookie(this.refreshCookieName, tokenPair.refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: this.refreshTokenExpiry * 1000, }); } verify(req: Request): TokenPayload | null { try { // First try to get token from cookie let token = req.cookies[this.cookieName]; // If no cookie token, try Authorization header if (!token) { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.substring(7); } } if (!token) return null; const decoded = jwt.verify(token, this.secretKey) as TokenPayload; return decoded; } catch (error) { return null; } } /** * Logout user by clearing tokens */ public logout(req: Request, res: Response): void { // Clear cookies if they exist if (req.cookies[this.cookieName]) { res.clearCookie(this.cookieName, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); } if (req.cookies[this.refreshCookieName]) { res.clearCookie(this.refreshCookieName, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); } // For bearer token auth, set headers to indicate logout res.setHeader('X-Auth-Logout', 'true'); res.setHeader('X-Clear-Tokens', 'true'); } // Check if token needs refresh (within 25% of expiry time) shouldRefreshToken(payload: TokenPayload): boolean { if (!payload.exp || !payload.iat) return false; const now = Math.floor(Date.now() / 1000); const tokenAge = now - payload.iat; const tokenLifetime = payload.exp - payload.iat; const refreshThreshold = tokenLifetime * 0.75; // Refresh when 75% of lifetime has passed return tokenAge >= refreshThreshold; } // Conditionally refresh token only if needed refreshIfNeeded(payload: TokenPayload, res: Response, req?: Request): boolean { if (this.shouldRefreshToken(payload)) { if (req) { // Try to use the new refresh token system const newTokenPair = this.attemptTokenRefresh(req, res); if (newTokenPair) { return true; } } // Fallback: create new token pair const freshPayload: Omit = { userId: payload.userId, authLevel: payload.authLevel, userStatus: payload.userStatus, orgId: payload.orgId }; // Check if using Bearer authentication if (req && this.isUsingBearerAuth(req)) { // For Bearer auth, create token pair and add to headers const newTokenPair = this.createTokenPair(freshPayload); res.setHeader('X-New-Access-Token', newTokenPair.accessToken); res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken); res.setHeader('X-Token-Refreshed', 'true'); } else { // For cookie auth, create token pair and set cookies const newTokenPair = this.create(freshPayload, res); this.setTokenCookies(res, newTokenPair); } return true; } return false; } /** * Parse duration string to seconds (e.g., "24h", "7d", "30m") * @param duration Duration string * @returns Duration in seconds */ private parseDuration(duration: string): number { const match = duration.match(/^(\d+)([smhd])$/); if (!match) { throw new Error(`Invalid duration format: ${duration}. Use format like '24h', '7d', '30m'`); } const [, value, unit] = match; const num = parseInt(value); switch (unit) { case 's': return num; // seconds case 'm': return num * 60; // minutes case 'h': return num * 60 * 60; // hours case 'd': return num * 60 * 60 * 24; // days default: throw new Error(`Unsupported duration unit: ${unit}`); } } }