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

```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)
