import jwt, { SignOptions } from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { Repository } from 'typeorm'; import { AppDataSource } from '../../../config/typeorm.js'; import { config } from '../../../config/index.js'; import { User, Session, SessionStatus } from '../entities/index.js'; import { blacklistToken, isTokenBlacklisted } from '../../../config/redis.js'; import { logger } from '../../../shared/utils/logger.js'; import { UnauthorizedError } from '../../../shared/types/index.js'; // ===== Interfaces ===== /** * JWT Payload structure for access and refresh tokens */ export interface JwtPayload { sub: string; // User ID tid: string; // Tenant ID email: string; roles: string[]; jti: string; // JWT ID único iat: number; exp: number; } /** * Token pair returned after authentication */ export interface TokenPair { accessToken: string; refreshToken: string; accessTokenExpiresAt: Date; refreshTokenExpiresAt: Date; sessionId: string; } /** * Request metadata for session tracking */ export interface RequestMetadata { ipAddress: string; userAgent: string; } // ===== TokenService Class ===== /** * Service for managing JWT tokens with blacklist support via Redis * and session tracking via TypeORM */ class TokenService { private sessionRepository: Repository; // Configuration constants private readonly ACCESS_TOKEN_EXPIRY = '15m'; private readonly REFRESH_TOKEN_EXPIRY = '7d'; private readonly ALGORITHM = 'HS256' as const; constructor() { this.sessionRepository = AppDataSource.getRepository(Session); } /** * Generates a new token pair (access + refresh) and creates a session * @param user - User entity with roles loaded * @param metadata - Request metadata (IP, user agent) * @returns Promise - Access and refresh tokens with expiration dates */ async generateTokenPair(user: User, metadata: RequestMetadata): Promise { try { logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId }); // Extract role codes from user roles const roles = user.roles ? user.roles.map(role => role.code) : []; // Calculate expiration dates const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY); const refreshTokenExpiresAt = this.calculateExpiration(this.REFRESH_TOKEN_EXPIRY); // Generate unique JWT IDs const accessJti = this.generateJti(); const refreshJti = this.generateJti(); // Generate access token const accessToken = this.generateToken({ sub: user.id, tid: user.tenantId, email: user.email, roles, jti: accessJti, }, this.ACCESS_TOKEN_EXPIRY); // Generate refresh token const refreshToken = this.generateToken({ sub: user.id, tid: user.tenantId, email: user.email, roles, jti: refreshJti, }, this.REFRESH_TOKEN_EXPIRY); // Create session record in database const session = this.sessionRepository.create({ userId: user.id, token: accessJti, // Store JTI instead of full token refreshToken: refreshJti, // Store JTI instead of full token status: SessionStatus.ACTIVE, expiresAt: accessTokenExpiresAt, refreshExpiresAt: refreshTokenExpiresAt, ipAddress: metadata.ipAddress, userAgent: metadata.userAgent, }); await this.sessionRepository.save(session); logger.info('Token pair generated successfully', { userId: user.id, sessionId: session.id, tenantId: user.tenantId, }); return { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, sessionId: session.id, }; } catch (error) { logger.error('Error generating token pair', { error: (error as Error).message, userId: user.id, }); throw error; } } /** * Refreshes an access token using a valid refresh token * Implements token replay detection for enhanced security * @param refreshToken - Valid refresh token * @param metadata - Request metadata (IP, user agent) * @returns Promise - New access and refresh tokens * @throws UnauthorizedError if token is invalid or replay detected */ async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise { try { logger.debug('Refreshing tokens'); // Verify refresh token const payload = this.verifyRefreshToken(refreshToken); // Find active session with this refresh token JTI const session = await this.sessionRepository.findOne({ where: { refreshToken: payload.jti, status: SessionStatus.ACTIVE, }, relations: ['user', 'user.roles'], }); if (!session) { logger.warn('Refresh token not found or session inactive', { jti: payload.jti, }); throw new UnauthorizedError('Refresh token inválido o expirado'); } // Check if session has already been used (token replay detection) if (session.revokedAt !== null) { logger.error('TOKEN REPLAY DETECTED - Session was already used', { sessionId: session.id, userId: session.userId, jti: payload.jti, }); // SECURITY: Revoke ALL user sessions on replay detection const revokedCount = await this.revokeAllUserSessions( session.userId, 'Token replay detected' ); logger.error('All user sessions revoked due to token replay', { userId: session.userId, revokedCount, }); throw new UnauthorizedError('Replay de token detectado. Todas las sesiones han sido revocadas por seguridad.'); } // Verify session hasn't expired if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) { logger.warn('Refresh token expired', { sessionId: session.id, expiredAt: session.refreshExpiresAt, }); await this.revokeSession(session.id, 'Token expired'); throw new UnauthorizedError('Refresh token expirado'); } // Mark current session as used (revoke it) session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = 'Used for refresh'; await this.sessionRepository.save(session); // Generate new token pair const newTokenPair = await this.generateTokenPair(session.user, metadata); logger.info('Tokens refreshed successfully', { userId: session.userId, oldSessionId: session.id, newSessionId: newTokenPair.sessionId, }); return newTokenPair; } catch (error) { logger.error('Error refreshing tokens', { error: (error as Error).message, }); throw error; } } /** * Revokes a session and blacklists its access token * @param sessionId - Session ID to revoke * @param reason - Reason for revocation */ async revokeSession(sessionId: string, reason: string): Promise { try { logger.debug('Revoking session', { sessionId, reason }); const session = await this.sessionRepository.findOne({ where: { id: sessionId }, }); if (!session) { logger.warn('Session not found for revocation', { sessionId }); return; } // Mark session as revoked session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = reason; await this.sessionRepository.save(session); // Blacklist the access token (JTI) in Redis const remainingTTL = this.calculateRemainingTTL(session.expiresAt); if (remainingTTL > 0) { await this.blacklistAccessToken(session.token, remainingTTL); } logger.info('Session revoked successfully', { sessionId, reason }); } catch (error) { logger.error('Error revoking session', { error: (error as Error).message, sessionId, }); throw error; } } /** * Revokes all active sessions for a user * Used for security events like password change or token replay detection * @param userId - User ID whose sessions to revoke * @param reason - Reason for revocation * @returns Promise - Number of sessions revoked */ async revokeAllUserSessions(userId: string, reason: string): Promise { try { logger.debug('Revoking all user sessions', { userId, reason }); const sessions = await this.sessionRepository.find({ where: { userId, status: SessionStatus.ACTIVE, }, }); if (sessions.length === 0) { logger.debug('No active sessions found for user', { userId }); return 0; } // Revoke each session for (const session of sessions) { session.status = SessionStatus.REVOKED; session.revokedAt = new Date(); session.revokedReason = reason; // Blacklist access token const remainingTTL = this.calculateRemainingTTL(session.expiresAt); if (remainingTTL > 0) { await this.blacklistAccessToken(session.token, remainingTTL); } } await this.sessionRepository.save(sessions); logger.info('All user sessions revoked', { userId, count: sessions.length, reason, }); return sessions.length; } catch (error) { logger.error('Error revoking all user sessions', { error: (error as Error).message, userId, }); throw error; } } /** * Adds an access token to the Redis blacklist * @param jti - JWT ID to blacklist * @param expiresIn - TTL in seconds */ async blacklistAccessToken(jti: string, expiresIn: number): Promise { try { await blacklistToken(jti, expiresIn); logger.debug('Access token blacklisted', { jti, expiresIn }); } catch (error) { logger.error('Error blacklisting access token', { error: (error as Error).message, jti, }); // Don't throw - blacklist is optional (Redis might be unavailable) } } /** * Checks if an access token is blacklisted * @param jti - JWT ID to check * @returns Promise - true if blacklisted */ async isAccessTokenBlacklisted(jti: string): Promise { try { return await isTokenBlacklisted(jti); } catch (error) { logger.error('Error checking token blacklist', { error: (error as Error).message, jti, }); // Return false on error - fail open return false; } } // ===== Private Helper Methods ===== /** * Generates a JWT token with the specified payload and expiry * @param payload - Token payload (without iat/exp) * @param expiresIn - Expiration time string (e.g., '15m', '7d') * @returns string - Signed JWT token */ private generateToken(payload: Omit, expiresIn: string): string { return jwt.sign(payload, config.jwt.secret, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'], algorithm: this.ALGORITHM, } as SignOptions); } /** * Verifies an access token and returns its payload * @param token - JWT access token * @returns JwtPayload - Decoded payload * @throws UnauthorizedError if token is invalid */ private verifyAccessToken(token: string): JwtPayload { try { return jwt.verify(token, config.jwt.secret, { algorithms: [this.ALGORITHM], }) as JwtPayload; } catch (error) { logger.warn('Invalid access token', { error: (error as Error).message, }); throw new UnauthorizedError('Access token inválido o expirado'); } } /** * Verifies a refresh token and returns its payload * @param token - JWT refresh token * @returns JwtPayload - Decoded payload * @throws UnauthorizedError if token is invalid */ private verifyRefreshToken(token: string): JwtPayload { try { return jwt.verify(token, config.jwt.secret, { algorithms: [this.ALGORITHM], }) as JwtPayload; } catch (error) { logger.warn('Invalid refresh token', { error: (error as Error).message, }); throw new UnauthorizedError('Refresh token inválido o expirado'); } } /** * Generates a unique JWT ID (JTI) using UUID v4 * @returns string - Unique identifier */ private generateJti(): string { return uuidv4(); } /** * Calculates expiration date from a time string * @param expiresIn - Time string (e.g., '15m', '7d') * @returns Date - Expiration date */ private calculateExpiration(expiresIn: string): Date { const unit = expiresIn.slice(-1); const value = parseInt(expiresIn.slice(0, -1), 10); const now = new Date(); switch (unit) { case 's': return new Date(now.getTime() + value * 1000); case 'm': return new Date(now.getTime() + value * 60 * 1000); case 'h': return new Date(now.getTime() + value * 60 * 60 * 1000); case 'd': return new Date(now.getTime() + value * 24 * 60 * 60 * 1000); default: throw new Error(`Invalid time unit: ${unit}`); } } /** * Calculates remaining TTL in seconds for a given expiration date * @param expiresAt - Expiration date * @returns number - Remaining seconds (0 if already expired) */ private calculateRemainingTTL(expiresAt: Date): number { const now = new Date(); const remainingMs = expiresAt.getTime() - now.getTime(); return Math.max(0, Math.floor(remainingMs / 1000)); } } // ===== Export Singleton Instance ===== export const tokenService = new TokenService();