--- id: "ET-AUTH-002" title: "JWT Tokens Implementation" type: "Specification" status: "Done" rf_parent: "RF-AUTH-002" epic: "OQI-001" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-AUTH-002: Especificación Técnica - JWT Tokens **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** ✅ Implementado **Épica:** [OQI-001](../_MAP.md) **Requerimiento:** [RF-AUTH-002](../requerimientos/RF-AUTH-002-email.md), [RF-AUTH-005](../requerimientos/RF-AUTH-005-sessions.md) --- ## Resumen Esta especificación detalla la implementación del sistema de tokens JWT para autenticación y autorización en Trading Platform. --- ## Arquitectura de Tokens ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ TOKEN ARCHITECTURE │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────┐ ┌───────────────────────┐ │ │ │ ACCESS TOKEN │ │ REFRESH TOKEN │ │ │ │ │ │ │ │ │ │ TTL: 15 minutes │ │ TTL: 7 days │ │ │ │ Algorithm: RS256 │ │ Algorithm: RS256 │ │ │ │ Storage: Memory │ │ Storage: HttpOnly │ │ │ │ │ │ Cookie │ │ │ │ Contains: │ │ │ │ │ │ - User ID │ │ Contains: │ │ │ │ - Email │ │ - User ID │ │ │ │ - Role │ │ - Token ID (jti) │ │ │ │ - Permissions │ │ - Session ID │ │ │ │ │ │ │ │ │ └───────────────────────┘ └───────────────────────┘ │ │ │ │ │ │ │ Used for API calls │ Used to refresh │ │ │ │ access token │ │ ▼ ▼ │ │ ┌───────────────────────┐ ┌───────────────────────┐ │ │ │ API Middleware │ │ /auth/refresh │ │ │ │ (authenticate) │ │ Endpoint │ │ │ └───────────────────────┘ └───────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Token Service **Ubicación:** `apps/backend/src/modules/auth/services/token.service.ts` ### Interfaz ```typescript export interface TokenPayload { sub: string; // User ID email: string; role: UserRole; permissions: string[]; sessionId?: string; } export interface RefreshTokenPayload { sub: string; // User ID jti: string; // Unique token ID sessionId: string; type: 'refresh'; } export interface TokenPair { accessToken: string; refreshToken: string; expiresIn: number; // seconds tokenType: 'Bearer'; } export class TokenService { generateTokens(user: User, sessionId?: string): Promise; verifyAccessToken(token: string): Promise; verifyRefreshToken(token: string): Promise; revokeRefreshToken(tokenId: string): Promise; rotateRefreshToken(oldToken: string): Promise; } ``` ### Implementación ```typescript import jwt from 'jsonwebtoken'; import crypto from 'crypto'; export class TokenService { private readonly accessTokenSecret: string; private readonly refreshTokenSecret: string; private readonly accessTokenExpiry = '15m'; private readonly refreshTokenExpiry = '7d'; constructor() { this.accessTokenSecret = process.env.JWT_ACCESS_SECRET!; this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET!; } async generateTokens(user: User, sessionId?: string): Promise { const tokenId = crypto.randomUUID(); const sid = sessionId || crypto.randomUUID(); const accessToken = jwt.sign( { sub: user.id, email: user.email, role: user.role, permissions: this.getPermissions(user.role), }, this.accessTokenSecret, { expiresIn: this.accessTokenExpiry, algorithm: 'RS256', issuer: 'trading.com', audience: 'trading.com', } ); const refreshToken = jwt.sign( { sub: user.id, jti: tokenId, sessionId: sid, type: 'refresh', }, this.refreshTokenSecret, { expiresIn: this.refreshTokenExpiry, algorithm: 'RS256', issuer: 'trading.com', } ); // Almacenar hash del refresh token en sesión await this.storeRefreshToken(user.id, tokenId, sid); return { accessToken, refreshToken, expiresIn: 900, // 15 minutes tokenType: 'Bearer', }; } async verifyAccessToken(token: string): Promise { try { const payload = jwt.verify(token, this.accessTokenSecret, { algorithms: ['RS256'], issuer: 'trading.com', audience: 'trading.com', }) as TokenPayload; return payload; } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new UnauthorizedError('Token expired'); } throw new UnauthorizedError('Invalid token'); } } async rotateRefreshToken(oldToken: string): Promise { const payload = await this.verifyRefreshToken(oldToken); // Verificar que el token no haya sido revocado const isValid = await this.validateRefreshToken(payload.jti); if (!isValid) { // Posible token reuse - revocar todas las sesiones await this.revokeAllUserSessions(payload.sub); throw new UnauthorizedError('Token has been revoked'); } // Revocar token anterior await this.revokeRefreshToken(payload.jti); // Obtener usuario const user = await this.userService.findById(payload.sub); if (!user) { throw new UnauthorizedError('User not found'); } // Generar nuevos tokens return this.generateTokens(user, payload.sessionId); } private getPermissions(role: UserRole): string[] { const permissions: Record = { investor: ['read:portfolio', 'trade:paper', 'read:education'], trader: ['read:portfolio', 'trade:real', 'read:education', 'create:signals'], student: ['read:education'], admin: ['admin:users', 'admin:content', 'read:*', 'write:*'], superadmin: ['*'], }; return permissions[role] || []; } } ``` --- ## Estructura de Tokens ### Access Token ```json // Header { "alg": "RS256", "typ": "JWT" } // Payload { "sub": "550e8400-e29b-41d4-a716-446655440000", "email": "usuario@example.com", "role": "investor", "permissions": ["read:portfolio", "trade:paper", "read:education"], "iat": 1701792000, "exp": 1701792900, "iss": "trading.com", "aud": "trading.com" } ``` ### Refresh Token ```json // Header { "alg": "RS256", "typ": "JWT" } // Payload { "sub": "550e8400-e29b-41d4-a716-446655440000", "jti": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "refresh", "iat": 1701792000, "exp": 1702396800, "iss": "trading.com" } ``` --- ## Middleware de Autenticación **Ubicación:** `apps/backend/src/core/middleware/auth.middleware.ts` ```typescript import { Request, Response, NextFunction } from 'express'; import { TokenService } from '@/modules/auth/services/token.service'; export interface AuthenticatedRequest extends Request { user: TokenPayload; } export const authenticate = async ( req: Request, res: Response, next: NextFunction ) => { try { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new UnauthorizedError('Missing or invalid authorization header'); } const token = authHeader.substring(7); const tokenService = new TokenService(); const payload = await tokenService.verifyAccessToken(token); (req as AuthenticatedRequest).user = payload; next(); } catch (error) { next(error); } }; export const requireRole = (...roles: UserRole[]) => { return (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; if (!roles.includes(authReq.user.role)) { throw new ForbiddenError('Insufficient permissions'); } next(); }; }; export const requirePermission = (...permissions: string[]) => { return (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; const hasPermission = permissions.every( perm => authReq.user.permissions.includes(perm) || authReq.user.permissions.includes('*') ); if (!hasPermission) { throw new ForbiddenError('Insufficient permissions'); } next(); }; }; ``` --- ## Flujo de Refresh Token ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Client │ │ Backend │ │ Redis │ │ PostgreSQL │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ Access token │ │ │ │ expired │ │ │ │ │ │ │ │ POST /auth/refresh│ │ │ │ { refreshToken } │ │ │ │──────────────────▶│ │ │ │ │ │ │ │ │ Verify JWT │ │ │ │ signature │ │ │ │ │ │ │ │ Check if revoked │ │ │ │──────────────────▶│ │ │ │◀──────────────────│ │ │ │ │ │ │ │ Get user data │ │ │ │───────────────────────────────────────▶ │ │◀─────────────────────────────────────── │ │ │ │ │ │ Revoke old token │ │ │ │──────────────────▶│ │ │ │ │ │ │ │ Generate new pair │ │ │ │ │ │ │ │ Store new token │ │ │ │──────────────────▶│ │ │ │ │ │ │◀──────────────────│ │ │ │ { accessToken, │ │ │ │ refreshToken } │ │ │ ``` --- ## Almacenamiento de Tokens ### Redis (Token Blacklist & Sessions) ```typescript // Almacenar refresh token await redis.setEx( `refresh:${userId}:${tokenId}`, 7 * 24 * 60 * 60, // 7 días JSON.stringify({ sessionId, createdAt: Date.now(), lastUsed: Date.now(), }) ); // Revocar token await redis.del(`refresh:${userId}:${tokenId}`); // Revocar todas las sesiones de un usuario const keys = await redis.keys(`refresh:${userId}:*`); if (keys.length > 0) { await redis.del(keys); } ``` ### Estructura en Redis ``` refresh:{userId}:{tokenId} => { "sessionId": "uuid", "createdAt": 1701792000000, "lastUsed": 1701792000000 } TTL: 7 días ``` --- ## Configuración ### Variables de Entorno ```env # JWT Secrets (RS256 - usar keys generadas) JWT_ACCESS_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----... JWT_ACCESS_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----... JWT_REFRESH_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----... JWT_REFRESH_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----... # Token TTLs JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=7d # Issuer/Audience JWT_ISSUER=trading.com JWT_AUDIENCE=trading.com ``` ### Generación de Keys RS256 ```bash # Generar key privada openssl genrsa -out private.key 2048 # Extraer key pública openssl rsa -in private.key -pubout -out public.key ``` --- ## Manejo en Frontend ### Interceptor de Axios ```typescript // apps/frontend/src/services/api.ts import axios from 'axios'; import { useAuthStore } from '@/stores/auth.store'; const api = axios.create({ baseURL: import.meta.env.VITE_API_URL, }); // Request interceptor - agregar token api.interceptors.request.use((config) => { const { accessToken } = useAuthStore.getState(); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // Response interceptor - refresh on 401 api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const { refreshToken, setTokens, logout } = useAuthStore.getState(); if (!refreshToken) { logout(); return Promise.reject(error); } const response = await axios.post( `${import.meta.env.VITE_API_URL}/auth/refresh`, { refreshToken } ); setTokens(response.data); originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`; return api(originalRequest); } catch (refreshError) { useAuthStore.getState().logout(); return Promise.reject(refreshError); } } return Promise.reject(error); } ); export default api; ``` ### Auth Store ```typescript // apps/frontend/src/stores/auth.store.ts import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface AuthState { user: User | null; accessToken: string | null; refreshToken: string | null; isAuthenticated: boolean; setUser: (user: User) => void; setTokens: (tokens: TokenPair) => void; logout: () => void; } export const useAuthStore = create()( persist( (set) => ({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: true }), setTokens: ({ accessToken, refreshToken }) => set({ accessToken, refreshToken }), logout: () => set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, }), }), { name: 'auth-storage', partialize: (state) => ({ refreshToken: state.refreshToken, // No persistir accessToken (memoria) }), } ) ); ``` --- ## Seguridad ### Protección contra Token Theft 1. **Short-lived access tokens** (15 min) 2. **Refresh token rotation** en cada uso 3. **Token binding** a device/IP (opcional) 4. **Revocación inmediata** posible ### Detección de Token Reuse ```typescript async rotateRefreshToken(oldToken: string): Promise { const payload = await this.verifyRefreshToken(oldToken); // Verificar si el token ya fue usado const tokenData = await redis.get(`refresh:${payload.sub}:${payload.jti}`); if (!tokenData) { // Token ya no existe = fue rotado o revocado // Posible robo de token // Revocar TODAS las sesiones del usuario await this.revokeAllUserSessions(payload.sub); // Notificar al usuario await this.notifySecurityAlert(payload.sub, 'token_reuse_detected'); // Log de seguridad await this.securityLog.log({ event: 'TOKEN_REUSE_DETECTED', userId: payload.sub, tokenId: payload.jti, timestamp: new Date(), }); throw new UnauthorizedError('Security alert: Token reuse detected'); } // Proceder con rotación normal... } ``` --- ## Testing ```typescript describe('TokenService', () => { describe('generateTokens', () => { it('should generate valid access and refresh tokens', async () => { const user = createMockUser(); const tokens = await tokenService.generateTokens(user); expect(tokens.accessToken).toBeDefined(); expect(tokens.refreshToken).toBeDefined(); expect(tokens.expiresIn).toBe(900); expect(tokens.tokenType).toBe('Bearer'); }); }); describe('verifyAccessToken', () => { it('should verify valid token', async () => { const user = createMockUser(); const { accessToken } = await tokenService.generateTokens(user); const payload = await tokenService.verifyAccessToken(accessToken); expect(payload.sub).toBe(user.id); expect(payload.email).toBe(user.email); expect(payload.role).toBe(user.role); }); it('should reject expired token', async () => { const expiredToken = jwt.sign( { sub: 'test' }, process.env.JWT_ACCESS_SECRET, { expiresIn: '-1h' } ); await expect(tokenService.verifyAccessToken(expiredToken)) .rejects.toThrow('Token expired'); }); }); describe('rotateRefreshToken', () => { it('should generate new tokens and revoke old', async () => { const user = createMockUser(); const { refreshToken } = await tokenService.generateTokens(user); const newTokens = await tokenService.rotateRefreshToken(refreshToken); expect(newTokens.refreshToken).not.toBe(refreshToken); // Old token should be revoked await expect(tokenService.rotateRefreshToken(refreshToken)) .rejects.toThrow(); }); }); }); ``` --- ## Referencias - [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) - [JWT Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-bcp) - [OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)