trading-platform/docs/02-definicion-modulos/OQI-001-fundamentos-auth/especificaciones/ET-AUTH-002-jwt.md
rckrdmrd c1b5081208 feat(ml): Complete FASE 11 - BTCUSD update and comprehensive documentation alignment
ML Engine Updates:
- Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records
- Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence)
- Backtest results: +176.71R profit with aggressive_filter strategy

Documentation Consolidation:
- Created docs/99-analisis/_MAP.md index with 13 new analysis documents
- Consolidated inventories: removed duplicates from orchestration/inventarios/
- Updated ML_INVENTORY.yml with BTCUSD metrics and training results
- Added execution reports: FASE11-BTCUSD, correction issues, alignment validation

Architecture & Integration:
- Updated all module documentation with NEXUS v3.4 frontmatter
- Fixed _MAP.md indexes across all folders
- Updated orchestration plans and traces

Files: 229 changed, 5064 insertions(+), 1872 deletions(-)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:31:29 -06:00

20 KiB

id title type status rf_parent epic version created_date updated_date
ET-AUTH-002 JWT Tokens Implementation Specification Done RF-AUTH-002 OQI-001 1.0 2025-12-05 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 Requerimiento: RF-AUTH-002, RF-AUTH-005


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

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<TokenPair>;
  verifyAccessToken(token: string): Promise<TokenPayload>;
  verifyRefreshToken(token: string): Promise<RefreshTokenPayload>;
  revokeRefreshToken(tokenId: string): Promise<void>;
  rotateRefreshToken(oldToken: string): Promise<TokenPair>;
}

Implementación

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<TokenPair> {
    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<TokenPayload> {
    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<TokenPair> {
    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<UserRole, string[]> = {
      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

// 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

// 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

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)

// 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

# 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

# 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

// 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

// 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<AuthState>()(
  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

async rotateRefreshToken(oldToken: string): Promise<TokenPair> {
  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

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