315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
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<TokenPayload, 'type' | 'iat' | 'exp'>): 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<TokenPayload, 'type' | 'iat' | 'exp'>, 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<TokenPayload, 'iat' | 'exp' | 'type'> = {
|
|
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}`);
|
|
}
|
|
}
|
|
} |