📚 Unidad ISS-14 · Feature RefreshTokens (sesiones) — capa 🧠 APRENDER
🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación
Capa Página Para qué 🧠 Aprender esta página comprender, explicar y relacionar 🛠 Construir Feature RefreshTokens (sesiones) ejecutar, programar y verificar ✅ GATE Cierre de la unidad condición para pasar al bloque siguiente
Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).
| Tema | 🧠 Aprender (esta página) | 🛠 Construir (ISS técnico) |
|---|---|---|
| Ruta y ficha de la unidad | Ruta de aprendizaje · Ficha del ISS | Contenido de la unidad |
| Mapas y estructura | Mapa mental · Mapa del backend · Árbol de archivos | Contenido de la unidad |
| Recorrido y comandos | Comandos explicados · Recorrido paso a paso | Contenido de la unidad |
| Diagnóstico | Diagnóstico | Criterios de aceptación |
| Evaluación y cierre | Criterios · Evaluación · GATE | Condiciones de cierre · Cierre |
🖥 Presentación del ISS
Presentación de la unidad. Diapositivas de ISS-14 — Feature RefreshTokens: Sesiones Renovables, Opacas y Revocables (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.
🎬 Video explicativo
Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, la rotación del refresh token del ISS-14. El cuaderno dice
usedy el modelo persisteinactive.rotateno lanza dentro de la transacción.POST /api/sesion/refreshtodavía no existe.
5:23 · narración en español · subtítulos activables desde el reproductor.
ISS-14 — Cuaderno de aprendizaje visual
Tema
Feature RefreshTokens (sesiones renovables y revocables): persistir la sesión como un token opaco del que la base de datos solo guarda un hash SHA-256, con rotación en cada renovación y detección de reúso que revoca la familia entera ante un robo.
Fuente técnica autoritativa
| Archivo fuente | ../manual/16-ISS-14-auth-refresh-tokens.md |
| Estado | Solo lectura — este cuaderno no modifica el ISS |
| Alcance | 746 líneas, 6 apartados (19.1 … 19.6), 1 DoD |
Este cuaderno es una capa pedagógica sobre ese archivo: su contenido técnico (código, criterios y comandos) aparece íntegro y verbatim en la sección Recorrido del ISS, paso a paso. El ISS manda; el cuaderno explica el por qué.
Pregunta que responde: ¿qué archivo es la fuente de verdad de este cuaderno y qué debo esperar de él?
Regla del ISS
Objetivo: persistir las sesiones como tokens opacos, de modo que un access token corto pueda renovarse mientras el usuario trabaja, y que una sesión pueda revocarse de verdad. Bloqueado por: ISS-13 (
authenticatees lo que permite hablar de «sesiones propias»).
La condición que el propio ISS exige es de seguridad verificable, no solo de funcionalidad: en la base de datos nunca hay un refresh token en claro (solo token_hash); rotar dos veces el mismo token dispara la revocación de la familia; y PATCH /api/sesiones/:id/deactivate solo afecta a una sesión propia. Todo ello con npx tsc --noEmit en verde y npm run dev arrancando.
Cómo leer este cuaderno
Cada concepto se presenta tres veces, desde tres ángulos distintos:
CONCEPTO
│
┌───────────┼───────────┐
▼ ▼ ▼
EXPLICACIÓN CÓDIGO VISUAL
│ │ │
¿qué es? ¿dónde está? ¿cómo lo
¿por qué? ¿qué hace? visualizo?
¿para qué? ¿cómo opera? ¿con qué
se relaciona?
Pregunta que responde: ¿cómo está organizado este cuaderno y qué espero encontrar en cada parte?
El recorrido de lectura es siempre el mismo:
Y cada cuaderno contiene los mismos seis componentes:
| Componente | Dónde vive | Para qué sirve |
|---|---|---|
| Texto | todas las secciones | entender el por qué |
| Código | Recorrido del ISS, paso a paso |
ver el qué exacto (verbatim) |
| Diagramas | Mapa mental, Mapa del backend, Rotación y detección de reúso, Flujos |
ver el cómo se conecta |
| Preguntas | Evaluación |
comprobar que entendiste |
| Evaluación | Evaluación |
practicar y autoevaluarte |
| GATE | GATE |
saber si puedes pasar al ISS-15 |
La sección clave de este cuaderno es Rotación y detección de reúso: si entiendes esos dos mecanismos, entiendes el ISS completo.
Ruta de aprendizaje
Esta ruta es específica del ISS-14: construye el ciclo de vida del token, de abajo hacia arriba.
Leer el contrato (19.1 … 19.6)
↓
DTOs: proyectar sin exponer token_hash
↓
Repository: findByHash · lock pesimista · revokeFamily
↓
Service: issue · rotate (reuse detection) · revoke
↓
Controller y rutas JWT (/api/sesiones…)
↓
Swagger (tag Sesiones)
↓
Pruebas HTTP (.http)
↓
Comprobar el hash y la rotación en BD
↓
GATE (tsc + dev + SQL)
Fíjate en lo que no aparece: no hay login, ni refresh, ni logout como endpoints; esos son ISS-15. Aquí construyes la maquinaria (issue, rotate, revoke) y de paso expones la gestión de tus propias sesiones.
Índice
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- Rotación y detección de reúso
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Glosario
- Criterios de aceptación
- Evaluación
- GATE
Ficha del ISS
| Campo | Valor |
|---|---|
| ISS | ISS-14 |
| Título | Feature RefreshTokens (sesiones renovables y revocables) |
| Objetivo | Persistir las sesiones como tokens opacos para que un access token corto se renueve y una sesión se revoque de verdad |
| Fase | Fase II — Auth con RBAC |
| Tecnología principal | Sequelize (transacción + lock pesimista) + node:crypto (SHA-256, randomUUID) + JWT |
| Depende de | ISS-13 — Middlewares de acceso |
| Habilita | ISS-15 — Feature Session |
| Archivos creados | dto/refresh-token-response.dto.ts, dto/index.ts, refresh-tokens.repository.ts, refresh-tokens.service.ts, refresh-tokens.controller.ts, refresh-tokens.routes.ts, refresh-tokens.swagger.ts, http/sessions.get.http |
| Archivos parcheados | Ninguno (el modelo refresh-token.model.ts ya existe desde el ISS-09; el registro en routes/index.ts se consolida en el CIERRE Fase II) |
| Componentes incorporados | DTO con proyección segura, Repository con lock y revocación por familia, Service con issue/rotate/revoke, Controller y rutas JWT, Swagger, .http |
| Verificación principal | DoD: en BD nunca hay token en claro; rotar dos veces el mismo token revoca la familia; npx tsc --noEmit y npm run dev OK |
| Resultado esperado | Sesiones persistidas con hash, rotación por familia y detección de reúso; gestión de las sesiones propias en /api/sesiones… |
| GATE | DoD del ISS-14 + la consulta SQL de verificación sobre refresh_tokens |
Qué implementamos AHORA
El feature completo, con dos responsabilidades bien separadas dentro del mismo service:
- Gestión de las sesiones propias: listar, consultar, revocar una, revocar todas y purgar. Se expone en modalidad JWT (solo
authenticate), porque operar sobre tus propias sesiones deriva de estar autenticado. - Ciclo de vida del token:
issue(emite una sesión),rotate(renueva con rotación y detección de reúso) yrevoke*(revoca). Lo consumirá el featuresessionen el ISS-15.
Y las tres piezas de seguridad que lo definen: hash SHA-256, family_id y lock pesimista al rotar.
Qué todavía NO implementamos
Este ISS no expone el login ni el refresh como endpoints, ni toca el access token. Para que no haya confusión:
| No se implementa aquí | Llega en |
|---|---|
POST /api/sesion/login (verificar contraseña y emitir el par de tokens) |
ISS-15 |
POST /api/sesion/refresh (rota el refresh token) |
ISS-15 |
POST /api/sesion/logout (revoca la sesión) |
ISS-15 |
GET /api/sesion/perfil y GET /api/permisos |
ISS-15 |
El agregador routes/index.ts con las 7 features de auth |
CIERRE Fase II (18-cierre-auth.md) |
Ojo con la trampa habitual: el service ya sabe
rotate, pero todavía nadie lo llama desde un endpoint. Eso es intencional: el ISS-14 construye la maquinaria y el ISS-15 la enchufa a/api/sesion/refresh. Si en el GATE buscas esa ruta, no la encontrarás; lo que sí puedes hacer es insertar y rotar tokens llamando al service, o esperar al ISS-15 para probarlo por HTTP.
Mapa mental del ISS
mindmap
root((ISS-14<br/>RefreshTokens))
Persistencia
token_hash SHA-256
nunca el token en claro
family_id
Ciclo de vida
issue
rotate
revoke
Seguridad
reuse detection
revocacion de familia
lock pesimista
API
api sesiones
modalidad JWT
404 si no es tuya
Verificacion
tsc y dev
SQL sobre refresh_tokens
GATE
familia revocada al reuso
Pregunta que responde: ¿de qué trata este ISS y qué piezas lo componen?
Mapa del backend
Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos?
IMPLEMENTADO HASTA ESTE ISS
───────────────────────────
HTTP
↓
MIDDLEWARES authenticate ✅ authorize ✅
↓
Routes ✅ (negocio JWT + RBAC)
↓
Controller ✅ (+ RefreshTokensController)
↓
Service ✅ (+ RefreshTokensService: issue/rotate/revoke)
↓
Repository ✅ (+ RefreshTokensRepository: findByHash con lock, revokeFamily)
↓
Model ✅ (+ RefreshToken, tabla refresh_tokens)
↓
Sequelize ✅ (transacción + FOR UPDATE)
↓
Base de datos ✅
Seguridad base ....... ✅ JWT HS256 · bcrypt 12 · AppError · sendError · resource-match
users ................ ✅
roles ................ ✅
resources ............ ✅ catálogo de 58 recursos
role_users ........... ✅
resource_roles ....... ✅ la matriz RBAC
access ............... ✅ authenticate / authorize
refresh_tokens ....... ✅ ← NUEVO: hash SHA-256, rotación, detección de reúso
OBJETIVO DE ARQUITECTURA
────────────────────────
sesión (login / refresh / logout / perfil / permisos) .... 🎯 ISS-15
Pregunta que responde: ¿qué capas del backend existen ya y cuáles son todavía objetivo?
Hasta el ISS-13 tenías identidad y permisos, pero ninguna sesión persistida. Aquí aparece la tabla refresh_tokens como fuente de verdad de la sesión y, con ella, la posibilidad de renovar y de revocar.
Rotación y detección de reúso
Esta es la sección que da sentido al ISS. Tres preguntas la gobiernan.
¿Por qué se guarda un hash SHA-256 y no el token en claro?
El refresh token en claro se le entrega al cliente una sola vez, en el momento de emitirlo. Lo que se persiste es sha256Hex(rawToken). La razón es puramente defensiva: si la tabla refresh_tokens se filtra (un volcado, un backup mal guardado, una inyección), el atacante obtiene hashes, no tokens utilizables. No puede presentar un hash en Authorization porque la API hashea lo que recibe y compara hashes: sin el token original, el hash no sirve.
Además, hashear hace que la búsqueda sea O(1) por índice único: findByHash calcula el SHA-256 del token entrante y busca la fila. El token en claro nunca toca la base de datos.
Matiz importante: un refresh token es un valor aleatorio y largo (
generateOpaqueToken), no una contraseña humana. Por eso basta un hash rápido (SHA-256); el coste computacional de bcrypt está pensado para frenar el ataque de diccionario contra secretos de baja entropía, algo que aquí no aplica. Y como el hash es determinista, permite el índice único.
¿Qué es family_id?
family_id es un identificador (un randomUUID()) que agrupa todos los tokens derivados de un mismo login por rotación. Cada vez que el usuario renueva, nace un token nuevo que conserva el family_id del anterior. La familia es, por tanto, la cadena completa de una sesión: el token original, el que lo relevó, el siguiente, etcétera.
Sin familia solo podrías revocar el último eslabón; con familia puedes revocar toda la cadena de una vez. Y hay un índice (ix_refresh_tokens_family_id) precisamente para que esa revocación no recorra la tabla entera.
¿Cómo funciona la rotación y por qué detecta el reúso?
Rotación: cada refresh token es de un solo uso. Al presentar uno válido, rotate lo marca como consumido y emite otro nuevo en la misma familia, con una nueva ventana de expiración (ventana deslizante). El token usado ya no vale: si alguien lo reutiliza, el sistema lo sabe.
Detección de reúso: si llega un token que existe en la base de datos pero ya no está activo (fue rotado), eso solo puede significar una cosa: alguien está presentando una copia de un token que ya se consumió. Como el usuario legítimo ya recibió el token nuevo, ese token viejo únicamente puede estar en manos de un tercero. La respuesta es revocar la familia completa: se invalidan todos los tokens de la cadena, los del atacante y los del usuario legítimo. La sesión se cierra en todas partes y hay que autenticarse de nuevo.
Es un intercambio consciente: se sacrifica la comodidad del usuario legítimo (tendrá que volver a entrar) para contener el robo. Revocar solo el último eslabón dejaría al atacante dentro; revocar la familia lo expulsa.
sequenceDiagram
autonumber
participant U as Usuario legitimo
participant At as Atacante
participant A as API
participant BD as refresh_tokens
U->>A: login
A->>BD: inserta token A en familia F como active
A-->>U: token A en claro (una sola vez)
Note over At: el atacante copia el token A
U->>A: refresh con token A
A->>BD: token A pasa a usado
A->>BD: inserta token B en la misma familia F
A-->>U: token B
At->>A: refresh con token A ya usado
A->>BD: reuse detection sobre la familia F
A->>BD: revoca TODA la familia F
A-->>At: 401
Note over U: la sesion del usuario tambien queda revocada
U->>A: refresh con token B
A-->>U: 401 porque la familia F ya no tiene tokens activos
Pregunta que responde: ¿cómo revela un robo la reutilización de un token y por qué la revocación alcanza a toda la familia?
El ciclo de vida de una fila, en estados:
stateDiagram-v2
direction LR
[*] --> active : issue crea el token
active --> used : rotate lo consume y emite otro de la misma familia
active --> inactive : logout o revocacion explicita
used --> inactive : reuse detection revoca la familia completa
inactive --> [*]
Pregunta que responde: ¿por qué estados pasa un refresh token desde que se emite hasta que deja de servir?
Nota de fidelidad: el ISS describe el token consumido por rotación como
used(y su DoD lo comprueba con esa palabra), mientras que el modelo persistido declara el dominiostatuscomoactive | inactivey el código de referencia marcainactiveal token rotado. Ambos describen la misma realidad: un token que ya no sirve. Lo que importa para la seguridad es querotatedistingue «existe y está activo» de «existe pero ya se consumió», porque esa distinción es la que dispara la revocación de familia.
¿Por qué el lock pesimista?
La rotación es un read-modify-write: leer el token, comprobar su estado, marcarlo y crear el siguiente. Si dos peticiones de refresh llegaran a la vez con el mismo token, sin bloqueo ambas podrían leer active y ambas emitir un token nuevo: dos tokens válidos donde debería haber uno. El lock: UPDATE (FOR UPDATE) dentro de la transacción serializa el acceso a esa fila: una petición gana y la otra encuentra el token ya consumido, lo interpreta como reúso y revoca la familia. Un caso de concurrencia se convierte así en un caso de seguridad correctamente detectado.
| Decisión | Motivo |
|---|---|
| Access token corto (15 min) | acota la ventana de un token robado |
| Refresh token opaco y hasheado | puede revocarse y, si roban la BD, no sirve para autenticarse |
| Rotación en cada refresh | un refresh token es de un solo uso |
Familia (family_id) |
permite revocar toda una cadena de sesión, no solo el último eslabón |
| Reuse detection | un token ya consumido que reaparece implica robo → se corta la familia entera |
| Lock pesimista al rotar | dos refreshes simultáneos no pueden ganar los dos |
flowchart TD
A["POST api sesion refresh con refresh token"] --> B["hash = sha256Hex del token"]
B --> C["findByHash con FOR UPDATE"]
C --> D{"¿Existe el token?"}
D -- "No" --> E["invalid"]
D -- "Sí" --> F{"¿Está activo?"}
F -- "No" --> G["reuse: revokeFamily"]
F -- "Sí" --> H{"¿Expiró?"}
H -- "Sí" --> I["expired: pasa a inactive"]
H -- "No" --> J["el viejo pasa a usado"]
J --> K["emite uno nuevo en la misma familia"]
K --> L["rotated"]
Pregunta que responde: ¿qué decisiones toma
rotatey en qué orden hasta desembocar en cada resultado posible?
Árbol de archivos
Leyenda: ★ = archivo creado en este ISS · △ = archivo existente que se parchea / se reutiliza.
Estructura antes
src/
└── features/
└── auth/
├── access/ (middlewares del ISS-13)
├── users/
├── roles/
├── resources/
├── role-users/
├── resource-roles/
├── rbac.associations.ts
└── refresh-tokens/
└── refresh-token.model.ts (modelo creado en el ISS-09)
Archivos creados / modificados en este ISS
src/
└── features/
└── auth/
└── refresh-tokens/
├── dto/
│ ├── refresh-token-response.dto.ts ★
│ └── index.ts ★
├── http/
│ └── sessions.get.http ★
├── refresh-tokens.repository.ts ★
├── refresh-tokens.service.ts ★
├── refresh-tokens.controller.ts ★
├── refresh-tokens.routes.ts ★
├── refresh-tokens.swagger.ts ★
└── refresh-token.model.ts △ existente desde el ISS-09
Estructura después
src/
└── features/
└── auth/
├── access/
├── users/
├── roles/
├── resources/
├── role-users/
├── resource-roles/
├── rbac.associations.ts
└── refresh-tokens/ ★ feature completo
├── dto/ ★ proyección segura (sin token_hash)
├── http/ ★ pruebas HTTP
├── refresh-tokens.repository.ts
├── refresh-tokens.service.ts
├── refresh-tokens.controller.ts
├── refresh-tokens.routes.ts
├── refresh-tokens.swagger.ts
└── refresh-token.model.ts (existente)
El feature gana la anatomía completa de un CRUD (DTO, repository, service, controller, routes, swagger, http), pero con una diferencia respecto a los CRUD de negocio: aquí el modelo ya existía desde el ISS-09. Este ISS le da comportamiento.
Pregunta que responde: ¿este ISS modifica la estructura del proyecto y en qué exactamente?
Anatomía del código
Archivo: src/features/auth/refresh-tokens/dto/refresh-token-response.dto.ts
Propósito
Definir la forma pública de una sesión: lo que la API puede devolver de una fila de refresh_tokens sin filtrar secretos.
Explicación
El tipo RefreshTokenResponseDto se deriva de RefreshTokenI omitiendo token_hash (Omit<..., "token_hash">) y añadiendo un campo derivado, is_expired, que no es columna sino cálculo (new Date(token.expires_at).getTime() <= Date.now()).
El mapper toRefreshTokenResponse desestructura la instancia (const { token_hash, ...safe } = token.toJSON()) y devuelve un objeto plano. Es la frontera de seguridad del feature: ni siquiera el hash tiene por qué salir de la API. Para revocar una sesión basta su id.
El archivo dto/index.ts es un barrel mínimo que reexporta el DTO para que el resto del feature importe desde una sola ruta.
Se conecta con
- Entrada: el service (que lo construye) y el controller (que lo devuelve).
- Salida: la respuesta HTTP y el Swagger.
Archivo: src/features/auth/refresh-tokens/refresh-tokens.repository.ts
Propósito
Ser el único punto que habla con Sequelize. El resto del feature no conoce la base de datos.
Explicación
El repositorio expone operaciones que describen el diseño de seguridad:
findByHash(tokenHash, transaction?, lock = false): busca por hash. Conlock = trueañadeFOR UPDATEdentro de la transacción. Es la consulta que sostiene la rotación concurrente.findAllByUser(userId, onlyActive = true): lista las sesiones de un usuario, activas por defecto. Alimenta la gestión de sesiones propias.findById(id): una sesión por clave primaria.create/update: alta y persistencia de cambios, ambos con transacción opcional.revokeFamily(familyId, transaction?): pasa ainactivetodas las filas activas de una familia y devuelve cuántas afectó. Es la operación que materializa la detección de reúso.revokeAllByUser(userId): revoca todas las sesiones activas de un usuario (cierre de sesión global).purgeInactiveByUser(userId): borrado físico de sesiones ya revocadas o expiradas (Op.orconOp.ltsobreexpires_at).countActiveByUser(userId): cuenta sesiones activas.
Fíjate en que las operaciones que participan en la rotación (findByHash, create, update, revokeFamily) aceptan una transacción: sin ella no habría atomicidad entre «marcar el viejo» y «crear el nuevo».
Se conecta con
- Entrada: el service, siempre.
- Salida: el modelo
RefreshTokeny Sequelize (transacciones,Op, locks).
Archivo: src/features/auth/refresh-tokens/refresh-tokens.service.ts
Propósito
Orquestar el ciclo de vida del token y la gestión de las sesiones propias. Es donde vive la lógica de seguridad.
Explicación
El service trabaja sobre dos bloques.
Gestión de sesiones propias (getAllMine, getMine, revokeMine, revokeAllMine, purgeMine, countActiveMine) opera siempre con userId. El helper findMineOrFail(userId, id) es la frontera de seguridad: si la sesión no existe o no pertenece al usuario, lanza AppError(404) — no 403, para no revelar que existe un id ajeno.
Ciclo de vida:
issue(userId, deviceInfo, transaction?): generarawToken(generateOpaqueToken), unfamily_idnuevo (randomUUID()) yexpiresAt; persistetoken_hash: sha256Hex(rawToken)constatus: "active"y devuelve{ rawToken, familyId, expiresAt }. El token en claro se devuelve una sola vez; en la base solo queda el hash.rotate(rawToken, deviceInfo): calcula el hash y abre unawithTransaction. Dentro, confindByHash(hash, t, true)(lock pesimista), decide:- no existe →
invalid; - existe pero no está activo → reuse detection:
revokeFamilyy devuelvereusecon el número de filas revocadas; - expiró → lo pasa a
inactivey devuelveexpired; - activo → lo marca consumido, emite un token nuevo en la misma familia y devuelve
rotatedcon elrawTokennuevo. revokeByToken(rawToken): logout idempotente. Un token inexistente no es error; uno ya revocado devuelvetrueporque el efecto deseado (que no sirva) ya se cumple.
Hay dos decisiones de diseño que merecen atención:
RotationOutcomees una unión discriminada, no una excepción. Si el reúso lanzara dentro de la transacción, elrollbackdesharía la revocación de familia que se acaba de escribir. Devolviendo un valor, la revocación confirma aunque el resultado sea un rechazo; el llamador decide el error después.- La expiración usa una ventana deslizante:
expiryFromNow()calculanow + REFRESH_TTL_DAYS(por defecto 7, configurable conJWT_REFRESH_TTL_DAYS). Cada rotación renueva la ventana.
Se conecta con
- Entrada: el controller (gestión de sesiones propias) y, en el ISS-15, el feature
session(login/refresh/logout). - Salida: el repositorio,
sha256Hex/generateOpaqueToken(shared/auth/password),withTransaction(shared/database) y el DTO.
Archivo: src/features/auth/refresh-tokens/refresh-tokens.controller.ts
Propósito
Traducir HTTP a llamadas del service para las sesiones propias.
Explicación
Extiende BaseController y usa this.run(res, ...) para centralizar el manejo de errores. En cada método obtiene la identidad con requireAuthUser(req).id y usa this.paramId(req) para el :id. Expone getAll, getOne, revokeAll, revokeOne y purge, y responde { sessions }, { session } o { message, ... }.
Ninguna operación recibe un userId de fuera de req.auth: es imposible, por construcción, operar sobre la sesión de otro.
Se conecta con
- Entrada: las rutas (siempre detrás de
authenticate). - Salida: el service.
Archivo: src/features/auth/refresh-tokens/refresh-tokens.routes.ts
Propósito
Declarar las rutas del feature en modalidad JWT (solo authenticate, sin authorize).
Explicación
Todas las rutas montan authenticate y ninguna monta authorize. El motivo, explicado en el propio archivo: ver y revocar las propias sesiones es un derecho derivado de estar autenticado, no una concesión de la matriz; no tendría sentido pedir un permiso para cerrar tu propia sesión. Por eso estas rutas tampoco figuran en el catálogo de 58 recursos.
Las rutas son: GET /api/sesiones (listar activas), PATCH /api/sesiones/deactivate-all (revocar todas), GET /api/sesiones/:id (consultar una), PATCH /api/sesiones/:id/deactivate (revocar una) y DELETE /api/sesiones (purgar revocadas/expiradas).
Hay una nota de enrutado importante: deactivate-all es una ruta literal del mismo verbo (PATCH) que /api/sesiones/:id/deactivate. No colisionan (distinto número de segmentos), pero la literal se registra antes por claridad y para que cualquier ruta literal futura siga la misma regla: Express resuelve por orden de registro.
Se conecta con
- Entrada: el agregador de features (CIERRE Fase II).
- Salida:
authenticate(barrel../access) y el controller.
Archivos refresh-tokens.swagger.ts y http/sessions.get.http
Propósito
Documentar y probar el feature.
Explicación
El Swagger declara el tag Sesiones y documenta los cinco endpoints con security: bearerSecurity (modalidad JWT). El esquema Session no incluye token_hash —coherente con el DTO— y sí family_id, device_info, expires_at, status e is_expired. Los errores previstos son 400 (id inválido), 401 (sin token) y 404 (no encontrada o ajena).
El archivo .http recorre el flujo completo: login para obtener el token, listar sesiones, consultar una, revocar todas, revocar una, purgar, y comprobar que sin token la respuesta es 401.
Se conecta con
- Entrada: la herramienta de cliente HTTP / Swagger UI.
- Salida: los endpoints de
/api/sesiones….
Comandos explicados
El ISS no instala dependencias: node:crypto viene con Node y Sequelize ya está en el proyecto. Su verificación es de ejecución y de inspección de datos.
npm run dev
COMANDO
↓
npm run dev
↓
QUÉ HACE
Arranca el servidor Express con recarga en caliente.
↓
POR QUÉ SE NECESITA
La rotación y la revocación se prueban contra la API en marcha.
↓
QUÉ CREA O MODIFICA
Nada en disco. Levanta el proceso.
↓
RESULTADO ESPERADO
Servidor escuchando y base de datos conectada.
↓
CÓMO VERIFICARLO
Un flujo de login + refresh inserta filas en refresh_tokens.
npx tsc --noEmit
COMANDO
↓
npx tsc --noEmit
↓
QUÉ HACE
Compila sin emitir archivos; solo comprueba tipos.
↓
POR QUÉ SE NECESITA
El service usa transacciones, uniones discriminadas y el modelo Sequelize:
un desajuste de tipos aquí evita errores silenciosos en runtime.
↓
QUÉ CREA O MODIFICA
Nada.
↓
RESULTADO ESPERADO
Sin errores (código de salida 0).
↓
CÓMO VERIFICARLO
Salida vacía.
La consulta SQL de verificación
COMANDO
↓
SELECT id, user_id, family_id, status, expires_at FROM refresh_tokens ORDER BY id;
↓
QUÉ HACE
Lista las filas de sesiones con su familia y estado.
↓
POR QUÉ SE NECESITA
Es la forma de comprobar el invariante del ISS: nunca hay un token en claro,
solo token_hash, y la rotación deja rastro en el estado.
↓
QUÉ CREA O MODIFICA
Nada (consulta de solo lectura).
↓
RESULTADO ESPERADO
Tras un login, una fila `active`; tras un refresh, la vieja consumida y una
nueva `active` con el MISMO family_id; tras un reúso, toda la familia `inactive`.
↓
CÓMO VERIFICARLO
Comparando los family_id y los status entre ejecuciones.
Pregunta que responde: ¿qué comandos cierran este ISS y qué demuestra cada uno?
Flujos
El flujo de rotate como diagrama de secuencia, con la transacción y el lock a la vista:
sequenceDiagram
autonumber
participant C as Cliente
participant S as RefreshTokensService
participant TR as Transaccion
participant R as Repository
participant BD as refresh_tokens
C->>S: rotate con refresh token
S->>S: hash = sha256Hex del token
S->>TR: withTransaction
TR->>R: findByHash con FOR UPDATE
R->>BD: SELECT por token_hash bloqueando la fila
BD-->>R: fila o null
alt No existe
R-->>S: invalid
else Existe pero no activo
S->>R: revokeFamily
R->>BD: UPDATE de toda la familia a inactive
R-->>S: reuse con numero de filas
else Existe activo y expirado
S->>R: update a inactive
R-->>S: expired
else Existe activo y vigente
S->>R: update del viejo a usado
S->>R: create del nuevo en la misma familia
R-->>S: rotated con token nuevo
end
TR-->>S: commit
S-->>C: resultado de la rotacion
Pregunta que responde: ¿cómo protege la transacción con lock la rotación y qué pasa en cada uno de sus cuatro caminos?
Observa el detalle fino del ISS: en el camino reuse, la transacción confirma (no revierte) la revocación de la familia. Por eso el service devuelve un valor en lugar de lanzar una excepción dentro de la transacción.
Recorrido del ISS, paso a paso
A partir de aquí viene el contenido técnico completo del ISS, verbatim:
sus objetivos, sus criterios, su código y sus comandos de verificación.
Se reproduce sin resumir y sin reformatear; solo se han degradado los encabezados un nivel para
que aniden bajo esta sección, y se han reescrito los enlaces relativos para que abran bien
desde docs/aprendizaje/ (reescritos a ../manual/).
Fase II: Auth con RBAC — ISS-14 — Feature RefreshTokens (sesiones renovables y revocables)
Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto:
app-storelab-express-ii— Express 5 + TypeScript + Sequelize, arquitectura por features. - Ya construido: Fase I, ISS-09 base, ISS-10 Users, ISS-11 Roles/Resources, ISS-12 pivotes e ISS-13 middlewares. - Recorrido obligatorio de una petición:HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II):../bd-storelab.md§14.6 (refresh_tokens) y §17 (ciclo de vida de la sesión persistida). - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Feature RefreshTokens (sesiones renovables y revocables) Feature / tabla features/auth/refresh-tokens/·refresh_tokensAPI /api/sesiones…(modalidad JWT)Depende de ISS-13 — Middlewares de acceso Habilita ISS-15 — Feature Session
Contenido de este ISS
- 19.1 DTOs del feature
- 19.2 Repository (búsqueda por hash, bloqueo pesimista y revocación por familia)
- 19.3 Service (emitir, rotar con detección de reuso y revocar)
- 19.4 Controller y rutas (sesiones propias, modalidad JWT)
- 19.5 Swagger
- 19.6 Pruebas HTTP
Objetivo: persistir las sesiones como tokens opacos, de modo que un access token corto pueda renovarse mientras el usuario trabaja, y que una sesión pueda revocarse de verdad.
Bloqueado por: ISS-13 (authenticate es lo que permite hablar de «sesiones propias»).
Criterios de aceptación (ISS-14) — consolidados
- [ ] 19.1
refresh-tokens/dto/(refresh-token-response.dto.ts,index.ts) - [ ] 19.2
refresh-tokens.repository.ts: busca portoken_hash, lista por usuario, aplica lock pesimista al rotar y revoca por familia - [ ] 19.3
refresh-tokens.service.ts:issue,rotate(con reuse detection) yrevoke* - [ ] 19.4
refresh-tokens.controller.tsyrefresh-tokens.routes.ts(modalidad JWT, sinauthorize) - [ ] 19.5
refresh-tokens.swagger.ts - [ ] 19.6
http/sessions.get.http - [ ]
npx tsc --noEmitOK
19.1 DTOs del feature
El service nunca devuelve la instancia de Sequelize: proyecta a un DTO plano que jamás incluye token_hash.
: > src/features/auth/refresh-tokens/dto/refresh-token-response.dto.ts
cat >> src/features/auth/refresh-tokens/dto/refresh-token-response.dto.ts << 'EOF'
import { RefreshToken, RefreshTokenI } from "../refresh-token.model";
/**
* Respuesta HTTP de una sesión persistida (refresh token).
*
* `token_hash` **se omite deliberadamente**: es un artefacto de seguridad. Ni
* siquiera su hash tiene por qué salir de la API. El `id` basta para revocar.
*/
export type RefreshTokenResponseDto = Omit<RefreshTokenI, "token_hash"> & {
/** Derivado, no columna: `expires_at` ya pasó. */
is_expired: boolean;
};
/** Mapper modelo -> DTO de respuesta (objeto plano; elimina `token_hash`). */
export function toRefreshTokenResponse(token: RefreshToken): RefreshTokenResponseDto {
const { token_hash, ...safe } = token.toJSON() as RefreshTokenI & { token_hash?: string };
return {
...safe,
is_expired: new Date(token.expires_at).getTime() <= Date.now(),
};
}
EOF
: > src/features/auth/refresh-tokens/dto/index.ts
cat >> src/features/auth/refresh-tokens/dto/index.ts << 'EOF'
export * from "./refresh-token-response.dto";
EOF
19.2 Repository
Tres operaciones son específicas y describen el diseño de seguridad:
| Método | Por qué existe |
|---|---|
findByTokenHash |
el token en claro no se almacena: se busca por su SHA-256 |
rotate con lock: UPDATE |
la rotación es un read-modify-write; el lock evita que dos peticiones concurrentes consuman el mismo refresh token |
revokeFamily |
la reutilización de un token ya rotado es señal de robo: se revoca toda la familia |
: > src/features/auth/refresh-tokens/refresh-tokens.repository.ts
cat >> src/features/auth/refresh-tokens/refresh-tokens.repository.ts << 'EOF'
import { CreationAttributes, Op, Transaction } from "sequelize";
import { RefreshToken } from "./refresh-token.model";
/**
* Capa Repository del feature RefreshTokens (tabla `refresh_tokens`).
*
* Única que habla con Sequelize. Las consultas que participan en la rotación
* aceptan transacción y, cuando corresponde, bloquean la fila (`FOR UPDATE`)
* para que dos peticiones de refresh simultáneas no emitan dos tokens válidos.
*/
export class RefreshTokensRepository {
/**
* Busca por hash del token.
*
* `lock: true` añade `FOR UPDATE` dentro de la transacción: es lo que hace que
* la rotación sea segura bajo concurrencia (solo una petición gana).
*/
public async findByHash(
tokenHash: string,
transaction?: Transaction,
lock = false
): Promise<RefreshToken | null> {
return RefreshToken.findOne({
where: { token_hash: tokenHash },
transaction,
...(lock ? { lock: transaction?.LOCK.UPDATE } : {}),
});
}
/** Sesiones de un usuario (activas o todas según `onlyActive`). */
public async findAllByUser(userId: number, onlyActive = true): Promise<RefreshToken[]> {
const where: Record<string, unknown> = { user_id: userId };
if (onlyActive) where.status = "active";
return RefreshToken.findAll({ where, order: [["createdAt", "DESC"]] });
}
/** Una sesión por PK (o `null`). */
public async findById(id: number): Promise<RefreshToken | null> {
return RefreshToken.findByPk(id);
}
/** Inserta un refresh token (alta de sesión o rotación). */
public async create(
data: CreationAttributes<RefreshToken>,
transaction?: Transaction
): Promise<RefreshToken> {
return RefreshToken.create(data, { transaction });
}
/** Persiste cambios sobre una instancia existente. */
public async update(
token: RefreshToken,
data: Partial<RefreshToken>,
transaction?: Transaction
): Promise<RefreshToken> {
return token.update(data, { transaction });
}
/**
* Revoca **toda la familia** de rotación.
*
* Se ejecuta al detectar reutilización de un token ya rotado: si un atacante
* tiene una copia del token anterior, la sesión legítima se invalida por
* completo y el usuario debe autenticarse de nuevo (Owasp/OAuth2: reuse
* detection con revocación de familia).
*/
public async revokeFamily(familyId: string, transaction?: Transaction): Promise<number> {
const [updated] = await RefreshToken.update(
{ status: "inactive" },
{ where: { family_id: familyId, status: "active" }, transaction }
);
return updated;
}
/** Revoca todas las sesiones activas de un usuario (cierre de sesión global). */
public async revokeAllByUser(userId: number): Promise<number> {
const [updated] = await RefreshToken.update(
{ status: "inactive" },
{ where: { user_id: userId, status: "active" } }
);
return updated;
}
/** Elimina físicamente las sesiones ya expiradas o revocadas de un usuario. */
public async purgeInactiveByUser(userId: number): Promise<number> {
return RefreshToken.destroy({
where: {
user_id: userId,
[Op.or]: [{ status: "inactive" }, { expires_at: { [Op.lt]: new Date() } }],
},
});
}
/** Cuenta las sesiones activas de un usuario. */
public async countActiveByUser(userId: number): Promise<number> {
return RefreshToken.count({ where: { user_id: userId, status: "active" } });
}
}
EOF
19.3 Service — emitir, rotar, revocar
Ciclo de vida (RFC 6749 §1.5, RFC 6750, OWASP):
login → emite family_id = <uuid> + refresh token (se guarda sha256) + access token (15 min)
uso → POST /api/sesion/refresh con el refresh token
├─ token válido y no usado → ROTA: el viejo pasa a `used`, nace uno nuevo (misma familia)
└─ token ya usado → REUSE DETECTION: se revoca la familia completa
logout → revoca el refresh token (o toda la familia)
| Decisión | Motivo |
|---|---|
| Access token corto (15 min) | acota la ventana de un token robado |
| Refresh token opaco y hasheado | puede revocarse y, si roban la BD, no sirve para autenticarse |
| Rotación en cada refresh | un refresh token es de un solo uso |
Familia (family_id) |
permite revocar toda una cadena de sesión, no solo el último eslabón |
| Reuse detection | un token ya consumido que reaparece implica robo → se corta la familia entera |
| Lock pesimista al rotar | dos refreshes simultáneos no pueden ganar los dos |
: > src/features/auth/refresh-tokens/refresh-tokens.service.ts
cat >> src/features/auth/refresh-tokens/refresh-tokens.service.ts << 'EOF'
import { Transaction } from "sequelize";
import { randomUUID } from "node:crypto";
import {
RefreshTokenResponseDto,
toRefreshTokenResponse,
} from "./dto";
import { RefreshTokensRepository } from "./refresh-tokens.repository";
import { RefreshToken } from "./refresh-token.model";
import { AppError } from "../../../shared/errors/app-error";
import { generateOpaqueToken, sha256Hex } from "../../../shared/auth/password";
import { withTransaction } from "../../../shared/database/with-transaction";
/** Vida útil de un refresh token (días). Configurable por entorno. */
const REFRESH_TTL_DAYS = Number(process.env.JWT_REFRESH_TTL_DAYS ?? 7);
/** Resultado de emitir una sesión nueva. */
export interface IssuedSession {
rawToken: string;
familyId: string;
expiresAt: Date;
}
/**
* Resultado de intentar rotar un refresh token.
*
* Se devuelve una **unión discriminada** en lugar de lanzar dentro de la
* transacción: si se lanzara, el `rollback` desharía la revocación de la familia
* que acabamos de escribir. El service de sesión decide el error **después** de
* que la transacción confirme.
*/
export type RotationOutcome =
| { kind: "rotated"; userId: number; rawToken: string; familyId: string; expiresAt: Date }
| { kind: "invalid" }
| { kind: "expired" }
| { kind: "reuse"; familyId: string; revoked: number };
/**
* Capa Service del feature RefreshTokens.
*
* Cubre dos responsabilidades:
* 1. **Gestión de las sesiones propias** (listar, consultar, revocar): es la
* parte que se expone con la modalidad JWT, sin RBAC, porque opera solo sobre
* las sesiones del usuario autenticado.
* 2. **Ciclo de vida del token** (emitir, rotar, revocar), que consume el
* feature `session` en login/refresh/logout.
*/
export class RefreshTokensService {
public constructor(
private readonly repository: RefreshTokensRepository = new RefreshTokensRepository()
) {}
// ================== GESTIÓN (sesiones propias) ==================
public async getAllMine(userId: number): Promise<RefreshTokenResponseDto[]> {
const tokens = await this.repository.findAllByUser(userId);
return tokens.map((token) => toRefreshTokenResponse(token));
}
public async getMine(userId: number, id: number): Promise<RefreshTokenResponseDto> {
return toRefreshTokenResponse(await this.findMineOrFail(userId, id));
}
/** Revoca una sesión propia concreta. */
public async revokeMine(userId: number, id: number): Promise<RefreshTokenResponseDto> {
const token = await this.findMineOrFail(userId, id);
await this.repository.update(token, { status: "inactive" });
return toRefreshTokenResponse(token);
}
/** Revoca **todas** las sesiones propias (útil si se sospecha un robo). */
public async revokeAllMine(userId: number): Promise<number> {
return this.repository.revokeAllByUser(userId);
}
/** Purga las sesiones propias ya revocadas o expiradas. */
public async purgeMine(userId: number): Promise<number> {
return this.repository.purgeInactiveByUser(userId);
}
public async countActiveMine(userId: number): Promise<number> {
return this.repository.countActiveByUser(userId);
}
// ================== CICLO DE VIDA ==================
/** Emite una sesión nueva (alta de login). Genera un `family_id` nuevo. */
public async issue(
userId: number,
deviceInfo: string | null,
transaction?: Transaction
): Promise<IssuedSession> {
const rawToken = generateOpaqueToken();
const familyId = randomUUID();
const expiresAt = expiryFromNow();
await this.repository.create(
{
user_id: userId,
token_hash: sha256Hex(rawToken),
family_id: familyId,
device_info: deviceInfo,
expires_at: expiresAt,
status: "active",
},
transaction
);
// El token en claro se devuelve **una sola vez**; en la base solo queda el hash.
return { rawToken, familyId, expiresAt };
}
/**
* Rota un refresh token: lo invalida y emite uno nuevo con el mismo
* `family_id`. Todo dentro de una transacción con bloqueo de fila.
*
* Concurrencia: si dos peticiones presentan el mismo token, una rota y la otra
* encuentra el token ya inactivo -> se interpreta como reutilización y se
* revoca la familia completa.
*/
public async rotate(rawToken: string, deviceInfo: string | null): Promise<RotationOutcome> {
const hash = sha256Hex(rawToken);
// El valor de retorno de la transacción se decide **dentro**, pero los
// efectos de revocación quedan confirmados aunque el resultado final sea un
// rechazo (por eso no se lanza aquí dentro).
const outcome = await withTransaction<RotationOutcome>(async (t) => {
const current = await this.repository.findByHash(hash, t, true);
if (!current) {
return { kind: "invalid" };
}
// REUSE DETECTION: el token existía pero ya no está activo (fue rotado).
if (current.status !== "active") {
const revoked = await this.repository.revokeFamily(current.family_id, t);
return { kind: "reuse", familyId: current.family_id, revoked };
}
if (new Date(current.expires_at).getTime() <= Date.now()) {
await this.repository.update(current, { status: "inactive" }, t);
return { kind: "expired" };
}
// Rotación: el token usado se invalida y nace uno nuevo en la misma familia.
await this.repository.update(current, { status: "inactive" }, t);
const rawNext = generateOpaqueToken();
const expiresAt = expiryFromNow();
await this.repository.create(
{
user_id: current.user_id,
token_hash: sha256Hex(rawNext),
family_id: current.family_id,
device_info: deviceInfo ?? current.device_info,
expires_at: expiresAt,
status: "active",
},
t
);
return {
kind: "rotated",
userId: current.user_id,
rawToken: rawNext,
familyId: current.family_id,
expiresAt,
};
});
return outcome;
}
/**
* Cierra la sesión asociada a un refresh token (logout).
*
* Es idempotente: un token inexistente o ya revocado no es un error, porque el
* efecto deseado (que no sirva) ya se cumple.
*/
public async revokeByToken(rawToken: string): Promise<boolean> {
const token = await this.repository.findByHash(sha256Hex(rawToken));
if (!token) return false;
if (token.status !== "active") return true;
await this.repository.update(token, { status: "inactive" });
return true;
}
// ================== HELPERS ==================
/**
* Busca una sesión **del propio usuario**.
*
* El filtro por `user_id` es la frontera de seguridad: aunque el RBAC no
* intervenga en estas rutas, un usuario nunca puede ver ni revocar la sesión
* de otro. Un `id` ajeno responde 404, no 403 (no se filtra su existencia).
*/
private async findMineOrFail(userId: number, id: number): Promise<RefreshToken> {
const token = await this.repository.findById(id);
if (!token || token.user_id !== userId) {
throw new AppError(404, "Session not found");
}
return token;
}
}
/** `now + REFRESH_TTL_DAYS`. Ventana deslizante: cada rotación la renueva. */
function expiryFromNow(): Date {
return new Date(Date.now() + REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000);
}
EOF
19.4 Controller y rutas
A diferencia del CRUD de administración, estas rutas son modalidad JWT y sin authorize: ver y revocar las propias sesiones es un derecho derivado de estar autenticado, no una concesión de la matriz (no tendría sentido pedir un permiso para cerrar la propia sesión). Por eso tampoco figuran en el catálogo de 58 recursos.
: > src/features/auth/refresh-tokens/refresh-tokens.controller.ts
cat >> src/features/auth/refresh-tokens/refresh-tokens.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { requireAuthUser } from "../../../shared/auth/auth-user";
import { RefreshTokensService } from "./refresh-tokens.service";
/**
* Capa Controller del feature RefreshTokens — **modalidad JWT**.
*
* Todas las operaciones actúan sobre las sesiones del usuario autenticado
* (`req.auth.id`). No exigen RBAC: poder ver y revocar **tus propias** sesiones
* es un derecho derivado de estar autenticado, no de tener un permiso concreto.
*
* Orden de operaciones (con una salvedad de enrutado, ver `refresh-tokens.routes.ts`):
* getAll → getOne → revokeAll (literal) → revokeOne → purge.
*/
export class RefreshTokensController extends BaseController {
public constructor(
private readonly service: RefreshTokensService = new RefreshTokensService()
) {
super();
}
// ================== READ ==================
public async getAll(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const sessions = await this.service.getAllMine(requireAuthUser(req).id);
res.status(200).json({ sessions });
});
}
public async getOne(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const session = await this.service.getMine(requireAuthUser(req).id, this.paramId(req));
res.status(200).json({ session });
});
}
// ================== STATE (revocar) ==================
/** Revoca **todas** las sesiones del usuario autenticado. */
public async revokeAll(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const revoked = await this.service.revokeAllMine(requireAuthUser(req).id);
res.status(200).json({ message: "All sessions revoked", revoked });
});
}
/** Revoca una sesión propia. */
public async revokeOne(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const session = await this.service.revokeMine(
requireAuthUser(req).id,
this.paramId(req)
);
res.status(200).json({ message: "Session revoked", session });
});
}
// ================== PURGE ==================
/** Purga (borrado físico) las sesiones propias ya revocadas o expiradas. */
public async purge(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const purged = await this.service.purgeMine(requireAuthUser(req).id);
res.status(200).json({ message: "Inactive sessions purged", purged });
});
}
}
EOF
: > src/features/auth/refresh-tokens/refresh-tokens.routes.ts
cat >> src/features/auth/refresh-tokens/refresh-tokens.routes.ts << 'EOF'
import { Application } from "express";
import { RefreshTokensController } from "./refresh-tokens.controller";
import { authenticate } from "../access";
/**
* Rutas del feature RefreshTokens — **modalidad 2 (JWT, sin RBAC)**.
*
* Todas operan sobre las **sesiones del usuario autenticado**. Ver y revocar las
* propias sesiones es un derecho derivado de estar autenticado, no de un permiso
* concreto; por eso no llevan `authorize` ni figuran en el catálogo de recursos.
*
* Nota de enrutado: `/api/sesiones/deactivate-all` es una **ruta literal** del
* mismo verbo (`PATCH`) que la ruta parametrizada de una sola sesión
* (`/api/sesiones/:id/deactivate`). No colisionan porque tienen distinto número
* de segmentos, pero la literal se registra primero por claridad y para que
* cualquier ruta literal futura siga la misma regla (Express resuelve por orden
* de registro).
*/
export class RefreshTokensRoutes {
public refreshTokensController: RefreshTokensController = new RefreshTokensController();
public routes(app: Application): void {
// getAll (sesiones propias)
app
.route("/api/sesiones")
.get(
authenticate,
this.refreshTokensController.getAll.bind(this.refreshTokensController)
);
// revocar todas las sesiones propias (ruta literal: va ANTES de /:id)
app
.route("/api/sesiones/deactivate-all")
.patch(
authenticate,
this.refreshTokensController.revokeAll.bind(this.refreshTokensController)
);
// getOne
app
.route("/api/sesiones/:id")
.get(
authenticate,
this.refreshTokensController.getOne.bind(this.refreshTokensController)
);
// revocar una sesión propia
app
.route("/api/sesiones/:id/deactivate")
.patch(
authenticate,
this.refreshTokensController.revokeOne.bind(this.refreshTokensController)
);
// purga de sesiones propias revocadas/expiradas
app
.route("/api/sesiones")
.delete(authenticate, this.refreshTokensController.purge.bind(this.refreshTokensController));
}
}
EOF
Nota de enrutado:
/api/sesiones/deactivate-alles una ruta literal del mismo verbo (PATCH) que/api/sesiones/:id/deactivate. No colisionan (distinto número de segmentos), pero la literal se registra antes por claridad y por si en el futuro se añade otra ruta literal.
19.5 Swagger
: > src/features/auth/refresh-tokens/refresh-tokens.swagger.ts
cat >> src/features/auth/refresh-tokens/refresh-tokens.swagger.ts << 'EOF'
import { bearerSecurity, invalidIdResponse, unauthorizedResponse } from "../../../shared/http/swagger-security";
/**
* Documentación OpenAPI del feature RefreshTokens — **sesiones propias**.
*
* Modalidad: **JWT** (sin RBAC). No forman parte del catálogo de recursos: ver y
* revocar las **propias** sesiones deriva de estar autenticado, no de un permiso
* concedido. Un `id` de sesión ajeno responde **404** (no se filtra su existencia).
*/
export const refreshTokensSwagger = {
tags: [
{
name: "Sesiones",
description:
"Sesiones persistidas del usuario autenticado (refresh tokens): listar, consultar y revocar — **JWT**",
},
],
paths: {
"/api/sesiones": {
get: {
tags: ["Sesiones"],
summary: "Listar mis sesiones activas",
description: "JWT — devuelve las sesiones del usuario del token. `token_hash` nunca se expone.",
security: bearerSecurity,
responses: {
"200": { description: "Sesiones propias (`{ sessions: [...] }`)" },
"401": unauthorizedResponse,
},
},
delete: {
tags: ["Sesiones"],
summary: "Purgar mis sesiones revocadas/expiradas",
description: "JWT — borrado físico de las sesiones propias ya inútiles.",
security: bearerSecurity,
responses: {
"200": { description: "Purga realizada (`{ message, purged }`)" },
"401": unauthorizedResponse,
},
},
},
"/api/sesiones/deactivate-all": {
patch: {
tags: ["Sesiones"],
summary: "Revocar todas mis sesiones",
description:
"JWT — pone `inactive` todas las sesiones propias (todos los dispositivos). " +
"Útil ante sospecha de robo: el refresh token deja de servir de inmediato.",
security: bearerSecurity,
responses: {
"200": { description: "Sesiones revocadas (`{ message, revoked }`)" },
"401": unauthorizedResponse,
},
},
},
"/api/sesiones/{id}": {
get: {
tags: ["Sesiones"],
summary: "Consultar una sesión propia",
description: "JWT — `family_id`, `device_info`, `expires_at`, `status`. 404 si no es del usuario.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Sesión (`{ session }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"404": { description: "No encontrada o no pertenece al usuario autenticado" },
},
},
},
"/api/sesiones/{id}/deactivate": {
patch: {
tags: ["Sesiones"],
summary: "Revocar una sesión propia",
description: "JWT — revocación lógica de una sesión concreta.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Sesión revocada (`{ message, session }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"404": { description: "No encontrada o no pertenece al usuario autenticado" },
},
},
},
},
components: {
schemas: {
Session: {
type: "object",
properties: {
id: { type: "integer", example: 1 },
user_id: { type: "integer", example: 2 },
family_id: { type: "string", format: "uuid" },
device_info: { type: "string", nullable: true, example: "Mozilla/5.0 ..." },
expires_at: { type: "string", format: "date-time" },
status: { type: "string", enum: ["active", "inactive"], example: "active" },
is_expired: { type: "boolean", example: false },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
},
},
};
EOF
19.6 Pruebas HTTP
: > src/features/auth/refresh-tokens/http/sessions.get.http
cat >> src/features/auth/refresh-tokens/http/sessions.get.http << 'EOF'
### Feature RefreshTokens — SESIONES PROPIAS (modalidad JWT, sin RBAC)
### Ver y revocar las propias sesiones deriva de estar autenticado, no de un permiso.
### Un id de sesión ajeno responde 404 (no se filtra su existencia).
@baseUrl = http://localhost:4000
# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "seller",
"password": "Seller123!"
}
@token = {{loginSeller.response.body.$.access_token}}
### Listar mis sesiones activas (nunca expone `token_hash`)
GET {{baseUrl}}/api/sesiones
Authorization: Bearer {{token}}
### Consultar una sesión propia
GET {{baseUrl}}/api/sesiones/1
Authorization: Bearer {{token}}
### Revocar TODAS mis sesiones (ruta literal; registrar antes que /:id)
PATCH {{baseUrl}}/api/sesiones/deactivate-all
Authorization: Bearer {{token}}
### Revocar una sesión concreta
PATCH {{baseUrl}}/api/sesiones/1/deactivate
Authorization: Bearer {{token}}
### Purgar (borrado físico) mis sesiones revocadas/expiradas
DELETE {{baseUrl}}/api/sesiones
Authorization: Bearer {{token}}
### Modalidad JWT: sin token -> 401
GET {{baseUrl}}/api/sesiones
EOF
Verificación
-- Tras un login hay una fila `active`; tras un refresh, la vieja queda `used`.
SELECT id, user_id, family_id, status, expires_at FROM refresh_tokens ORDER BY id;
DoD del ISS-14
- [ ] Todos los criterios de aceptación (19.1 … 19.6) cumplidos
- [ ] En BD nunca hay un refresh token en claro: solo
token_hash - [ ] Rotar dos veces el mismo token dispara la revocación de la familia
- [ ]
PATCH /api/sesiones/:id/deactivatesolo afecta a una sesión propia - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Diagnóstico
| Síntoma | Causa probable | Solución |
|---|---|---|
| En la tabla aparece un token legible | Se guardó rawToken en vez de sha256Hex(rawToken) |
Persistir siempre token_hash; el token en claro solo se devuelve al cliente |
| Cada refresh falla con «invalid» | El hash calculado no coincide con el guardado (encoding, salt o token mal recortado) | Usar la misma función sha256Hex en issue y en rotate; no transformar el token entrante |
| El refresh devuelve 401 tras rotar una vez | El token presentado es el viejo (ya consumido): es reúso | Enviar siempre el token nuevo que devolvió la última rotación |
| Tras un reúso, el usuario legítimo también queda fuera | Comportamiento correcto: la familia completa se revoca | Volver a iniciar sesión; es el precio deliberado de contener el robo |
| Dos refresh simultáneos emiten dos tokens válidos | La consulta no bloqueó la fila (falta lock: UPDATE o la transacción) |
Rotar siempre con findByHash(hash, t, true) y withTransaction |
GET /api/sesiones/1 de otro usuario devuelve 200 |
Falta el filtro por user_id |
Pasar siempre por findMineOrFail (respuesta 404, no 403) |
GET /api/sesiones devuelve 401 inesperadamente |
Modalidad JWT: falta el Bearer token o el usuario está inactivo | Enviar un access token válido; authenticate revalida users.status |
PATCH /api/sesiones/deactivate-all revoca la sesión equivocada |
Orden de registro de rutas literales vs parametrizadas | Registrar la literal antes que /:id |
El refresh funciona pero la fila vieja sigue active |
No se marcó consumida antes de crear la nueva | Marcar el token actual antes de emitir el siguiente, dentro de la misma transacción |
Pregunta que responde: si algo falla aquí, ¿por dónde empiezo a mirar?
Conexión con el resto del curso
- Lo habilita el ISS-13. Sin
authenticateno habría «sesiones propias»: todas las rutas de este feature son modalidad JWT y dependen del middleware. - Habilita el ISS-15. El feature
sessionconsumiráissue,rotateyrevoke*para implementarlogin,refreshylogout. Aquí se construye la maquinaria; allí se enchufa a/api/sesion/*. - Piezas que reutiliza:
shared/auth/password(generateOpaqueToken,sha256Hex),shared/database/with-transaction(withTransaction),shared/http/base-controller(BaseController.run),shared/auth/auth-user(requireAuthUser),AppError,sendError, y el modeloRefreshTokendel ISS-09. - Lo que no reutiliza: la matriz RBAC. Estas rutas no montan
authorizeporque no hay un permiso que conceder para ver tus propias sesiones. - El cierre. El tag de Swagger y el registro de rutas en
routes/index.tsse consolidan en18-cierre-auth.md.
Glosario
- Refresh token: credencial opaca de larga vida que permite obtener nuevos access tokens sin volver a autenticarse. Se persiste (hasheada) y es revocable.
- Access token: JWT corto (15 min). Autocontenido y no persistido; por eso no se puede revocar individualmente y se compensa manteniéndolo corto.
token_hash: SHA-256 del refresh token. Es lo único que se guarda; permite buscar por índice único sin almacenar el secreto.family_id: identificador que agrupa todos los tokens derivados de un mismo login por rotación. Revocar la familia cierra la cadena completa.- Rotación: cada refresh consume el token usado y emite uno nuevo en la misma familia. Un refresh token es de un solo uso.
- Reuse detection: presentar un token ya consumido se interpreta como robo y dispara la revocación de la familia entera.
- Lock pesimista (
FOR UPDATE): bloqueo de fila dentro de una transacción que serializa la rotación y evita dos tokens válidos para el mismo token de origen. - Ventana deslizante: la expiración se recalcula en cada rotación (
now + TTL), de modo que la sesión sigue viva mientras el usuario trabaje. RotationOutcome: unión discriminada (rotated|invalid|expired|reuse) que evita lanzar excepciones dentro de la transacción para no revertir la revocación.- Purga: borrado físico de sesiones revocadas o expiradas; distinto de la revocación lógica (
status = inactive).
Criterios de aceptación
Los del ISS, textuales, como checklist:
- [ ] 19.1
refresh-tokens/dto/(refresh-token-response.dto.ts,index.ts) - [ ] 19.2
refresh-tokens.repository.ts: busca portoken_hash, lista por usuario, aplica lock pesimista al rotar y revoca por familia - [ ] 19.3
refresh-tokens.service.ts:issue,rotate(con reuse detection) yrevoke* - [ ] 19.4
refresh-tokens.controller.tsyrefresh-tokens.routes.ts(modalidad JWT, sinauthorize) - [ ] 19.5
refresh-tokens.swagger.ts - [ ] 19.6
http/sessions.get.http - [ ]
npx tsc --noEmitOK
Evaluación
Preguntas de comprensión
-
¿Por qué se guarda un hash SHA-256 y no el token en claro? Para que un filtrado de la tabla (volcado, backup, inyección) no entregue credenciales utilizables. El atacante obtendría hashes, y como la API hashea lo que recibe y compara hashes, un hash no sirve para autenticarse sin el token original. Además, el hash determinista permite buscar por índice único en O(1).
-
¿Por qué SHA-256 y no bcrypt, si bcrypt es «más seguro» para secretos? Porque los refresh tokens no son contraseñas humanas: son valores aleatorios y largos, con mucha entropía y por tanto no susceptibles a un ataque de diccionario. Para ese tipo de secreto basta un hash rápido, y conviene que sea rápido y determinista porque se calcula en cada búsqueda y se apoya en un índice único.
-
¿Qué es
family_idy sin él qué no podrías hacer? Es el identificador que agrupa todos los tokens derivados de un mismo login por rotación. Sin familia solo podrías revocar el último eslabón; con familia puedes revocar la cadena completa, que es lo que exige la detección de reúso. -
Describe la rotación en una frase y la detección de reúso en otra. Rotación: cada refresh consume el token presentado y emite uno nuevo que conserva el
family_id, renovando además la expiración. Reúso: si llega un token que existe pero ya fue consumido, se asume robo y se revoca toda la familia, expulsando tanto al atacante como al usuario legítimo. -
¿Por qué revocar la familia entera y no solo el token reutilizado? Porque el token reutilizado ya está inservible (fue consumido); el problema es que quien lo presenta tiene una copia, es decir, es un atacante. Revocar solo ese token no lo expulsa: seguiría usando el token nuevo si lo tuviera, o el eslabón vigente. Revocar la familia corta la cadena completa y obliga a reautenticarse, conteniendo el robo.
-
¿Qué aporta el lock pesimista y qué pasaría sin él? Serializa el read-modify-write de la rotación. Sin él, dos peticiones simultáneas con el mismo token podrían leer ambas
activey emitir dos tokens nuevos válidos. ConFOR UPDATE, una gana y la otra encuentra el token consumido: se interpreta como reúso y se revoca la familia. -
¿Por qué
rotatedevuelve una unión discriminada en lugar de lanzar excepciones? Porque la revocación de familia por reúso se escribe dentro de la transacción y debe confirmarse aunque el resultado sea un rechazo. Si se lanzara una excepción, elrollbackdesharía esa revocación. Devolviendo un valor, la transacción confirma los efectos y el llamador decide el error HTTP después. -
¿Por qué
findMineOrFailresponde 404 y no 403 cuando el id es de otro usuario? Porque un 403 confirmaría que esa sesión existe y pertenece a alguien: filtraría información. El 404 trata «no existe» y «no es tuya» de la misma forma, de modo que un usuario no puede enumerar sesiones ajenas. Es la misma frontera de seguridad que el filtro poruser_id. -
¿Por qué estas rutas no montan
authorize, a diferencia de las de negocio? Porque operar sobre tus propias sesiones es un derecho derivado de estar autenticado, no una concesión de la matriz RBAC. No tiene sentido pedir un permiso para cerrar tu propia sesión, así que estas rutas son JWT puras y no figuran en el catálogo de 58 recursos.
Ejercicios
Ejercicio 1 — Traza la rotación. Un usuario hace login (token A) y luego dos refreshes consecutivos (token B, luego token C), todos válidos. Describe el estado de las filas después de cada paso.
Respuesta razonada
Asumiendo una sola familia `F`: - Tras el login: fila A con `family_id = F`, `status = active`. - Tras el primer refresh: A pasa a consumido y se inserta B con `family_id = F`, `status = active`. - Tras el segundo refresh: B pasa a consumido y se inserta C con `family_id = F`, `status = active`. En todo momento hay un único token activo por familia. Los tokens A y B ya no sirven: si alguien los presenta, `rotate` los encuentra pero no activos, y en lugar de emitir nada **revoca toda la familia F**, dejando A, B y C inactivos.Ejercicio 2 — El robo. Un atacante copia el token B. El usuario legítimo refresca (usa B, obtiene C). Luego el atacante presenta B. Explica qué pasa y por qué el usuario legítimo también queda fuera.
Respuesta razonada
Cuando el usuario legítimo refresca con B, B se consume y nace C en la misma familia. Cuando el atacante presenta B, el sistema lo encuentra pero lo ve no activo: reúso. La respuesta es `revokeFamily`, que pasa a inactivo **toda** la familia. A partir de ahí, el atacante no puede renovar (B está consumido y la familia revocada) y el usuario legítimo tampoco (C pasa a inactivo). Ambos deben reautenticarse con usuario y contraseña. Que el usuario legítimo quede fuera es un efecto deliberado: en el momento del reúso no hay forma de distinguir con certeza quién es el dueño del token viejo, así que la opción segura es cerrar la sesión entera y forzar un login limpio.Ejercicio 3 — Concurrencia. Dos peticiones de refresh llegan exactamente a la vez con el mismo token activo, y la consulta no usa lock. Explica el fallo y cómo lo corrige el lock pesimista.
Respuesta razonada
Sin lock, ambas transacciones pueden leer la fila como `active` antes de que ninguna la modifique. Ambas concluirían «token válido y no usado» y cada una emitiría un token nuevo: **dos tokens activos para el mismo origen**, y el invariante de un solo uso por refresh se rompe. Con `findByHash(hash, t, true)` (que añade `FOR UPDATE`), la segunda transacción espera a que la primera termine. Al continuar, lee el estado ya actualizado: el token está consumido, así que lo interpreta como reúso y **revoca la familia**. La carrera se convierte en una detección de seguridad en lugar de un agujero. Detalle de diseño: por eso `rotate` devuelve un valor en vez de lanzar; la transacción que detecta el reúso debe **confirmar** la revocación, no revertirla.GATE
Todos los comandos exactos del cierre del ISS. Compila sin errores de tipos:
Arranca el servidor:
Ejecuta el flujo de prueba (http/sessions.get.http) y, con el servidor en marcha, inspecciona la base de datos:
-- Tras un login hay una fila `active`; tras un refresh, la vieja queda consumida
-- (el ISS la describe como `used`) y nace otra `active` con el MISMO family_id.
SELECT id, user_id, family_id, status, expires_at FROM refresh_tokens ORDER BY id;
Resultado esperado: ninguna columna contiene un token en claro (solo token_hash); cada refresh deja la fila anterior consumida y una nueva activa en la misma familia; rotar dos veces el mismo token dispara la revocación de la familia completa.
Checklist de cierre (DoD del ISS-14):
- [ ] Todos los criterios de aceptación (19.1 … 19.6) cumplidos
- [ ] En BD nunca hay un refresh token en claro: solo
token_hash - [ ] Rotar dos veces el mismo token dispara la revocación de la familia
- [ ]
PATCH /api/sesiones/:id/deactivatesolo afecta a una sesión propia - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Con el GATE en verde, la sesión ya es persistida, renovable y revocable. El paso siguiente es el ISS-15 — Feature Session, donde login, refresh y logout exponen por HTTP la maquinaria que acabas de construir.
Navegación de la ruta: ← ISS-13 · 🛠 Construir · ↑ Ruta Express · → ISS-14 · 🛠 Construir