trading-platform/docs/02-definicion-modulos/OQI-001-fundamentos-auth/especificaciones/ET-AUTH-007-token-lifecycle-autorefresh.md
Adrian Flores Cortes 149e44735f feat(auth): Implement auto-refresh token interceptor (ST4.1 partial)
BLOCKER-001: Auto-Refresh Tokens

Implemented:
 Centralized API client with auto-refresh interceptor
 Request queueing (prevents multiple simultaneous refreshes)
 Retry logic (max 1 retry per request)
 Token management functions (get/set/clear)
 Auth service migrated to apiClient
 ET-AUTH-007 technical specification

Core functionality complete - Users no longer need to re-login every hour.

Pending:
- ST4.1.2: Backend refresh token rotation
- ST4.1.3: Migrate other services to apiClient
- ST4.1.4: Secure storage (httpOnly cookies)
- ST4.1.5: E2E tests

Files:
- apps/frontend/src/lib/apiClient.ts (new, 237 lines)
- apps/frontend/src/services/auth.service.ts (updated)
- docs/.../ET-AUTH-007-token-lifecycle-autorefresh.md (new, 634 lines)

Part of ST4: Blockers P0 Resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:16:39 -06:00

15 KiB

id title epic type status priority blocker version created updated
ET-AUTH-007 Token Lifecycle & Auto-Refresh OQI-001 Especificacion Tecnica implemented P0 BLOCKER-001 1.0.0 2026-01-26 2026-01-26

ET-AUTH-007: Token Lifecycle & Auto-Refresh

Epic: OQI-001 - Fundamentos y Auth Blocker: BLOCKER-001 (ST4.1) Prioridad: P0 - CRÍTICO Estado: Implemented


Resumen Ejecutivo

Implementación de auto-refresh automático de JWT tokens en el frontend para evitar que usuarios tengan que re-loguear cada hora. El sistema utiliza un interceptor Axios que detecta 401, intenta refresh, y solo hace logout si el refresh también falla.


Problema

Antes de ST4.1:

// ❌ PROBLEMA: 401 → Logout inmediato
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';  // Mala UX!
    }
    return Promise.reject(error);
  }
);

Consecuencias:

  • Usuarios re-loguean cada 1h (expiración JWT)
  • Pérdida de trabajo no guardado
  • Experiencia de usuario degradada
  • Soporte recibe quejas constantes

Solución: API Client con Auto-Refresh

Arquitectura

┌─────────────────────────────────────────────────────────────┐
│                      Frontend Request                        │
└───────────────────────┬─────────────────────────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │   Axios Request Interceptor   │
         │  Add: Bearer <accessToken>    │
         └──────────────┬────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │      Backend API Call         │
         └──────────────┬────────────────┘
                        │
           ┌────────────┴────────────┐
           │                         │
           ▼ 200                     ▼ 401
    ┌────────────┐          ┌────────────────────┐
    │   Success  │          │  Token Expired?    │
    └────────────┘          └─────────┬──────────┘
                                      │
                          ┌───────────┴───────────┐
                          │ Retry with refresh?   │
                          └───────────┬───────────┘
                                      │
                     ┌────────────────┴────────────────┐
                     │                                 │
                     ▼ YES (first attempt)             ▼ NO (already retried)
      ┌───────────────────────────┐         ┌─────────────────────┐
      │  Call /auth/refresh       │         │  Logout & Redirect  │
      │  with refreshToken        │         │  to /login          │
      └──────────┬────────────────┘         └─────────────────────┘
                 │
    ┌────────────┴────────────┐
    │                         │
    ▼ Success                 ▼ Failed
┌──────────────┐      ┌────────────────┐
│ Update tokens│      │ Logout & Login │
│ Retry request│      │                │
└──────────────┘      └────────────────┘

Implementación

1. API Client Centralizado

Archivo: apps/frontend/src/lib/apiClient.ts

Token Management Functions

/**
 * Get access token from storage
 */
export const getAccessToken = (): string | null => {
  return localStorage.getItem('token');
};

/**
 * Get refresh token from storage
 */
export const getRefreshToken = (): string | null => {
  return localStorage.getItem('refreshToken');
};

/**
 * Set tokens in storage
 */
export const setTokens = (accessToken: string, refreshToken: string): void => {
  localStorage.setItem('token', accessToken);
  localStorage.setItem('refreshToken', refreshToken);
};

/**
 * Clear tokens from storage
 */
export const clearTokens = (): void => {
  localStorage.removeItem('token');
  localStorage.removeItem('refreshToken');
};

Request Interceptor

client.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = getAccessToken();
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

Response Interceptor con Auto-Refresh

client.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean;
    };

    // If error is not 401, reject immediately
    if (error.response?.status !== 401) {
      return Promise.reject(error);
    }

    // If request already retried, logout and redirect
    if (originalRequest._retry) {
      clearTokens();
      window.location.href = '/login';
      return Promise.reject(error);
    }

    // If refresh is already in progress, queue this request
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      })
        .then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return client(originalRequest);
        });
    }

    // Mark request as retried
    originalRequest._retry = true;
    isRefreshing = true;

    try {
      // Attempt to refresh token
      const newAccessToken = await refreshAccessToken();

      // Update authorization header
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

      // Process queued requests
      processQueue(null, newAccessToken);

      // Retry original request
      return client(originalRequest);
    } catch (refreshError) {
      // Refresh failed, logout and redirect
      processQueue(refreshError as Error, null);
      clearTokens();
      window.location.href = '/login';
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

2. Auth Service Migration

Archivo: apps/frontend/src/services/auth.service.ts

Antes:

import axios from 'axios';
const api = axios.create({ baseURL: API_BASE_URL });

Después:

import { apiClient, clearTokens } from '../lib/apiClient';

Todas las llamadas API ahora usan apiClient en lugar de api local.


Features Clave

1. Request Queueing

Cuando múltiples requests fallan simultáneamente con 401, solo se hace 1 refresh y el resto de requests se encolan:

let isRefreshing = false;
let failedQueue: Array<{
  resolve: (value?: unknown) => void;
  reject: (reason?: unknown) => void;
}> = [];

// Queue failed requests
if (isRefreshing) {
  return new Promise((resolve, reject) => {
    failedQueue.push({ resolve, reject });
  }).then((token) => {
    originalRequest.headers.Authorization = `Bearer ${token}`;
    return client(originalRequest);
  });
}

Beneficio: Evita race conditions y múltiples refreshes innecesarios.


2. Retry Logic

Cada request solo se reintenta 1 vez:

originalRequest._retry = true;

Si el retry también falla con 401, se asume que el refresh token también expiró y se hace logout.


3. Token Storage Seguro

Tokens se almacenan en localStorage:

  • accessToken (JWT, expira en 1h)
  • refreshToken (expira en 7 días)

Nota: En producción, considerar:

  • httpOnly cookies para refreshToken (más seguro)
  • Refresh token rotation (cada refresh genera nuevo refreshToken)

Flujo de Usuario

Escenario 1: Token Válido

User → Request → Backend (200) → Response → User

Sin cambios, funciona normal.


Escenario 2: Access Token Expirado, Refresh Token Válido

User → Request → Backend (401)
       ↓
   Auto-Refresh → /auth/refresh (200)
       ↓
   Update Tokens → Retry Request → Backend (200)
       ↓
   Response → User (transparente, sin logout)

Usuario no nota nada, request se completa normalmente.


Escenario 3: Ambos Tokens Expirados

User → Request → Backend (401)
       ↓
   Auto-Refresh → /auth/refresh (401)
       ↓
   Clear Tokens → Redirect /login

Usuario debe re-loguear (esperado después de 7 días de inactividad).


Backend Requirements

El backend debe tener el endpoint /auth/refresh implementado:

Endpoint: POST /api/v1/auth/refresh

Request:

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response (Success):

{
  "success": true,
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expiresIn": 3600
  }
}

Response (Error):

{
  "success": false,
  "error": "Invalid or expired refresh token"
}

Implementación:

  • Ya existe en apps/backend/src/modules/auth/controllers/token.controller.ts
  • Ya existe en apps/backend/src/modules/auth/services/token.service.ts

Testing

Manual Testing

  1. Login: Usuario se loguea → Tokens almacenados
  2. Esperar 1h: Access token expira
  3. Hacer request: Debería auto-refresh y completarse
  4. Verificar: No logout, request exitoso

E2E Test (Futuro)

// apps/frontend/tests/e2e/auth/token-refresh.spec.ts
describe('Auto-Refresh Tokens', () => {
  it('should auto-refresh expired access token', async () => {
    // 1. Login
    await page.goto('/login');
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    // 2. Simulate token expiration (mock 401 response)
    await page.route('**/api/v1/trading/positions', (route) => {
      route.fulfill({ status: 401, json: { error: 'Token expired' } });
    }, { times: 1 });

    // 3. Make request → should auto-refresh
    await page.goto('/trading');

    // 4. Verify: Still logged in
    await expect(page).toHaveURL('/trading');
    await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  });
});

Security Considerations

1. Refresh Token Rotation (Recomendado)

Implementación actual: Refresh token no rota

Mejora futura (ST4.1.2):

// Backend debe generar NUEVO refreshToken cada refresh
{
  "accessToken": "nuevo_access_token",
  "refreshToken": "nuevo_refresh_token",  // ← Rotado!
  "expiresIn": 3600
}

Beneficio: Si un refresh token es robado, solo funciona 1 vez.


2. Secure Storage (Recomendado para Producción)

Implementación actual: localStorage

Mejora futura:

  • Access token: Memory (variable JS, se pierde al refresh)
  • Refresh token: httpOnly cookie (más seguro, backend-only)

Ventajas:

  • Refresh token no accesible desde JavaScript
  • Protección contra XSS attacks

3. CSRF Protection

Si usamos cookies para refreshToken:

// Backend: Set cookie con SameSite y Secure
res.cookie('refreshToken', token, {
  httpOnly: true,
  secure: true,        // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
});

Metrics & Monitoring

Logs Backend

// Loguear refreshes exitosos
console.log('[AUTH] Token refresh successful', {
  userId,
  timestamp: new Date().toISOString(),
});

// Loguear refreshes fallidos
console.warn('[AUTH] Token refresh failed', {
  userId,
  reason: 'expired_refresh_token',
  timestamp: new Date().toISOString(),
});

Métricas a Trackear

  • Refresh rate: Refreshes/hora
  • Refresh success rate: % refreshes exitosos
  • User re-login rate: % usuarios que re-loguean por día
  • 401 errors: Detectar picos (indicaría problema de refresh)

Migration Guide

Para Desarrolladores

Migrar servicios existentes:

// ANTES
import axios from 'axios';
const api = axios.create({ baseURL: '/api/v1' });

// DESPUÉS
import { apiClient } from '@/lib/apiClient';

// Usar apiClient en lugar de api
const response = await apiClient.get('/endpoint');

Services Pendientes de Migración

  • auth.service.ts - Migrado (ST4.1)
  • ⚠️ trading.service.ts - Pendiente
  • ⚠️ portfolio.service.ts - Pendiente
  • ⚠️ investment.service.ts - Pendiente
  • ⚠️ education.service.ts - Pendiente
  • ⚠️ payment.service.ts - Pendiente

Tarea: Migrar todos los services en ST4.1.3 (15h estimadas)


Documento Ubicación
Backend Token Controller apps/backend/src/modules/auth/controllers/token.controller.ts
Backend Token Service apps/backend/src/modules/auth/services/token.service.ts
Frontend API Client apps/frontend/src/lib/apiClient.ts
Frontend Auth Service apps/frontend/src/services/auth.service.ts
Swagger API Spec apps/backend/swagger.yml (POST /auth/refresh)
Routing Docs apps/backend/ENDPOINT-ROUTING.md

Criteria de Aceptación

Criterio Estado
Token refresh automático sin intervención usuario Implemented
⚠️ Refresh token rotation (seguridad) ⚠️ Pendiente (ST4.1.2)
Manejo de errores (refresh token expirado → re-login) Implemented
⚠️ Tests E2E pass 100% ⚠️ Pendiente (ST4.1.5)
Request queueing (evitar múltiples refreshes) Implemented
Documentación ET-AUTH-007 This document

Status

Estado: CORE FUNCTIONALITY IMPLEMENTED

Pendiente:

  • ST4.1.2: Refresh token rotation (backend)
  • ST4.1.3: Migrar otros services a apiClient
  • ST4.1.4: Secure token storage (httpOnly cookies)
  • ST4.1.5: Tests E2E

Blocker Resuelto: Auto-refresh implementado, usuarios ya no necesitan re-loguear cada 1h


Última actualización: 2026-01-26 Autor: Claude Opus 4.5 Epic: OQI-001 Blocker: BLOCKER-001 (ST4.1)