- Archivados 5 análisis obsoletos a _archive/2026-01-25/ - MASTER-ANALYSIS-PLAN marcada SUPERSEDIDA - FRONTEND-COMPREHENSIVE-AUDIT marcada COMPLETADA (7+ entregables) - FRONTEND-MODULE-DOCS marcada CANCELADA (P3, sin progreso) - BLOCKER-001-TOKEN-REFRESH marcada POSTERGADA - Actualizado PROJECT-STATUS.md y _INDEX.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
20 KiB
OQI-001: Contratos de API - Fundamentos-Auth
Módulo: OQI-001 (fundamentos-auth)
Ruta: apps/backend/src/modules/auth/
Endpoints Analizados: 14
Fecha: 2026-01-25
Status: ANÁLISIS COMPLETO
ENDPOINTS CONSUMIDOS POR EL FRONTEND
AUTENTICACIÓN BASE (4 Endpoints)
1. POST /api/v1/auth/register
Componente Consumidor: Register.tsx (línea 46) Método HTTP: POST Descripción: Registro de nuevo usuario
Request Schema:
{
email: string // Requerido, formato email válido
password: string // Requerido, ≥8 chars, mayúscula, minúscula, número, especial
firstName: string // Requerido
lastName: string // Requerido
acceptTerms: boolean // Requerido (true)
}
Response Schema (Success):
{
success: true
data: {
user: {
id: string
email: string
firstName: string
lastName: string
status: 'pending_verification' | 'active'
emailVerified: boolean
createdAt: string
}
tokens?: {
accessToken: string
refreshToken: string
}
}
}
Response Schema (Error):
{
success: false
error: string // Ej: "Email already registered"
errors?: [
{
field: string
message: string
}
]
statusCode: number // 400, 409, 422, etc
}
Manejo de Error (Frontend):
if (!response.ok) {
throw new Error(data.error || data.errors?.[0]?.message || 'Error al registrar')
}
Estados HTTP Esperados:
201 Created- Registro exitoso400 Bad Request- Validación fallida409 Conflict- Email ya registrado422 Unprocessable Entity- Datos inválidos
2. POST /api/v1/auth/login
Componente Consumidor: Login.tsx (línea 31) Método HTTP: POST Descripción: Inicio de sesión con email/contraseña
Request Schema:
{
email: string // Requerido
password: string // Requerido
totpCode?: string // Opcional, 6 dígitos si 2FA está habilitado
rememberMe?: boolean // Opcional, default false
}
Response Schema (Sin 2FA):
{
success: true
data: {
user: {
id: string
email: string
displayName: string
mfaEnabled: boolean
status: 'active'
lastLoginAt: string
}
tokens: {
accessToken: string
refreshToken: string
expiresIn: number // Segundos
tokenType: 'Bearer'
}
session: {
id: string
ipAddress: string
userAgent: string
}
}
}
Response Schema (Requiere 2FA):
{
success: true
requiresTwoFactor: true
data: {
tempToken: string // Token temporal para verificar TOTP
}
}
Manejo de Error (Frontend):
if (!response.ok) {
throw new Error(data.error || 'Error al iniciar sesion')
}
if (data.requiresTwoFactor) {
setRequiresTwoFactor(true) // Mostrar input TOTP
return
}
// Almacenar tokens
localStorage.setItem('accessToken', data.data.tokens.accessToken)
localStorage.setItem('refreshToken', data.data.tokens.refreshToken)
navigate('/dashboard')
Estados HTTP Esperados:
200 OK- Login exitoso401 Unauthorized- Credenciales incorrectas403 Forbidden- Cuenta desactivada/suspendida429 Too Many Requests- Rate limiting
3. POST /api/v1/auth/logout
Componente Consumidor: SessionsList.tsx + DeviceCard.tsx (revoke sesión actual) Método HTTP: POST Descripción: Cierre de sesión actual
Request Schema:
{
// Vacío, auth via Bearer token
}
Headers Requeridos:
Authorization: Bearer <accessToken>
Response Schema:
{
success: true
data: {
message: "Logged out successfully"
}
}
Manejo de Error (Frontend):
// En authService.logout()
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
// Redirect a /login
Estados HTTP Esperados:
200 OK- Logout exitoso401 Unauthorized- Token inválido
4. POST /api/v1/auth/logout-all
Componente Consumidor: SessionsList.tsx - Botón "Sign Out All Devices" Método HTTP: POST Descripción: Cierre de todas las sesiones excepto la actual (se cierra actual también)
Request Schema:
{
// Vacío, auth via Bearer token
}
Headers Requeridos:
Authorization: Bearer <accessToken>
Response Schema:
{
success: true
data: {
message: "Signed out from 3 devices" // Count de sesiones cerradas
count: number
}
}
Manejo de Error (Frontend):
// En authService.revokeAllSessions()
try {
const response = await api.post('/auth/logout-all')
const count = parseInt(response.data.message?.match(/\d+/)?.[0] || '0')
} catch (error) {
throw new Error('Failed to revoke all sessions')
}
// Después: localStorage.removeItem, redirect /login
Estados HTTP Esperados:
200 OK- Logout exitoso401 Unauthorized- No autenticado
EMAIL & CONTRASEÑA (3 Endpoints)
5. POST /api/v1/auth/verify-email
Componente Consumidor: VerifyEmail.tsx (línea 23) Método HTTP: POST Descripción: Verificar email con token de confirmación
Request Schema:
{
token: string // Requerido, token de verificación (query param)
}
Response Schema (Success):
{
success: true
data: {
message: "Email verified successfully"
user: {
id: string
email: string
emailVerified: true
status: 'active'
}
}
}
Response Schema (Error):
{
success: false
error: string // "Invalid or expired token"
}
Manejo de Error (Frontend):
// En VerifyEmail.tsx useEffect()
if (!response.ok) {
throw new Error(data.error || 'Error al verificar email')
}
setStatus('success') // Mostrar CheckCircle
Estados HTTP Esperados:
200 OK- Verificación exitosa400 Bad Request- Token inválido/expirado409 Conflict- Email ya verificado
6. POST /api/v1/auth/forgot-password
Componente Consumidor: ForgotPassword.tsx (línea 17) Método HTTP: POST Descripción: Solicitar enlace de recuperación de contraseña
Request Schema:
{
email: string // Requerido
}
Response Schema:
{
success: true
data: {
message: "If account exists, reset link will be sent to email"
}
}
Comportamiento de Seguridad:
- SIEMPRE retorna
200 OK, incluso si email no existe (privacy) - Envía email solo si cuenta existe
- Link expira en 1 hora (típico)
Manejo de Error (Frontend):
// En ForgotPassword.tsx
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Error al enviar el email')
}
setSubmitted(true) // Mostrar mensaje "Revisa tu Email"
Estados HTTP Esperados:
200 OK- Solicitud procesada (incluso si email no existe)429 Too Many Requests- Rate limiting
7. POST /api/v1/auth/reset-password
Componente Consumidor: ResetPassword.tsx (línea 47) Método HTTP: POST Descripción: Establecer nueva contraseña con token
Request Schema:
{
token: string // Requerido (query param)
password: string // Requerido, mismas reglas que Register
}
Response Schema (Success):
{
success: true
data: {
message: "Password reset successfully"
user: {
id: string
email: string
}
}
}
Response Schema (Error):
{
success: false
error: string // "Invalid or expired token" / "Password too weak"
}
Manejo de Error (Frontend):
// En ResetPassword.tsx
if (!response.ok) {
throw new Error(data.error || 'Error al restablecer contrasena')
}
setStatus('success')
// Después puede iniciar sesión con nueva contraseña
Estados HTTP Esperados:
200 OK- Restablecimiento exitoso400 Bad Request- Token inválido/expirado422 Unprocessable Entity- Contraseña débil
TELÉFONO & OTP (2 Endpoints)
8. POST /api/v1/auth/phone/send-otp
Componente Consumidor: PhoneLoginForm.tsx (línea 42) Método HTTP: POST Descripción: Enviar código OTP por SMS/WhatsApp
Request Schema:
{
phoneNumber: string // Requerido, sin caracteres especiales
countryCode: string // Requerido, ej: "52", "1", "57"
channel: 'sms' | 'whatsapp' // Requerido
}
Response Schema (Success):
{
success: true
data: {
message: "OTP sent successfully"
expiresAt: string // ISO timestamp, típicamente +10 mins
maskedPhone: string // "55 1234 5678"
}
}
Response Schema (Error):
{
success: false
error: string // "Invalid phone number" / "SMS provider error"
}
Manejo de Error (Frontend):
// En PhoneLoginForm.tsx handleSendOTP()
const response = await fetch('/api/v1/auth/phone/send-otp', {...})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Error al enviar el codigo')
}
setOtpExpiresAt(new Date(data.data.expiresAt))
setStep('otp') // Cambiar a pantalla de verificación
Estados HTTP Esperados:
200 OK- OTP enviado400 Bad Request- Número inválido429 Too Many Requests- Rate limiting503 Service Unavailable- Proveedor SMS/WhatsApp inactivo
9. POST /api/v1/auth/phone/verify-otp
Componente Consumidor: PhoneLoginForm.tsx (línea 73) Método HTTP: POST Descripción: Verificar código OTP y obtener tokens
Request Schema:
{
phoneNumber: string // Requerido
countryCode: string // Requerido
otpCode: string // Requerido, exactamente 6 dígitos
}
Response Schema (Success):
{
success: true
data: {
tokens: {
accessToken: string
refreshToken: string
expiresIn: number
tokenType: 'Bearer'
}
user: {
id: string
phone: string
phoneVerified: true
status: 'active'
}
}
}
Response Schema (Error):
{
success: false
error: string // "Invalid OTP" / "OTP expired" / "Invalid phone number"
}
Manejo de Error (Frontend):
// En PhoneLoginForm.tsx handleVerifyOTP()
const response = await fetch('/api/v1/auth/phone/verify-otp', {...})
if (!response.ok) {
throw new Error(data.error || 'Codigo invalido')
}
localStorage.setItem('accessToken', data.data.tokens.accessToken)
localStorage.setItem('refreshToken', data.data.tokens.refreshToken)
navigate('/dashboard')
Estados HTTP Esperados:
200 OK- Verificación exitosa400 Bad Request- OTP inválido401 Unauthorized- OTP expirado429 Too Many Requests- Demasiados intentos fallidos
OAUTH (3+ Endpoints)
10. GET /api/v1/auth/{provider}
Componente Consumidor: SocialLoginButtons.tsx (línea 65) Método HTTP: GET Descripción: Obtener URL de OAuth del proveedor
Proveedores Soportados:
googlefacebooktwitterapplegithub
Query Parameters:
?returnUrl=/login // URL a redirigir después del callback
Response Schema:
{
success: true
data: {
authUrl: string // URL de OAuth del proveedor
state: string // State token para CSRF protection
codeChallenge?: string // PKCE
}
}
Manejo de Error (Frontend):
// En SocialLoginButtons.tsx handleSocialLogin()
const response = await fetch(
`/api/v1/auth/${provider}?returnUrl=${encodeURIComponent(window.location.pathname)}`
)
const data = await response.json()
if (data.success && data.data.authUrl) {
window.location.href = data.data.authUrl
}
Estados HTTP Esperados:
200 OK- URL generada400 Bad Request- Proveedor inválido
11. POST /api/v1/auth/{provider}/callback
Componente Consumidor: AuthCallback.tsx (procesado por backend) Método HTTP: POST (redirección GET desde proveedor) Descripción: Callback post-OAuth, procesa code y obtiene tokens
Query Parameters (desde proveedor OAuth):
?code=authorization_code
&state=state_token
&error=access_denied (si usuario deniega)
Response Schema (en AuthCallback.tsx query params):
/auth/callback?
accessToken=xxx
&refreshToken=xxx
&isNewUser=true|false
&returnUrl=/dashboard
&error=error_code
Error Codes Posibles:
invalid_state- CSRF detection fallidooauth_failed- Conexión con proveedor fallóoauth_error- Error genérico OAuthmissing_code_verifier- PKCE validation fallidainvalid_provider- Proveedor no registrado
Manejo de Error (Frontend):
// En AuthCallback.tsx useEffect()
if (errorParam) {
setStatus('error')
setError(getErrorMessage(errorParam))
return
}
if (accessToken && refreshToken) {
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
setStatus('success')
setTimeout(() => {
if (isNewUser) navigate('/onboarding')
else navigate(returnUrl)
}, 1500)
}
SESIONES (3 Endpoints)
12. GET /api/v1/auth/sessions
Componente Consumidor: SessionsList.tsx (línea 32, en store) Método HTTP: GET Descripción: Obtener todas las sesiones activas del usuario
Headers Requeridos:
Authorization: Bearer <accessToken>
Response Schema:
{
success: true
data: [
{
id: string // UUID
userAgent: string // User-Agent header
ipAddress: string // IP origen
createdAt: string // ISO timestamp
lastActiveAt: string // ISO timestamp
isCurrent: boolean // True si es la sesión actual
deviceId?: string
},
...
]
}
Manejo de Error (Frontend):
// En authService.getSessions()
try {
const response = await api.get('/auth/sessions')
return response.data.data || []
} catch (error) {
throw new Error('Failed to fetch active sessions')
}
Consumido por: SessionsList.tsx a través de Zustand store
- useSessionsStore.fetchSessions() llamado en useEffect
Estados HTTP Esperados:
200 OK- Sesiones obtenidas401 Unauthorized- No autenticado
13. DELETE /api/v1/auth/sessions/{sessionId}
Componente Consumidor: DeviceCard.tsx (línea 150, a través de store) Método HTTP: DELETE Descripción: Revocar una sesión específica
Headers Requeridos:
Authorization: Bearer <accessToken>
Path Parameters:
sessionId: string // UUID de la sesión
Response Schema:
{
success: true
data: {
message: "Session revoked successfully"
}
}
Manejo de Error (Frontend):
// En authService.revokeSession()
try {
await api.delete(`/auth/sessions/${sessionId}`)
} catch (error) {
throw new Error('Failed to revoke session')
}
// En store: remueve de lista
set({
sessions: state.sessions.filter(s => s.id !== sessionId)
})
Caso Especial - Sesión Actual: Si sessionId es la sesión actual:
- Backend cierra la sesión
- Frontend detecta en store (isCurrent === true)
- Llama a logout() + redirect /login
Estados HTTP Esperados:
200 OK- Revocación exitosa401 Unauthorized- No autenticado404 Not Found- Sesión no existe
2FA / SEGURIDAD (2+ Endpoints - Parcialmente Implementados)
14. POST /api/v1/auth/2fa/setup
Componente Consumidor: SecuritySettings.tsx - Tab "two-factor" (SIN IMPLEMENTAR) Método HTTP: POST Descripción: Generar QR y secreto para 2FA
Nota: Botón "Setup" existe pero no tiene handler implementado
Request Schema Esperado:
{
method: 'totp' | 'sms'
}
Response Schema Esperado:
{
success: true
data: {
secret: string // Base32 para app authenticator
qrCode: string // URL de QR (data:image/png...)
backupCodes?: string[] // Códigos de backup
}
}
Estados HTTP Esperados:
200 OK- Setup generado401 Unauthorized- No autenticado
15. POST /api/v1/auth/2fa/enable
Componente Consumidor: SecuritySettings.tsx - Tab "two-factor" (SIN IMPLEMENTAR) Método HTTP: POST Descripción: Activar 2FA tras escanear QR
Nota: Endpoint no llamado desde frontend actual
Request Schema Esperado:
{
method: 'totp' | 'sms'
verificationCode: string // Código de 6 dígitos
}
RESUMEN DE CONTRATOS
| # | Endpoint | Método | Estado | Componente | Autenticación |
|---|---|---|---|---|---|
| 1 | /auth/register | POST | ✓ | Register.tsx | ✗ |
| 2 | /auth/login | POST | ✓ | Login.tsx | ✗ |
| 3 | /auth/logout | POST | ✓ | SessionsList/DeviceCard | ✓ |
| 4 | /auth/logout-all | POST | ✓ | SessionsList | ✓ |
| 5 | /auth/verify-email | POST | ✓ | VerifyEmail.tsx | ✗ |
| 6 | /auth/forgot-password | POST | ✓ | ForgotPassword.tsx | ✗ |
| 7 | /auth/reset-password | POST | ✓ | ResetPassword.tsx | ✗ |
| 8 | /auth/phone/send-otp | POST | ✓ | PhoneLoginForm | ✗ |
| 9 | /auth/phone/verify-otp | POST | ✓ | PhoneLoginForm | ✗ |
| 10 | /auth/{provider} | GET | ✓ | SocialLoginButtons | ✗ |
| 11 | /auth/{provider}/callback | POST | ✓ | AuthCallback | ✗ |
| 12 | /auth/sessions | GET | ✓ | SessionsList (store) | ✓ |
| 13 | /auth/sessions/{id} | DELETE | ✓ | DeviceCard (store) | ✓ |
| 14 | /auth/2fa/setup | POST | ✗ | SecuritySettings (TODO) | ✓ |
| 15 | /auth/2fa/enable | POST | ✗ | SecuritySettings (TODO) | ✓ |
Leyenda:
- ✓ = Implementado
- ✗ = No implementado / TODO
- Autenticación: ✓ = Requiere Bearer token
PATRONES DE MANEJO DE ERRORES
Patrón 1: Formularios (Register, Login)
try {
const response = await fetch('/api/v1/auth/...', {...})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.errors?.[0]?.message || 'Error genérico')
}
// Éxito
} catch (err) {
setError(err instanceof Error ? err.message : 'Error desconocido')
}
Patrón 2: Store (Zustand)
try {
await authService.action()
set({ /* updated state */ })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Mensaje por defecto'
set({ error: errorMessage })
throw error // Re-lanzar para capturador superior
}
Patrón 3: Llamadas Automáticas (VerifyEmail)
useEffect(() => {
if (!token) {
setStatus('no-token')
return
}
verifyEmail(token)
}, [token])
CONFIGURACIÓN DE API
Base URL: import.meta.env?.VITE_API_URL || '/api/v1'
Axios Interceptors (auth.service.ts):
- Request: Agrega
Authorization: Bearer <token>si localStorage tiene token - Response: En error 401, limpia localStorage y redirige a /login
Headers por Defecto:
'Content-Type': 'application/json'
'Authorization': 'Bearer <accessToken>' (si aplica)
NOTAS CRÍTICAS
- Rate Limiting: forgotten-password y phone/send-otp implementan rate limiting
- CSRF: OAuth usa state token (generado en backend)
- PKCE: OAuth puede usar PKCE (code_challenge / code_verifier)
- Token Refresh: Interceptor responde a 401 pero no hay refresh endpoint consumido desde componentes
- Seguridad Privacidad: forgot-password NO indica si email existe
- OTP Expiración: Típicamente 10 minutos
- Email Verification: Token típicamente expira en 24-48 horas
- Password Reset: Token expira en 1 hora
Análisis completado: 2026-01-25 15 endpoints documentados (13 implementados, 2 TODO)