0018. Seguridad del pre_token (expiración corta, un solo uso)¶
Estado: Propuesto Fecha: 2026-09-08 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad, frontend (React 18), log_sesiones
Contexto¶
El login en dos pasos del ADR 0010 emite un pre_token (5 min) tras validar la
contraseña: sirve solo para elegir empresa en el paso 2
(/api/v1/auth/login/empresa). Quedó registrado como deuda: "debe tener
expiración corta (5 min) y un solo uso; su implementación puede ser un JWT
firmado con claim scope: "pre-auth" o un registro opaco en caché". Este ADR
resuelve cómo.
Decisión¶
El pre_token es un JWT firmado con la misma clave RS256 del 0011, con
scope: "pre-auth", y el consumo de un solo uso se garantiza con un
registro en PostgreSQL (no Redis: no añadimos infraestructura que hoy no
tenemos).
1. Formato y claims¶
| Claim | Valor | Por qué |
|---|---|---|
sub |
usuario_id (UUID) |
a quién pertenece |
scope |
"pre-auth" |
nunca access; cualquier endpoint de negocio lo rechaza |
jti |
UUID aleatorio | identifica el consumo (un solo uso) |
iat / exp |
exp = iat + 5 min |
expiración corta, configurable con PRE_TOKEN_TTL_MIN |
aud |
"com.sigfa.plataforma" |
audiencia fija de plataforma (ADR 0020) |
El pre_token no lleva empresa_id, tipo_usuario ni permisos: revelar
las empresas antes de la contraseña era la fuga a evitar (0010), y el paso 2
las re-validará contra la base.
2. Registro de consumo (un solo uso)¶
Tabla nueva trx_pre_tokens (solo se registra el jti, no el token):
CREATE TABLE core.identidad.trx_pre_tokens (
jti UUID PRIMARY KEY, -- claim jti del pre_token
usuario_id UUID NOT NULL REFERENCES core.identidad.mae_usuarios(id),
expiracion TIMESTAMPTZ NOT NULL,
usado_en TIMESTAMPTZ NULL, -- NULL = aún no consumido
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ixp_trx_pre_tokens_usuario ON core.identidad.trx_pre_tokens (usuario_id);
Emisión (paso 1 del login): insertar la fila en la misma transacción que el hash de sesión, y responder lista de empresas + pre_token.
Consumo (paso 2):
- Verificar firma RS256 +
scope=pre-auth+exp+aud(los fallos del JWT se rechazan con 401 sin tocar la base). - Marcar el
jticomo usado:INSERT ... (jti, ...) VALUES (...) ON CONFLICT (jti) DO NOTHINGseguido deSELECT usado_en .... Si ya existía (replay) → tratar como fuga (§3). - Re-validar que
empresa_idpertenezca al usuario (rel_usuario_empresaactiva, ADR 0005/0016). Elpre_tokenno confía en un id de empresa incrustado.
3. Replay = sospecha de compromiso¶
Reusar un jti ya consumido (o expirado) se registra en log_sesiones
(accion='login', exitoso=false, razon_fallo='pre_token_replay') y se
invalidan los pre_tokens pendientes del usuario (UPDATE usado_en a los
NULL de ese usuario): un atacante que copió un login no puede repetirlo ni
disparar más pasos 2.
4. Flujo "una sola empresa" (0010)¶
Aunque el frontend no muestre el modal cuando el usuario tiene una sola empresa, el paso 2 usa igual el pre_token (el servidor no sabe "cuántas empresas" por el layout): mismo flujo, sin atajos.
5. Cambio de empresa sin cerrar sesión¶
El cambio de empresa del 0010 usa un refresh token válido, no un pre_token: el pre_token es exclusivo del flujo credenciales → primer token.
Alternativas descartadas¶
- Registro opaco aleatorio en caché (Redis) — añade infraestructura y un
punto de fallo para algo que PostgreSQL resuelve con una tabla; al mismo
tiempo, sin firma, no se puede verificar
expsin consultar la tabla en cada paso. - pre_token con permisos/empresas incrustados — infla el token y vuelve a filtrar información antes de elegir empresa.
- pre_token de larga duración (30 min) — amplía la ventana de replay de un capturador de tráfico.
- Sin un solo uso (reusable los 5 min) — un token robado se puede reusar hasta que expire; el registro de consumo lo neutraliza al primer uso.
- pre_token como JWT "access" camuflado — un downstream que no revise
scopeaceptaría un token sinempresa_id/permisos;scope: "pre-auth"explícito evita ese error de clase.
Consecuencias¶
- El pre_token se valida en dos movimientos: firma (estático, sin base) y
consumo (una fila en
trx_pre_tokens). Replay detectado → respuesta de fuga. trx_pre_tokenscrece con cada login; la purga de usados/expirados es un job programado (ADRs 0015 y 0021).- El login en dos pasos se mantiene sin exponer las empresas antes de la contraseña y sin depender de Redis.
- Todos los tokens del sistema comparten la audiencia fija de plataforma
(ADR 0020) y las mismas claves;
el pre_token solo se distingue por
scope.
Este ADR resuelve la deuda de seguridad del 0010 (pre_token). La tabla
trx_pre_tokens convive con trx_refresh_tokens/log_sesiones (0005); su
limpieza programada está en el 0021; la expiración (5 min) es configurable
junto al resto de TTLs en el 0020.