// ============================================================================ // Trading Platform - Token Service // ============================================================================ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import crypto from 'crypto'; import { config } from '../../../config'; import { db } from '../../../shared/database'; import { sessionCache } from './session-cache.service'; import type { User, AuthTokens, JWTPayload, JWTRefreshPayload, Session, } from '../types/auth.types'; export class TokenService { private readonly accessTokenSecret: string; private readonly refreshTokenSecret: string; private readonly accessTokenExpiry: string; private readonly refreshTokenExpiry: string; private readonly refreshTokenExpiryMs: number; constructor() { this.accessTokenSecret = config.jwt.accessSecret; this.refreshTokenSecret = config.jwt.refreshSecret; this.accessTokenExpiry = config.jwt.accessExpiry; this.refreshTokenExpiry = config.jwt.refreshExpiry; this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry); } private parseExpiry(expiry: string): number { const match = expiry.match(/^(\d+)([smhd])$/); if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days const value = parseInt(match[1], 10); const unit = match[2]; switch (unit) { case 's': return value * 1000; case 'm': return value * 60 * 1000; case 'h': return value * 60 * 60 * 1000; case 'd': return value * 24 * 60 * 60 * 1000; default: return 7 * 24 * 60 * 60 * 1000; } } generateAccessToken(user: User, sessionId?: string): string { const payload: Omit = { sub: user.id, email: user.email, role: user.role, provider: user.primaryAuthProvider, sessionId, // Include sessionId for session validation (FASE 3) }; return jwt.sign(payload, this.accessTokenSecret, { expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'], }); } generateRefreshToken(userId: string, sessionId: string): string { const payload: Omit = { sub: userId, sessionId, }; return jwt.sign(payload, this.refreshTokenSecret, { expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'], }); } verifyAccessToken(token: string): JWTPayload | null { try { return jwt.verify(token, this.accessTokenSecret) as JWTPayload; } catch { return null; } } verifyRefreshToken(token: string): JWTRefreshPayload | null { try { return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload; } catch { return null; } } async createSession( userId: string, userAgent?: string, ipAddress?: string, deviceInfo?: Record ): Promise<{ session: Session; tokens: AuthTokens }> { const sessionId = uuidv4(); const refreshTokenValue = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs); // TODO: Add refresh_token_hash and refresh_token_issued_at to INSERT // after running migration: apps/database/migrations/2026-01-27_add_token_rotation.sql // const refreshTokenHash = this.hashToken(refreshTokenValue); // const issuedAt = new Date(); const result = await db.query( `INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt] ); const session = result.rows[0]; // Get user for access token const userResult = await db.query( 'SELECT * FROM users WHERE id = $1', [userId] ); const user = userResult.rows[0]; const accessToken = this.generateAccessToken(user, sessionId); const refreshToken = this.generateRefreshToken(userId, sessionId); return { session, tokens: { accessToken, refreshToken, expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000, tokenType: 'Bearer', }, }; } async refreshSession(refreshToken: string): Promise { const decoded = this.verifyRefreshToken(refreshToken); if (!decoded) return null; // Check session exists and is valid const sessionResult = await db.query( `SELECT * FROM sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`, [decoded.sessionId, decoded.sub] ); if (sessionResult.rows.length === 0) return null; const session = sessionResult.rows[0]; // Token rotation: Validate refresh token hash (if columns exist) if (session.refreshTokenHash) { const currentRefreshTokenHash = this.hashToken(refreshToken); if (session.refreshTokenHash !== currentRefreshTokenHash) { // Token reuse detected! Revoke all user sessions for security await this.revokeAllUserSessions(decoded.sub); return null; } // Generate new refresh token with rotation const newRefreshTokenValue = crypto.randomBytes(32).toString('hex'); const newRefreshTokenHash = this.hashToken(newRefreshTokenValue); const newIssuedAt = new Date(); // Update session with new refresh token hash await db.query( `UPDATE sessions SET refresh_token = $1, refresh_token_hash = $2, refresh_token_issued_at = $3, last_active_at = NOW() WHERE id = $4`, [newRefreshTokenValue, newRefreshTokenHash, newIssuedAt, decoded.sessionId] ); } else { // Fallback: columns not migrated yet, just update last_active_at await db.query( 'UPDATE sessions SET last_active_at = NOW() WHERE id = $1', [decoded.sessionId] ); } // Get user const userResult = await db.query( 'SELECT * FROM users WHERE id = $1', [decoded.sub] ); if (userResult.rows.length === 0) return null; const user = userResult.rows[0]; const newAccessToken = this.generateAccessToken(user, decoded.sessionId); const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId); return { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000, tokenType: 'Bearer', }; } async revokeSession(sessionId: string, userId: string): Promise { const result = await db.query( `UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`, [sessionId, userId] ); // Invalidate cache (FASE 3) if ((result.rowCount ?? 0) > 0) { sessionCache.invalidate(sessionId); } return (result.rowCount ?? 0) > 0; } async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise { let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL'; const params: (string | undefined)[] = [userId]; if (exceptSessionId) { query += ' AND id != $2'; params.push(exceptSessionId); } const result = await db.query(query, params); // Invalidate all cached sessions for this user (FASE 3) // Note: This is a simple prefix-based invalidation // In production, consider storing user_id -> session_ids mapping if ((result.rowCount ?? 0) > 0) { sessionCache.invalidateByPrefix(userId); } return result.rowCount ?? 0; } async getActiveSessions(userId: string): Promise { const result = await db.query( `SELECT * FROM sessions WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() ORDER BY last_active_at DESC`, [userId] ); return result.rows; } /** * Check if session is active (with 30s cache) - FASE 3 * @param sessionId Session ID to validate * @returns true if session is active, false otherwise */ async isSessionActive(sessionId: string): Promise { // Check cache first const cached = sessionCache.get(sessionId); if (cached !== null) { return cached; } // Query database const result = await db.query( `SELECT id FROM sessions WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW() LIMIT 1`, [sessionId] ); const isActive = result.rows.length > 0; // Cache result sessionCache.set(sessionId, isActive); return isActive; } generateEmailToken(): string { return crypto.randomBytes(32).toString('hex'); } hashToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } } export const tokenService = new TokenService();