Saltar a contenido

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

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

8 diapositivas · se visualiza dentro del sitio (archivo editable .pptx como opción secundaria).


🎬 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 used y el modelo persiste inactive. rotate no lanza dentro de la transacción. POST /api/sesion/refresh todaví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 (authenticate es 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:

EXPLICACIÓN
    ↓
CÓDIGO (lo inserta el generador, verbatim)
    ↓
VISUAL

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

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Rotación y detección de reúso
  5. Árbol de archivos
  6. Anatomía del código
  7. Comandos explicados
  8. Flujos
  9. Recorrido del ISS, paso a paso
  10. Diagnóstico
  11. Conexión con el resto del curso
  12. Glosario
  13. Criterios de aceptación
  14. Evaluación
  15. 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) y revoke* (revoca). Lo consumirá el feature session en 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 dominio status como active | inactive y el código de referencia marca inactive al token rotado. Ambos describen la misma realidad: un token que ya no sirve. Lo que importa para la seguridad es que rotate distingue «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 rotate y 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. Con lock = true añade FOR UPDATE dentro 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 a inactive todas 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.or con Op.lt sobre expires_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 RefreshToken y 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?): genera rawToken (generateOpaqueToken), un family_id nuevo (randomUUID()) y expiresAt; persiste token_hash: sha256Hex(rawToken) con status: "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 una withTransaction. Dentro, con findByHash(hash, t, true) (lock pesimista), decide:
  • no existe → invalid;
  • existe pero no está activo → reuse detection: revokeFamily y devuelve reuse con el número de filas revocadas;
  • expiró → lo pasa a inactive y devuelve expired;
  • activo → lo marca consumido, emite un token nuevo en la misma familia y devuelve rotated con el rawToken nuevo.
  • revokeByToken(rawToken): logout idempotente. Un token inexistente no es error; uno ya revocado devuelve true porque el efecto deseado (que no sirva) ya se cumple.

Hay dos decisiones de diseño que merecen atención:

  1. RotationOutcome es una unión discriminada, no una excepción. Si el reúso lanzara dentro de la transacción, el rollback desharí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.
  2. La expiración usa una ventana deslizante: expiryFromNow() calcula now + REFRESH_TTL_DAYS (por defecto 7, configurable con JWT_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_tokens
API /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 por token_hash, lista por usuario, aplica lock pesimista al rotar y revoca por familia
  • [ ] 19.3 refresh-tokens.service.ts: issue, rotate (con reuse detection) y revoke*
  • [ ] 19.4 refresh-tokens.controller.ts y refresh-tokens.routes.ts (modalidad JWT, sin authorize)
  • [ ] 19.5 refresh-tokens.swagger.ts
  • [ ] 19.6 http/sessions.get.http
  • [ ] npx tsc --noEmit OK

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-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 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/deactivate solo afecta a una sesión propia
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

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 authenticate no habría «sesiones propias»: todas las rutas de este feature son modalidad JWT y dependen del middleware.
  • Habilita el ISS-15. El feature session consumirá issue, rotate y revoke* para implementar login, refresh y logout. 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 modelo RefreshToken del ISS-09.
  • Lo que no reutiliza: la matriz RBAC. Estas rutas no montan authorize porque 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.ts se consolidan en 18-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 por token_hash, lista por usuario, aplica lock pesimista al rotar y revoca por familia
  • [ ] 19.3 refresh-tokens.service.ts: issue, rotate (con reuse detection) y revoke*
  • [ ] 19.4 refresh-tokens.controller.ts y refresh-tokens.routes.ts (modalidad JWT, sin authorize)
  • [ ] 19.5 refresh-tokens.swagger.ts
  • [ ] 19.6 http/sessions.get.http
  • [ ] npx tsc --noEmit OK

Evaluación

Preguntas de comprensión

  1. ¿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).

  2. ¿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.

  3. ¿Qué es family_id y 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.

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

  5. ¿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.

  6. ¿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 active y emitir dos tokens nuevos válidos. Con FOR UPDATE, una gana y la otra encuentra el token consumido: se interpreta como reúso y se revoca la familia.

  7. ¿Por qué rotate devuelve 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, el rollback desharía esa revocación. Devolviendo un valor, la transacción confirma los efectos y el llamador decide el error HTTP después.

  8. ¿Por qué findMineOrFail responde 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 por user_id.

  9. ¿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:

npx tsc --noEmit

Arranca el servidor:

npm run dev

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/deactivate solo afecta a una sesión propia
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

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