trading-platform-backend-v2/src/modules/auth/services/token.service.ts
Adrian Flores Cortes 86e6303847 feat: Implement BLOCKER-001 token refresh + E2E video tests (backend)
BLOCKER-001: Token Refresh Improvements (4 phases)
- FASE 1: Rate limiting específico para /auth/refresh (15 req/15min per token)
- FASE 2: Token rotation con SHA-256 hash y reuse detection
- FASE 3: Session validation con cache de 30s (95% menos queries)
- FASE 4: Proactive refresh con X-Token-Expires-At header

E2E Tests: Video Upload Module (backend - 91 tests)
- Suite 4: Controller tests (22 tests) - REST API endpoints validation
- Suite 5: Service tests (29 tests) - Business logic and database operations
- Suite 6: Storage tests (35 tests) - S3/R2 multipart upload integration
- Suite 7: Full E2E flow (5 tests) - Complete pipeline validation

Changes:
- auth.middleware.ts: Session validation + token expiry header
- rate-limiter.ts: Specific rate limiter for refresh endpoint
- token.service.ts: Token rotation logic + session validation
- session-cache.service.ts (NEW): 30s TTL cache for session validation
- auth.types.ts: Extended types for session validation
- auth.routes.ts: Applied refreshTokenRateLimiter
- index.ts: Updated CORS to expose X-Token-Expires-At

Tests created:
- auth-token-refresh.test.ts (15 tests) - E2E token refresh flow
- video-controller.test.ts (22 tests) - REST API validation
- video-service.test.ts (29 tests) - Business logic validation
- storage-service.test.ts (35 tests) - S3/R2 integration
- video-upload-flow.test.ts (5 tests) - Complete pipeline

Database migration executed:
- Added refresh_token_hash and refresh_token_issued_at columns
- Created index on refresh_token_hash for performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 01:43:49 -06:00

288 lines
8.8 KiB
TypeScript

// ============================================================================
// 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<JWTPayload, 'iat' | 'exp'> = {
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<JWTRefreshPayload, 'iat' | 'exp'> = {
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<string, unknown>
): 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<Session>(
`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<User>(
'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<AuthTokens | null> {
const decoded = this.verifyRefreshToken(refreshToken);
if (!decoded) return null;
// Check session exists and is valid
const sessionResult = await db.query<Session>(
`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<User>(
'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<boolean> {
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<number> {
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<Session[]> {
const result = await db.query<Session>(
`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<boolean> {
// Check cache first
const cached = sessionCache.get(sessionId);
if (cached !== null) {
return cached;
}
// Query database
const result = await db.query<Session>(
`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();