--- id: "ET-AUTH-007" title: "Token Lifecycle & Auto-Refresh" epic: "OQI-001" type: "Especificacion Tecnica" status: "implemented" priority: "P0" blocker: "BLOCKER-001" version: "1.0.0" created: "2026-01-26" updated: "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:** ```typescript // ❌ 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 │ └──────────────┬────────────────┘ │ ▼ ┌──────────────────────────────┐ │ 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 ```typescript /** * 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 ```typescript 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 ```typescript 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:** ```typescript import axios from 'axios'; const api = axios.create({ baseURL: API_BASE_URL }); ``` **Después:** ```typescript 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: ```typescript 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**: ```typescript 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:** ```json { "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response (Success):** ```json { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiresIn": 3600 } } ``` **Response (Error):** ```json { "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) ```typescript // 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):** ```typescript // 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: ```typescript // 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 ```typescript // 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:** ```typescript // 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) --- ## Related Documents | 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)