📚 Unidad ISS-15 · Feature Session (login y perfil) — 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 Session (login y perfil) 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-15 — Feature Session: Inicio de Sesión, Registro y Perfil (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, el inicio de sesión del ISS-15. Usuario ausente, inactivo o contraseña mala responden el mismo 401. El archivo de rutas de sesión existe; montarlo en la app es el cierre de fase, no esta unidad.
5:05 · narración en español · subtítulos activables desde el reproductor.
ISS-15 — Cuaderno de aprendizaje visual
Tema
Feature Session: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos, demostrando que las tres modalidades de acceso (OPEN, JWT y JWT + RBAC) conviven sin fricción.
Fuente técnica autoritativa
| Archivo fuente | ../manual/17-ISS-15-auth-session.md |
| Estado | Solo lectura — este cuaderno no modifica el ISS |
| Alcance | Feature features/auth/session/: DTOs, service, controller, rutas, Swagger y pruebas .http; cierre con verificación E2E de las tres modalidades |
El ISS manda; el cuaderno explica. Todo el contenido técnico del ISS (código, rutas, credenciales y criterios) aparece aquí íntegro y verbatim más abajo, en la sección Recorrido del ISS, paso a paso. Lo único que añade este cuaderno es el por qué.
Pregunta que responde: ¿de dónde sale cada dato técnico de este cuaderno?
Regla del ISS
Objetivo: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos. Es el feature que demuestra que las tres modalidades conviven sin fricción. Bloqueado por: ISS-14.
La condición que el propio ISS exige se recoge en su DoD: las tres modalidades deben ser observables con curl (401 sin token, 403 sin concesión, 200/201 con ella), GET /api/permisos debe reflejar la matriz real (58 para admin, 7 para seller), la rotación de refresh debe invalidar el token anterior, y npx tsc --noEmit debe pasar sin errores con npm run dev arrancando.
Pregunta que responde: ¿qué tiene que pasar para poder dar este ISS por bueno?
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 por qué se enseña todo tres veces?
El recorrido de lectura es siempre el mismo:
Pregunta que responde: ¿en qué orden recorro cada concepto dentro del cuaderno?
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 |
ver el qué exacto (verbatim del ISS) |
| Diagramas | Mapa mental, Mapa del backend, 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 siguiente ISS |
Ruta de aprendizaje
Esta ruta es específica de ISS-15: primero los DTOs, después el service (el corazón del ciclo de vida), luego controller y rutas, y al final Swagger, pruebas HTTP y verificación E2E.
Escribir los DTOs (login, refresh, logout, respuesta, index)
↓
Escribir session.service.ts (login, refresh, logout, profile, myPermissions)
↓
Escribir session.controller.ts (this.run + deviceInfo)
↓
Escribir session.routes.ts (OPEN + JWT en un archivo)
↓
Escribir session.swagger.ts (openSecurity / bearerSecurity)
↓
Escribir los .http (login, refresh, perfil)
↓
Verificación E2E con curl
↓
npx tsc --noEmit y npm run dev
↓
Verificar (GATE)
Pregunta que responde: ¿cuál es el camino concreto que sigo para completar este ISS?
Índice
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- Árbol de archivos
- Anatomía del código
- Flujos
- Comandos explicados
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Criterios de aceptación
- Evaluación
- Glosario
- GATE
- Anexo — Pruebas E2E de los endpoints de Business bajo las 3 modalidades de acceso
Ficha del ISS
| Campo | Valor |
|---|---|
| ISS | ISS-15 |
| Título | Feature Session (login, refresh, logout, perfil y permisos) |
| Objetivo | Exponer el punto de entrada (login), la renovación automática (refresh con rotación), la salida (logout), el perfil y los permisos efectivos del usuario |
| Fase | Fase II — Auth con RBAC |
| Tecnología principal | Express 5 + TypeScript + Sequelize; JWT HS256 y refresh tokens con rotación |
| Depende de | ISS-14 — Feature RefreshTokens |
| Habilita | Cierre de Fase II |
| Archivos creados | session/dto/{login,refresh-session,logout-session,session-response,index}.ts, session.service.ts, session.controller.ts, session.routes.ts, session.swagger.ts, session/http/{session.login,session.refresh,session.profile}.http |
| Archivos parcheados | Ninguno en este ISS (el ISS solo enumera altas dentro de features/auth/session/) |
| Componentes incorporados | Feature Session: DTOs + SessionService + SessionController + SessionRoutes + módulo Swagger + pruebas .http |
| Verificación principal | Recorrido E2E que prueba OPEN → JWT → JWT + RBAC (401 / 403 / 200-201) y npx tsc --noEmit |
| Resultado esperado | Login, refresh (con rotación), logout, perfil y permisos funcionando; las tres modalidades observables con curl |
| GATE | npx tsc --noEmit sin errores, npm run db:seed && npm run dev, y las comprobaciones E2E en verde |
Qué implementamos AHORA
- DTOs del feature:
LoginDto,RefreshSessionDto,LogoutSessionDto,SessionTokensDtoyProfileDto, más el barrilindex.ts. - El
SessionService, que orquesta piezas ya construidas: autentica (login), rota (refresh), revoca (logout), devuelve identidad (profile) y lista permisos (myPermissions). - El
SessionControllerconthis.run(res, …)y el helperdeviceInfoque lee elUser-Agent. - Las rutas donde conviven las tres modalidades:
login/refresh/logoutOPEN yperfil/permisosJWT. - El módulo Swagger
sessionSwaggerconopenSecurityen las OPEN ybearerSecurityen las JWT. - Los archivos
.httpde login, refresh y perfil, y la verificación E2E documentada.
Qué todavía NO implementamos
| No se implementa aquí | Llega en |
|---|---|
La base de seguridad (JWT HS256, bcrypt 12, AppError, sendError) |
ISS-09 |
Feature users (hash, cambio de contraseña, permisos efectivos) |
ISS-10 |
Features roles y resources (catálogo de 58 recursos) |
ISS-11 |
role_users y resource_roles (la matriz RBAC) |
ISS-12 |
Middlewares authenticate / authorize |
ISS-13 |
refresh_tokens (hash SHA-256, rotación, detección de reúso) |
ISS-14 |
El registro de SessionRoutes en el enrutador de la App |
Cierre de Fase II |
Autorización granular (authorize) en las propias rutas de sesión |
No aplica: los puntos de acceso previos a la matriz no la llevan |
Ojo con la trampa habitual:
POST /api/sesion/refreshyPOST /api/sesion/logoutson OPEN de facto, pero no están «abiertos»: exigen el refresh token en el cuerpo. OPEN significa «sin identidad previa (sin access token)», no «sin credencial».
Mapa mental del ISS
mindmap
root((ISS-15<br/>Feature Session))
Objetivo
Punto de entrada login
Renovacion refresh
Salida logout
Perfil y permisos
Service
login
refresh
logout
profile
myPermissions
DTOs
LoginDto
RefreshSessionDto
LogoutSessionDto
SessionTokensDto
Rutas
OPEN login refresh logout
JWT perfil permisos
Seguridad
Rotacion de refresh
Deteccion de reuso
Ventana deslizante
Mismo 401 generico
Pruebas
Archivos http
Recorrido E2E con curl
GATE
tsc noEmit
npm run dev
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 → Express App ✅
↓
Routes → Controller → Service → Repository → Model → DTO ✅
↓
Sequelize → BD ✅
Seeders ✅ (ISS-04)
Swagger / api docs ✅ (ISS-05)
Fase I — Business (clients, product-types, products, sales) ✅
Seguridad base, users, roles, resources, pivotes ✅ (ISS-09 … ISS-12)
Middlewares authenticate / authorize ✅ (ISS-13)
refresh_tokens con rotación ✅ (ISS-14)
🆕 Sesión (login / refresh / logout / perfil / permisos) ✅ (ISS-15)
features/auth/session/
├── dto/ (LoginDto, RefreshSessionDto, LogoutSessionDto, SessionTokensDto, ProfileDto)
├── session.service.ts (orquesta)
├── session.controller.ts
├── session.routes.ts (OPEN + JWT)
├── session.swagger.ts
└── http/ (login, refresh, profile)
LAS TRES MODALIDADES DE ACCESO QUE CONVIVEN
├── OPEN → POST /api/sesion/login
│ POST /api/sesion/refresh
│ POST /api/sesion/logout
├── JWT → GET /api/sesion/perfil
│ GET /api/permisos
└── JWT + RBAC → rutas de negocio (ej. GET /api/clientes)
OBJETIVO DE ARQUITECTURA
────────────────────────
🎯 Cierre de Fase II (18-cierre-auth.md)
⬜ Todo el laboratorio queda construido tras el cierre
Pregunta que responde: ¿qué capas del backend existen ya y cuáles siguen siendo objetivo?
Este es el ISS donde la arquitectura de seguridad se ve completa: las tres modalidades no se sustituyen entre sí, se suman en el mismo proyecto.
Árbol de archivos
Leyenda: ★ = creado o trabajado en este ISS · △ = existente parcheado en este ISS.
Estructura antes
src/
├── config/
├── database/
│ ├── db.ts
│ └── seeders/
├── features/
│ └── auth/
│ ├── access/ (authenticate, authorize)
│ ├── refresh-tokens/
│ │ ├── refresh-token.model.ts
│ │ ├── refresh-tokens.repository.ts
│ │ └── refresh-tokens.service.ts
│ ├── resource-roles/
│ ├── role-users/
│ ├── roles/
│ ├── resources/
│ ├── users/
│ └── session/ ← todavía no existe
├── routes/
│ └── index.ts
├── shared/
│ ├── auth/
│ │ ├── auth-user.ts (requireAuthUser)
│ │ ├── jwt.ts (signAccessToken, ACCESS_TOKEN_TTL_SECONDS)
│ │ └── password.ts (comparePassword)
│ ├── errors/
│ │ └── app-error.ts (AppError)
│ └── http/
│ ├── base-controller.ts (BaseController.run)
│ └── swagger-security.ts (openSecurity, bearerSecurity, …)
└── server.ts
Pregunta que responde: ¿qué archivos existían ya antes de empezar este ISS?
Archivos creados / modificados en este ISS
★ src/features/auth/session/dto/login.dto.ts
★ src/features/auth/session/dto/refresh-session.dto.ts
★ src/features/auth/session/dto/logout-session.dto.ts
★ src/features/auth/session/dto/session-response.dto.ts
★ src/features/auth/session/dto/index.ts
★ src/features/auth/session/session.service.ts
★ src/features/auth/session/session.controller.ts
★ src/features/auth/session/session.routes.ts
★ src/features/auth/session/session.swagger.ts
★ src/features/auth/session/http/session.login.http
★ src/features/auth/session/http/session.refresh.http
★ src/features/auth/session/http/session.profile.http
Pregunta que responde: ¿qué toca exactamente este ISS y con qué rol?
Estructura después
src/
├── features/
│ └── auth/
│ ├── access/
│ ├── refresh-tokens/
│ ├── resource-roles/
│ ├── role-users/
│ ├── roles/
│ ├── resources/
│ ├── users/
│ └── session/ ★ (nuevo)
│ ├── dto/ ★
│ │ ├── login.dto.ts
│ │ ├── refresh-session.dto.ts
│ │ ├── logout-session.dto.ts
│ │ ├── session-response.dto.ts
│ │ └── index.ts
│ ├── http/ ★
│ │ ├── session.login.http
│ │ ├── session.refresh.http
│ │ └── session.profile.http
│ ├── session.service.ts ★
│ ├── session.controller.ts
│ ├── session.routes.ts ★
│ └── session.swagger.ts ★
└── server.ts
Pregunta que responde: ¿cómo queda el proyecto después de este ISS?
Anatomía del código
Archivo: src/features/auth/session/dto/…
Propósito
Definir los contratos de entrada y salida del feature. Son interfaces puras (interface), sin lógica.
Explicación
LoginDto—identifier(usuario o correo, normalizado a minúsculas) ypassword. Es la entrada dePOST /api/sesion/login.RefreshSessionDto—refresh_token. El token viaja en el cuerpo, no enAuthorization, porque es una credencial de sesión, no un token de acceso.LogoutSessionDto— tambiénrefresh_token: se revoca la sesión concreta que se presenta.SessionTokensDto— el par de tokens:access_token,token_type("Bearer"),expires_in,refresh_tokenyrefresh_expires_in. El refresh se devuelve en claro solo aquí, porque el servidor guarda únicamente su hash.ProfileDto— datos públicos del perfil:id,username,email,avatar,status. Nunca incluyepassword.index.ts— barril que reexporta los cuatro DTOs.
Se conecta con
- Entrada: los importa
session.service.ts,session.controller.tsysession.swagger.ts. - Salida: no llama a nada; son tipos.
Archivo: src/features/auth/session/session.service.ts
Propósito
Ser el ciclo de vida de la sesión. Orquesta piezas ya construidas y no duplica su lógica.
| Método | Reutiliza | Hace |
|---|---|---|
login |
UsersRepository, comparePassword, RefreshTokensService.issue |
autentica y abre sesión |
refresh |
RefreshTokensService.rotate |
renueva el access token rotando el refresh |
logout |
RefreshTokensService.revokeByToken |
revoca la sesión |
profile |
UsersRepository.findById |
devuelve la identidad |
myPermissions |
ResourceRolesService.findEffectiveForUser |
lista los (method, path) vigentes |
Explicación
login(body, deviceInfo)— exigeidentifierypassword; busca confindByIdentifierWithPassword; si no existe o estáinactive, lanza401 "Invalid credentials"; si la contraseña no coincide (comparePassword), lanza el mismo401; si todo va bien,issue(user.id, deviceInfo)abre la familia ybuildTokensarma el par.refresh(body, deviceInfo)— exigerefresh_token; llama arotatey traduce el resultado:invalid→401 "Invalid refresh token",expired→401 "Refresh token expired",reuse→401con el mensaje de familia revocada. Después revalida la identidad: si el usuario fue desactivado,revokeAllMiney401 "User is not active".logout(body)— exigerefresh_tokeny llama arevokeByToken. Idempotente.profile(userId)— busca el usuario; si no existe o no está activo,404 "User not found"; si existe, lo proyecta contoProfile.myPermissions(userId)— delega enresourceRolesService.findEffectiveForUser(userId).buildTokens(user, refreshToken, refreshExpiresAt)— firma el access consignAccessToken({ id, username })y calcularefresh_expires_inen segundos conMath.max(0, Math.floor(...)).- Seguridad del login — la respuesta es idéntica para «usuario inexistente» y «contraseña incorrecta»; evita la enumeración de usuarios.
Se conecta con
- Entrada:
SessionController(todas las operaciones). - Salida:
UsersRepository,RefreshTokensService,ResourceRolesService,comparePassword,signAccessToken.
Archivo: src/features/auth/session/session.controller.ts
Propósito
Traducir HTTP ↔ service: leer el cuerpo y la identidad, y responder. Mezcla las dos modalidades base.
Explicación
extends BaseController— aporta la envolturathis.run(res, async () => { … }), que concentra el manejo de errores (y por eso losAppErrordel service salen como códigos HTTP).- OPEN —
login,refreshylogoutleenreq.bodyy llaman al service;loginyrefreshresponden200con el par de tokens,logoutcon{ message: "Session closed" }. - JWT —
profileymyPermissionsobtienen la identidad conrequireAuthUser(req).id, resuelta antes por el middlewareauthenticate. deviceInfo(req)— helper local que toma elUser-Agent, devuelvenullsi falta y lo recorta a 500 caracteres para la auditoría.
Se conecta con
- Entrada: las rutas (
session.routes.ts). - Salida:
SessionServicey, en las JWT,requireAuthUser.
Archivo: src/features/auth/session/session.routes.ts
Propósito
Montar las tres modalidades en un solo archivo, declarando qué middleware protege cada ruta.
Explicación
- Clase
SessionRoutes— exponesessionControllery un métodoroutes(app). - OPEN —
POST /api/sesion/login,POST /api/sesion/refreshyPOST /api/sesion/logoutse registran sin middleware. - JWT —
GET /api/sesion/perfilyGET /api/permisosse registran conauthenticate(importado de../access) por delante del controller. - Sin
authorize— ninguna ruta de sesión lleva autorización granular: son puntos de acceso previos o ajenos a la matriz de permisos.
Se conecta con
- Entrada: la instanciará el enrutador de la App (registro que no aparece en este ISS).
- Salida:
SessionControllery el middlewareauthenticate.
Archivo: src/features/auth/session/session.swagger.ts
Propósito
Documentar el feature con las tres modalidades juntas y declarar la seguridad por operación.
Explicación
openSecurity— enlogin,refreshylogout, que no exigen access token.bearerSecurity— enperfilypermisos, más la respuesta401normalizada.- Cuerpos documentados —
requestBodycon los esquemasLoginyRefreshToken. components.schemas—Login,RefreshTokenySessionTokens.- Nota didáctica del ISS — OPEN no significa «sin base de datos»: el login lee el hash de
usersy escriberefresh_tokens.
Se conecta con
- Entrada: lo agrega el registry
src/swagger/index.ts(patrón de ISS-05). - Salida: importa
bearerSecurity,openSecurityyunauthorizedResponse.
Archivo: src/features/auth/session/http/…
Propósito
Dejar pruebas HTTP reproducibles (REST Client) de login, refresh y perfil.
Explicación
session.login.http— login conadmin, login por correo, un401de credenciales inválidas, y variables encadenadas (@adminAccessToken,@adminRefreshToken,@expiresIn) para reutilizar en otros archivos.session.refresh.http— login → refresh → usar el access nuevo → reusar el token viejo (401) → comprobar que el rotado tampoco sirve.session.profile.http— perfil y permisos con token deseller,401sin token y logout idempotente.- Credenciales de ejemplo —
admin / Admin123!(rol ADMIN, 58 permisos) yseller / Seller123!(rol SELLER, 7 permisos).
Se conecta con
- Entrada: las ejecuta el cliente REST del editor.
- Salida: consume los endpoints reales del servidor.
Flujos
Flujo de login (OPEN)
sequenceDiagram
autonumber
participant Cliente
participant Ctrl as "SessionController"
participant Svc as "SessionService"
participant Users as "UsersRepository"
participant RT as "RefreshTokensService"
participant DB as "BD"
Cliente->>Ctrl: POST /api/sesion/login
Ctrl->>Svc: login(body, deviceInfo)
Svc->>Users: findByIdentifierWithPassword(identifier)
Users->>DB: SELECT usuario
DB-->>Users: usuario o null
Svc->>Svc: comparePassword()
Svc->>RT: issue(user.id, deviceInfo)
RT->>DB: INSERT refresh_tokens
Svc->>Svc: signAccessToken()
Svc-->>Ctrl: SessionTokensDto
Ctrl-->>Cliente: 200 access_token + refresh_token
Pregunta que responde: ¿qué ocurre paso a paso cuando un usuario hace login?
Flujo de rotación del refresh token
sequenceDiagram
autonumber
participant Cliente
participant Ctrl as "SessionController"
participant Svc as "SessionService"
participant RT as "RefreshTokensService"
participant DB as "BD"
Cliente->>Ctrl: POST /api/sesion/refresh
Ctrl->>Svc: refresh(body, deviceInfo)
Svc->>RT: rotate(refresh_token, deviceInfo)
RT->>DB: marca el token presentado como used
RT->>DB: INSERT nuevo refresh con la misma familia
RT-->>Svc: kind ok con rawToken
Svc->>Svc: revalida usuario y signAccessToken()
Svc-->>Ctrl: par nuevo
Ctrl-->>Cliente: 200 access + refresh rotado
Note over Cliente,DB: Reuso: token viejo -> 401 y familia revocada
Pregunta que responde: ¿cómo se renueva la sesión y qué pasa si un refresh se reutiliza?
Ciclo de vida de la sesión
stateDiagram-v2
[*] --> Activa: login valido
Activa --> Activa: refresh con rotacion
Activa --> Revocada: logout
Activa --> Revocada: reuso detectado
Activa --> Expirada: vence la ventana
Revocada --> [*]
Expirada --> [*]
Pregunta que responde: ¿por qué estados puede pasar una sesión desde que nace hasta que muere?
Las tres modalidades conviviendo
flowchart TD
A["Cliente"] --> B{"¿Qué operación?"}
B -->|"login / refresh / logout"| C["OPEN: credencial en el cuerpo"]
B -->|"perfil / permisos"| D["JWT: authenticate"]
B -->|"rutas de negocio"| E["JWT + RBAC: authenticate + authorize"]
C --> F["200 con tokens o 401"]
D --> G["200 con datos o 401"]
E --> H["200/201 si autorizado o 403"]
Pregunta que responde: ¿cómo distingue el backend qué modalidad aplica a cada petición?
Comandos explicados
npm run db:seed && npm run dev
COMANDO
↓
npm run db:seed && npm run dev
↓
QUÉ HACE
Siembra la base (usuarios, roles y permisos) y luego arranca el servidor.
↓
POR QUÉ SE NECESITA
El recorrido E2E necesita las credenciales admin/seller y la matriz RBAC
cargadas antes de probar login y permisos.
↓
QUÉ CREA O MODIFICA
Filas en la BD (seed) y el proceso del servidor en http://localhost:4000.
↓
RESULTADO ESPERADO
Seeders en verde y servidor arrancado sin error.
↓
CÓMO VERIFICARLO
La consola muestra el arranque del servidor; detenerlo con Ctrl+C al final.
npx tsc --noEmit
COMANDO
↓
npx tsc --noEmit
↓
QUÉ HACE
Comprueba los tipos de todo el proyecto sin generar archivos.
↓
POR QUÉ SE NECESITA
Es un criterio de aceptación literal del ISS: el feature debe compilar.
↓
QUÉ CREA O MODIFICA
Nada (no emite salida a disco).
↓
RESULTADO ESPERADO
Sin errores.
↓
CÓMO VERIFICARLO
Código de salida 0 y ninguna línea de error.
curl -s -X POST $BASE/api/sesion/login …
COMANDO
↓
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"admin","password":"Admin123!"}'
↓
QUÉ HACE
Prueba la modalidad OPEN: envía credenciales y recibe el par de tokens.
↓
POR QUÉ SE NECESITA
Es el punto de entrada del recorrido E2E.
↓
QUÉ CREA O MODIFICA
Inserta una fila nueva en refresh_tokens (nueva familia).
↓
RESULTADO ESPERADO
200 con access_token, token_type, expires_in, refresh_token y refresh_expires_in.
↓
CÓMO VERIFICARLO
Guardar el access_token y usarlo en las comprobaciones JWT.
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil
COMANDO
↓
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil
↓
QUÉ HACE
Prueba la modalidad JWT contra el perfil propio.
↓
POR QUÉ SE NECESITA
Demuestra que `authenticate` valida el token y revalida al usuario en la BD.
↓
QUÉ CREA O MODIFICA
Nada (solo lectura).
↓
RESULTADO ESPERADO
200 con { user } sin password; 401 si falta el token.
↓
CÓMO VERIFICARLO
Repetir sin cabecera Authorization: debe responder 401.
curl -i -X POST -H "Authorization: Bearer $SELLER" … $BASE/api/clientes
COMANDO
↓
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
-d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes # 403
↓
QUÉ HACE
Prueba la modalidad JWT + RBAC con un token válido pero sin concesión.
↓
POR QUÉ SE NECESITA
Demuestra que autenticación no es autorización: seller está autenticado
pero no puede crear clientes.
↓
QUÉ CREA O MODIFICA
Nada (la petición se rechaza).
↓
RESULTADO ESPERADO
403.
↓
CÓMO VERIFICARLO
Ver el código HTTP con `-i`; comparar con el mismo POST usando el token de admin.
curl -s -X POST $BASE/api/sesion/refresh …
COMANDO
↓
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
-d '{"refresh_token":"<refresh_token del login>"}'
↓
QUÉ HACE
Renueva el access token rotando el refresh.
↓
POR QUÉ SE NECESITA
Prueba la renovación automática sin volver a pedir credenciales.
↓
QUÉ CREA O MODIFICA
Marca el refresh anterior como usado e inserta uno nuevo de la misma familia.
↓
RESULTADO ESPERADO
200 con un par nuevo; el refresh anterior queda inválido.
↓
CÓMO VERIFICARLO
Reenviar el token viejo: debe responder 401 y revocar la familia.
GET /api/permisos
COMANDO
↓
GET /api/permisos
↓
QUÉ HACE
Ejecuta la misma consulta RBAC que el middleware `authorize` y devuelve
los permisos efectivos del usuario autenticado.
↓
POR QUÉ SE NECESITA
Es la herramienta de depuración del RBAC y parte del E2E.
↓
QUÉ CREA O MODIFICA
Nada (solo lectura).
↓
RESULTADO ESPERADO
58 recursos para `admin` y 7 para `seller`.
↓
CÓMO VERIFICARLO
Llamarlo con cada token y contar los `(method, path)`.
Recorrido del ISS, paso a paso
A partir de aquí viene el contenido técnico completo del ISS, verbatim: su objetivo, sus criterios y sus bloques de código. 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 hacia ../manual/.
Fase II: Auth con RBAC — ISS-15 — Feature Session (login, refresh, logout, perfil y permisos)
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, ISS-13 middlewares e ISS-14 RefreshTokens. - 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§17 (ciclo de vida de la sesión) y §18 (tres formas de acceder a la BD). - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Feature Session (login, refresh, logout, perfil y permisos) Feature / tabla features/auth/session/· cruzausersyrefresh_tokensAPI /api/sesion/*y/api/permisos(OPEN y JWT)Depende de ISS-14 — Feature RefreshTokens Habilita Cierre de Fase II
Contenido de este ISS
- 20.1 DTOs del feature
- 20.2 Service (login, refresh, logout, perfil, permisos)
- 20.3 Controller
- 20.4 Rutas: las tres modalidades conviviendo en un archivo
- 20.5 Swagger
- 20.6 Pruebas HTTP
- 20.7 Verificación end-to-end de las tres modalidades
Objetivo: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos. Es el feature que demuestra que las tres modalidades conviven sin fricción.
Bloqueado por: ISS-14.
Criterios de aceptación (ISS-15) — consolidados
- [ ] 20.1
session/dto/(login.dto.ts,refresh-session.dto.ts,logout-session.dto.ts,session-response.dto.ts,index.ts) - [ ] 20.2
session.service.ts:login(verifica contraseña + emite sesión),refresh(rota),logout(revoca),profile,myPermissions - [ ] 20.3
session.controller.tsconthis.run(res, …) - [ ] 20.4
session.routes.ts:login/refresh/logoutOPEN;perfil/permisosJWT - [ ] 20.5
session.swagger.tsconsecurity: []en las OPEN ybearerAuthen las JWT - [ ] 20.6 archivos
.httpde login, refresh y perfil - [ ] 20.7 recorrido E2E que prueba OPEN → JWT → JWT + RBAC
- [ ]
npx tsc --noEmitOK
20.1 DTOs del feature
: > src/features/auth/session/dto/login.dto.ts
cat >> src/features/auth/session/dto/login.dto.ts << 'EOF'
/**
* Datos de entrada de `POST /api/sesion/login` (modalidad OPEN).
*
* `identifier` acepta **usuario o correo**: la consulta de credenciales busca por
* cualquiera de los dos, normalizando a minúsculas.
*/
export interface LoginDto {
identifier: string;
password: string;
}
EOF
: > src/features/auth/session/dto/refresh-session.dto.ts
cat >> src/features/auth/session/dto/refresh-session.dto.ts << 'EOF'
/**
* Datos de entrada de `POST /api/sesion/refresh` (modalidad OPEN con credencial
* de sesión).
*
* El refresh token viaja en el **cuerpo**, no en la cabecera `Authorization`:
* es una credencial de sesión, no un token de acceso.
*/
export interface RefreshSessionDto {
refresh_token: string;
}
EOF
: > src/features/auth/session/dto/logout-session.dto.ts
cat >> src/features/auth/session/dto/logout-session.dto.ts << 'EOF'
/**
* Datos de entrada de `POST /api/sesion/logout`.
*
* Se envía el refresh token que se quiere revocar (la sesión concreta). Es
* idempotente: repetirlo no devuelve error.
*/
export interface LogoutSessionDto {
refresh_token: string;
}
EOF
: > src/features/auth/session/dto/session-response.dto.ts
cat >> src/features/auth/session/dto/session-response.dto.ts << 'EOF'
/**
* Respuesta de `login` y `refresh`: el **par de tokens**.
*
* - `access_token`: JWT corto, autocontenido; viaja en `Authorization: Bearer`.
* - `refresh_token`: token opaco larga vida; **se devuelve solo aquí**, en claro,
* porque el servidor guarda únicamente su hash. El cliente debe guardarlo y
* enviarlo a `/api/sesion/refresh` para renovar sin volver a autenticarse.
* - `expires_in`: segundos de vida del token de acceso (para que el cliente
* programe la renovación *antes* de que expire).
*/
export interface SessionTokensDto {
access_token: string;
token_type: "Bearer";
expires_in: number;
refresh_token: string;
refresh_expires_in: number;
}
/** Datos públicos del perfil propio (modalidad JWT). Nunca incluye `password`. */
export interface ProfileDto {
id: number;
username: string;
email: string;
avatar: string | null;
status: "active" | "inactive";
}
EOF
: > src/features/auth/session/dto/index.ts
cat >> src/features/auth/session/dto/index.ts << 'EOF'
export * from "./login.dto";
export * from "./refresh-session.dto";
export * from "./logout-session.dto";
export * from "./session-response.dto";
EOF
20.2 Service
SessionService orquesta piezas ya construidas y no duplica su lógica:
| Método | Reutiliza | Hace |
|---|---|---|
login |
UsersRepository.findByUsernameOrEmail, verifyPassword, RefreshTokensService.issue |
autentica y abre sesión |
refresh |
RefreshTokensService.rotate |
renueva el access token rotando el refresh |
logout |
RefreshTokensService.revoke |
revoca la sesión |
profile |
requireAuthUser |
devuelve la identidad |
myPermissions |
UsersService.getEffectivePermissions |
lista los (method, path) vigentes |
Seguridad del login: si el usuario no existe o la contraseña no coincide, la respuesta es el mismo 401 genérico. No se distingue «usuario inexistente» de «contraseña incorrecta» (evita enumeración de usuarios). Un usuario inactive tampoco puede iniciar sesión.
: > src/features/auth/session/session.service.ts
cat >> src/features/auth/session/session.service.ts << 'EOF'
import {
LoginDto,
LogoutSessionDto,
ProfileDto,
RefreshSessionDto,
SessionTokensDto,
} from "./dto";
import { UsersRepository } from "../users/users.repository";
import { RefreshTokensService } from "../refresh-tokens/refresh-tokens.service";
import { ResourceRolesService } from "../resource-roles/resource-roles.service";
import { EffectivePermissionDto } from "../resource-roles/dto";
import { User } from "../users/user.model";
import { AppError } from "../../../shared/errors/app-error";
import { comparePassword } from "../../../shared/auth/password";
import { ACCESS_TOKEN_TTL_SECONDS, signAccessToken } from "../../../shared/auth/jwt";
/**
* Capa Service del feature Session — **el ciclo de vida de la sesión**.
*
* Cubre las dos modalidades sin autorización granular:
*
* | Operación | Modalidad | Escribe seguridad |
* |---|---|---|
* | `login` | OPEN (valida credenciales) | `refresh_tokens` (nueva familia) |
* | `refresh` | OPEN (credencial de sesión) | `refresh_tokens` (rotación) |
* | `logout` | OPEN (credencial de sesión) | `refresh_tokens` (revocación) |
* | `profile` | JWT | — (solo lectura) |
*
* **Renovación automática.** El cliente mantiene la sesión sin volver a pedir
* credenciales: cuando el access token está por expirar, llama a `refresh` con
* el refresh token y recibe un par nuevo. La ventana del refresh token se
* reinicia en cada rotación (ventana deslizante), así que un usuario que sigue
* trabajando no se ve expulsado; uno inactivo durante toda la ventana, sí.
*/
export class SessionService {
public constructor(
private readonly usersRepository: UsersRepository = new UsersRepository(),
private readonly refreshTokensService: RefreshTokensService = new RefreshTokensService(),
private readonly resourceRolesService: ResourceRolesService = new ResourceRolesService()
) {}
// ================== LOGIN (OPEN) ==================
/**
* Valida credenciales y abre una sesión.
*
* Nota de seguridad: la respuesta es **la misma** para "usuario inexistente" y
* "contraseña incorrecta" (`Invalid credentials`) para no revelar qué usuarios
* existen. La comparación de la contraseña ocurre en memoria; el hash nunca
* sale de la base de datos.
*/
public async login(body: LoginDto, deviceInfo: string | null): Promise<SessionTokensDto> {
if (!body.identifier || !body.password) {
throw new AppError(400, "identifier and password are required");
}
const user = await this.usersRepository.findByIdentifierWithPassword(body.identifier);
if (!user || user.status !== "active") {
throw new AppError(401, "Invalid credentials");
}
const matches = await comparePassword(body.password, user.password);
if (!matches) {
throw new AppError(401, "Invalid credentials");
}
const session = await this.refreshTokensService.issue(user.id, deviceInfo);
return this.buildTokens(user, session.rawToken, session.expiresAt);
}
// ================== REFRESH (OPEN con credencial de sesión) ==================
/**
* Rota el refresh token y emite un par nuevo.
*
* Traduce el resultado de la rotación (que no lanza dentro de la transacción,
* para no deshacer la revocación por reutilización) al error HTTP que
* corresponde:
* - `invalid` -> 401
* - `expired` -> 401
* - `reuse` -> 401 **habiendo revocado toda la familia**
*/
public async refresh(
body: RefreshSessionDto,
deviceInfo: string | null
): Promise<SessionTokensDto> {
if (!body.refresh_token) {
throw new AppError(400, "refresh_token is required");
}
const outcome = await this.refreshTokensService.rotate(body.refresh_token, deviceInfo);
if (outcome.kind === "invalid") {
throw new AppError(401, "Invalid refresh token");
}
if (outcome.kind === "expired") {
throw new AppError(401, "Refresh token expired");
}
if (outcome.kind === "reuse") {
throw new AppError(401, "Refresh token reuse detected: session family revoked");
}
// Revalida la identidad: si el usuario fue desactivado, se corta la sesión
// aunque el refresh token siga siendo válido.
const user = await this.usersRepository.findById(outcome.userId);
if (!user || user.status !== "active") {
await this.refreshTokensService.revokeAllMine(outcome.userId);
throw new AppError(401, "User is not active");
}
return this.buildTokens(user, outcome.rawToken, outcome.expiresAt);
}
// ================== LOGOUT (OPEN con credencial de sesión) ==================
/** Revoca la sesión del refresh token presentado. Idempotente. */
public async logout(body: LogoutSessionDto): Promise<void> {
if (!body.refresh_token) {
throw new AppError(400, "refresh_token is required");
}
await this.refreshTokensService.revokeByToken(body.refresh_token);
}
// ================== PERFIL (JWT) ==================
/** Datos públicos del usuario autenticado. */
public async profile(userId: number): Promise<ProfileDto> {
const user = await this.usersRepository.findById(userId);
if (!user || user.status !== "active") {
throw new AppError(404, "User not found");
}
return toProfile(user);
}
/** Permisos efectivos del propio usuario (modalidad JWT, sin RBAC). */
public async myPermissions(userId: number): Promise<EffectivePermissionDto[]> {
return this.resourceRolesService.findEffectiveForUser(userId);
}
// ================== HELPERS ==================
/** Arma el par de tokens: firma el access y adjunta el refresh recién emitido. */
private buildTokens(user: User, refreshToken: string, refreshExpiresAt: Date): SessionTokensDto {
const access = signAccessToken({ id: user.id, username: user.username });
return {
access_token: access.token,
token_type: "Bearer",
expires_in: access.expiresIn,
refresh_token: refreshToken,
refresh_expires_in: Math.max(
0,
Math.floor((refreshExpiresAt.getTime() - Date.now()) / 1000)
),
};
}
}
/** Proyección a `ProfileDto`: solo campos públicos. */
function toProfile(user: User): ProfileDto {
return {
id: user.id,
username: user.username,
email: user.email,
avatar: user.avatar ?? null,
status: user.status,
};
}
/** Reexporta la constante para que el controller pueda documentar `expires_in`. */
export { ACCESS_TOKEN_TTL_SECONDS };
EOF
Renovación automática mientras el usuario trabaja: el cliente llama a POST /api/sesion/refresh con el refresh token cuando el access token expira. Como el refresh rota en cada uso y la familia se revoca ante reuso, la sesión puede prolongarse indefinidamente sin degradar la seguridad.
20.3 Controller
: > src/features/auth/session/session.controller.ts
cat >> src/features/auth/session/session.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { requireAuthUser } from "../../../shared/auth/auth-user";
import { LoginDto, LogoutSessionDto, RefreshSessionDto } from "./dto";
import { SessionService } from "./session.service";
/**
* Capa Controller del feature Session.
*
* Mezcla las dos modalidades base:
* - `login`, `refresh` y `logout` son **OPEN** (no hay identidad previa; la
* credencial va en el cuerpo);
* - `profile` y `myPermissions` son **JWT** (la identidad la resolvió
* `authenticate` antes de llegar aquí).
*/
export class SessionController extends BaseController {
public constructor(
private readonly service: SessionService = new SessionService()
) {
super();
}
// ================== OPEN ==================
/** Inicia sesión: credenciales -> par de tokens. */
public async login(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const tokens = await this.service.login(req.body as LoginDto, deviceInfo(req));
res.status(200).json(tokens);
});
}
/** Renueva el access token rotando el refresh token. */
public async refresh(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const tokens = await this.service.refresh(req.body as RefreshSessionDto, deviceInfo(req));
res.status(200).json(tokens);
});
}
/** Cierra la sesión del refresh token presentado. */
public async logout(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
await this.service.logout(req.body as LogoutSessionDto);
res.status(200).json({ message: "Session closed" });
});
}
// ================== JWT ==================
/** Perfil del usuario autenticado. */
public async profile(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const user = await this.service.profile(requireAuthUser(req).id);
res.status(200).json({ user });
});
}
/** Permisos efectivos del usuario autenticado. */
public async myPermissions(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const permissions = await this.service.myPermissions(requireAuthUser(req).id);
res.status(200).json({ permissions });
});
}
}
/** `device_info` de auditoría a partir del encabezado `User-Agent`. */
function deviceInfo(req: Request): string | null {
const value = req.headers["user-agent"];
if (!value) return null;
return String(value).slice(0, 500);
}
EOF
20.4 Rutas — las tres modalidades en un solo archivo
| Ruta | Modalidad | Middleware |
|---|---|---|
POST /api/sesion/login |
OPEN | — |
POST /api/sesion/refresh |
OPEN (credencial de sesión) | — |
POST /api/sesion/logout |
OPEN (credencial de sesión) | — |
GET /api/sesion/perfil |
JWT | authenticate |
GET /api/permisos |
JWT | authenticate |
Ninguna lleva authorize: la autorización granular no aplica a los puntos de acceso previos a la matriz. GET /api/permisos devuelve, eso sí, los permisos efectivos del usuario autenticado —la misma consulta que usa el middleware authorize—, lo que lo hace ideal para depurar el RBAC.
: > src/features/auth/session/session.routes.ts
cat >> src/features/auth/session/session.routes.ts << 'EOF'
import { Application } from "express";
import { SessionController } from "./session.controller";
import { authenticate } from "../access";
/**
* Rutas del feature Session — **las tres modalidades en un solo archivo**.
*
* | Ruta | Modalidad | Middleware |
* |---|---|---|
* | `POST /api/sesion/login` | OPEN | — |
* | `POST /api/sesion/refresh` | OPEN (credencial de sesión) | — |
* | `POST /api/sesion/logout` | OPEN (credencial de sesión) | — |
* | `GET /api/sesion/perfil` | JWT | `authenticate` |
* | `GET /api/permisos` | JWT | `authenticate` |
*
* Ninguna lleva `authorize`: la autorización granular no aplica a los puntos de
* acceso previos o ajenos a la matriz de permisos. `/api/permisos` devuelve, eso
* sí, **los permisos efectivos** del usuario autenticado (la misma consulta que
* usa el middleware `authorize`), lo que lo hace ideal para depurar el RBAC.
*/
export class SessionRoutes {
public sessionController: SessionController = new SessionController();
public routes(app: Application): void {
// login (OPEN)
app
.route("/api/sesion/login")
.post(this.sessionController.login.bind(this.sessionController));
// refresh (OPEN + refresh token)
app
.route("/api/sesion/refresh")
.post(this.sessionController.refresh.bind(this.sessionController));
// logout (OPEN + refresh token)
app
.route("/api/sesion/logout")
.post(this.sessionController.logout.bind(this.sessionController));
// perfil (JWT)
app
.route("/api/sesion/perfil")
.get(authenticate, this.sessionController.profile.bind(this.sessionController));
// permisos efectivos del usuario autenticado (JWT)
app
.route("/api/permisos")
.get(authenticate, this.sessionController.myPermissions.bind(this.sessionController));
}
}
EOF
20.5 Swagger
refresh y logout son OPEN de facto: no exigen access token, sino el refresh token en el cuerpo. Por eso declaran security: [] y documentan su propio cuerpo.
: > src/features/auth/session/session.swagger.ts
cat >> src/features/auth/session/session.swagger.ts << 'EOF'
import {
bearerSecurity,
openSecurity,
unauthorizedResponse,
} from "../../../shared/http/swagger-security";
/**
* Documentación OpenAPI del feature Session — **las tres modalidades juntas**.
*
* - `login` / `refresh` / `logout`: **OPEN**. Nota didáctica: OPEN no significa
* "sin base de datos", significa "sin identidad previa". El login **lee** el
* hash de `users` y **escribe** `refresh_tokens`; el refresh **rota** el token.
* - `perfil` / `permisos`: **JWT**.
*
* La respuesta de `login` y `refresh` es el **par de tokens**. El `refresh_token`
* se devuelve en claro **solo aquí**: el servidor guarda únicamente su SHA-256.
*/
export const sessionSwagger = {
tags: [
{ name: "Sesión", description: "Login, renovación, cierre y perfil — **OPEN** + **JWT**" },
],
paths: {
"/api/sesion/login": {
post: {
tags: ["Sesión"],
summary: "Iniciar sesión (OPEN)",
description:
"Modalidad **OPEN**. Valida usuario/correo + contraseña y abre una sesión: " +
"emite un access token corto (JWT) y un refresh token persistido como hash, con un `family_id` nuevo. " +
"La respuesta es idéntica para usuario inexistente y contraseña incorrecta (no se enumeran usuarios).",
security: openSecurity,
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/Login" } },
},
},
responses: {
"200": { description: "Par de tokens (`access_token`, `refresh_token`, `expires_in`)" },
"400": { description: "Faltan `identifier` o `password`" },
"401": { description: "Credenciales inválidas o usuario inactivo" },
},
},
},
"/api/sesion/refresh": {
post: {
tags: ["Sesión"],
summary: "Renovar el access token (OPEN con credencial de sesión)",
description:
"Modalidad **OPEN**. **Rota** el refresh token: invalida el presentado y emite uno nuevo con el mismo " +
"`family_id`. Si se presenta un token ya rotado, se interpreta como **reutilización** y se revoca toda la " +
"familia (401). La rotación es atómica y con bloqueo de fila, así que dos peticiones simultáneas no emiten " +
"dos tokens válidos. La ventana del refresh se reinicia en cada rotación: renovación automática mientras el usuario trabaja.",
security: openSecurity,
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RefreshToken" } },
},
},
responses: {
"200": { description: "Par de tokens nuevo (`access_token` + `refresh_token` rotado)" },
"400": { description: "Falta `refresh_token`" },
"401": {
description:
"Token inválido, expirado o **reutilizado** (en este último caso, la familia queda revocada)",
},
},
},
},
"/api/sesion/logout": {
post: {
tags: ["Sesión"],
summary: "Cerrar sesión (OPEN con credencial de sesión)",
description:
"Modalidad **OPEN**. Revoca el refresh token presentado. Idempotente: repetirlo no devuelve error. " +
"El access token sigue siendo válido hasta expirar (vida corta); para invalidación inmediata, desactivar el usuario.",
security: openSecurity,
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RefreshToken" } },
},
},
responses: {
"200": { description: "Sesión cerrada (`{ message }`)" },
"400": { description: "Falta `refresh_token`" },
},
},
},
"/api/sesion/perfil": {
get: {
tags: ["Sesión"],
summary: "Perfil del usuario autenticado (JWT)",
description:
"Modalidad **JWT**. El middleware `authenticate` valida el token y **revalida en la base** que el usuario " +
"sigue activo: desactivar una cuenta invalida sus tokens al instante (401).",
security: bearerSecurity,
responses: {
"200": { description: "Perfil (`{ user }`) — nunca incluye `password`" },
"401": unauthorizedResponse,
},
},
},
"/api/permisos": {
get: {
tags: ["Sesión"],
summary: "Mis permisos efectivos (JWT)",
description:
"Modalidad **JWT**. Ejecuta la misma consulta que el middleware `authorize` " +
"(`resource_roles → roles → role_users → resources`, todos los eslabones activos) y devuelve el par " +
"`(method, path)` de cada permiso. Es la herramienta para **depurar el RBAC**: lo que aparece aquí es exactamente lo que autoriza.",
security: bearerSecurity,
responses: {
"200": { description: "Permisos efectivos (`{ permissions: [...] }`)" },
"401": unauthorizedResponse,
},
},
},
},
components: {
schemas: {
Login: {
type: "object",
required: ["identifier", "password"],
properties: {
identifier: { type: "string", example: "admin", description: "`username` o `email`" },
password: { type: "string", format: "password", example: "Admin123!" },
},
},
RefreshToken: {
type: "object",
required: ["refresh_token"],
properties: {
refresh_token: { type: "string", example: "9f2c... (opaco, no es un JWT)" },
},
},
SessionTokens: {
type: "object",
properties: {
access_token: { type: "string", description: "JWT firmado (HS256), vida corta" },
token_type: { type: "string", example: "Bearer" },
expires_in: { type: "integer", example: 900, description: "Segundos de vida del access token" },
refresh_token: { type: "string", description: "Token opaco; se devuelve solo en login/refresh" },
refresh_expires_in: { type: "integer", example: 604800 },
},
},
},
},
};
EOF
20.6 Pruebas HTTP
: > src/features/auth/session/http/session.login.http
cat >> src/features/auth/session/http/session.login.http << 'EOF'
### Feature Session — LOGIN (modalidad OPEN)
### OPEN = sin identidad previa. Aquí SÍ se consulta `users` (hash) y se escribe `refresh_tokens`.
@baseUrl = http://localhost:4000
# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin",
"password": "Admin123!"
}
### Credenciales de ejemplo
# admin / Admin123! -> rol ADMIN (58 permisos)
# seller / Seller123! -> rol SELLER (7 permisos)
### Login por correo (mismo endpoint, `identifier` acepta username o email)
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin@storelab.local",
"password": "Admin123!"
}
### Respuesta 401 - credenciales inválidas (mismo mensaje que "usuario inexistente")
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin",
"password": "password-incorrecta"
}
### Guarda los tokens para los demás archivos .http
@adminAccessToken = {{loginAdmin.response.body.$.access_token}}
@adminRefreshToken = {{loginAdmin.response.body.$.refresh_token}}
@expiresIn = {{loginAdmin.response.body.$.expires_in}}
EOF
: > src/features/auth/session/http/session.refresh.http
cat >> src/features/auth/session/http/session.refresh.http << 'EOF'
### Feature Session — REFRESH (modalidad OPEN con credencial de sesión)
### Renovación automática: el cliente llama aquí cuando el access token está por expirar.
### ROTACIÓN: el refresh token enviado queda inválido y se emite uno nuevo (misma familia).
### REUSE DETECTION: si se reenvía un token ya rotado -> 401 y se revoca toda la familia.
@baseUrl = http://localhost:4000
### 1) Login para obtener el par inicial
# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin",
"password": "Admin123!"
}
### 2) Refresh: devuelve access_token nuevo + refresh_token ROTADO
# @name refresh
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json
{
"refresh_token": "{{loginAdmin.response.body.$.refresh_token}}"
}
### 3) El access token nuevo sirve en una ruta JWT
GET {{baseUrl}}/api/sesion/perfil
Authorization: Bearer {{refresh.response.body.$.access_token}}
### 4) REUSE: reenviar el token viejo -> 401 y la familia queda revocada
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json
{
"refresh_token": "{{loginAdmin.response.body.$.refresh_token}}"
}
### 5) Consecuencia: el token rotado (paso 2) tampoco sirve ya -> 401
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json
{
"refresh_token": "{{refresh.response.body.$.refresh_token}}"
}
EOF
: > src/features/auth/session/http/session.profile.http
cat >> src/features/auth/session/http/session.profile.http << 'EOF'
### Feature Session — LOGOUT y PERFIL (OPEN con credencial de sesión + JWT)
@baseUrl = http://localhost:4000
# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "seller",
"password": "Seller123!"
}
### PERFIL — modalidad JWT: `authenticate` valida el token y REVALIDA en la BD
### que el usuario sigue activo (desactivarlo invalida sus tokens al instante).
GET {{baseUrl}}/api/sesion/perfil
Authorization: Bearer {{loginSeller.response.body.$.access_token}}
### MIS PERMISOS — modalidad JWT: misma consulta RBAC que usa `authorize`
### (seller -> 7 permisos; admin -> 58). Es la herramienta para depurar el RBAC.
GET {{baseUrl}}/api/permisos
Authorization: Bearer {{loginSeller.response.body.$.access_token}}
### Sin token -> 401 (no autenticado)
GET {{baseUrl}}/api/sesion/perfil
### LOGOUT — modalidad OPEN con credencial de sesión. Idempotente.
POST {{baseUrl}}/api/sesion/logout
Content-Type: application/json
{
"refresh_token": "{{loginSeller.response.body.$.refresh_token}}"
}
### Ya no se puede renovar -> 401
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json
{
"refresh_token": "{{loginSeller.response.body.$.refresh_token}}"
}
EOF
20.7 Verificación end-to-end de las tres modalidades
BASE=http://localhost:4000
# 1) OPEN — login (no requiere identidad)
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"admin","password":"Admin123!"}'
# 2) JWT — perfil con el access token (sin permisos RBAC de por medio)
ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"admin","password":"Admin123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil
# 3) JWT + RBAC — el mismo token, ahora sí, autoriza negocio
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/clientes
# 4) JWT + RBAC (denegado) — seller no tiene POST /api/clientes
SELLER=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"seller","password":"Seller123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
-d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes # 403
# 5) Renovación — rotar el refresh token
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
-d '{"refresh_token":"<refresh_token del login>"}'
| Comprobación | Esperado |
|---|---|
POST /api/sesion/login con credenciales válidas |
200 + access_token + refresh_token |
POST /api/sesion/login con contraseña errónea |
401 genérico |
GET /api/sesion/perfil sin token |
401 |
GET /api/permisos con token de seller |
lista de 7 recursos |
POST /api/clientes con token de seller |
403 |
POST /api/sesion/refresh con el refresh válido |
200 + nuevos tokens; el anterior queda used |
| Reusar el refresh anterior | 401 + familia revocada |
DoD del ISS-15
- [ ] Todos los criterios de aceptación (20.1 … 20.7) cumplidos
- [ ] Las tres modalidades son observables con
curl(401 sin token, 403 sin concesión, 200/201 con ella) - [ ]
GET /api/permisosrefleja la matriz real (58 paraadmin, 7 paraseller) - [ ] La rotación de refresh invalida el token anterior
- [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Diagnóstico
| Síntoma | Causa probable | Solución |
|---|---|---|
401 al hacer login con credenciales correctas |
Usuario inactive o contraseña no coincide |
Revisar status y re-sembrar; el mensaje es deliberadamente genérico |
Siempre 401 Invalid credentials |
identifier mal escrito (el login acepta usuario o correo) |
Probar con admin y con admin@storelab.local |
401 en perfil con token válido |
El usuario fue desactivado (se revalida en la BD) | Reactivarlo o volver a hacer login con un usuario activo |
refresh responde 401 la segunda vez |
Rotación: el token presentado ya se usó | Es el comportamiento correcto; usar el token rotado del paso anterior |
reuse devuelve 401 y el token nuevo tampoco sirve |
Se detectó reutilización y se revocó toda la familia | Volver a hacer login: la familia se corta por seguridad |
400 refresh_token is required |
El cuerpo no lleva el refresh token | Enviarlo en el cuerpo, no en Authorization |
403 en /api/clientes con token de seller |
El rol no tiene la concesión POST /api/clientes |
Usar el token de admin o revisar GET /api/permisos |
GET /api/permisos devuelve menos de lo esperado |
Eslabón inactivo en la matriz RBAC | Revisar role_users/resource_roles (ISS-12) |
npx tsc --noEmit falla |
Import o tipo del feature incompleto | Revisar los imports de DTOs y de shared/auth |
El servidor no reconoce /api/sesion/* |
SessionRoutes no está registrado en la App |
Es el enganche del Cierre de Fase II |
Pregunta que responde: si algo falla en login, refresh o permisos, ¿por dónde empiezo a mirar?
Conexión con el resto del curso
- Lo habilita: ISS-14 — Feature RefreshTokens. Sin la rotación y la detección de reúso, el
refreshno podría delegar. - Reutiliza: la base de seguridad de ISS-09 (
AppError, JWT, bcrypt), el featureusersde ISS-10, la matriz RBAC de ISS-11 y ISS-12, y los middlewares de ISS-13. - Reutiliza un patrón: el módulo Swagger por feature que viste en ISS-05;
sessionSwaggerse agrega al registry como cualquier otro. - Lo usa: el Cierre de Fase II, que registra
SessionRoutesen el enrutador de la App. - Idea de fondo: este feature es la prueba de que las tres modalidades se suman: eliminar una empobrecería el sistema.
Pregunta que responde: ¿con qué piezas anteriores y posteriores del curso se conecta este ISS?
Criterios de aceptación
Los del ISS, textuales:
- [ ] 20.1
session/dto/(login.dto.ts,refresh-session.dto.ts,logout-session.dto.ts,session-response.dto.ts,index.ts) - [ ] 20.2
session.service.ts:login(verifica contraseña + emite sesión),refresh(rota),logout(revoca),profile,myPermissions - [ ] 20.3
session.controller.tsconthis.run(res, …) - [ ] 20.4
session.routes.ts:login/refresh/logoutOPEN;perfil/permisosJWT - [ ] 20.5
session.swagger.tsconsecurity: []en las OPEN ybearerAuthen las JWT - [ ] 20.6 archivos
.httpde login, refresh y perfil - [ ] 20.7 recorrido E2E que prueba OPEN → JWT → JWT + RBAC
- [ ]
npx tsc --noEmitOK
DoD del ISS, textual:
- [ ] Todos los criterios de aceptación (20.1 … 20.7) cumplidos
- [ ] Las tres modalidades son observables con
curl(401 sin token, 403 sin concesión, 200/201 con ella) - [ ]
GET /api/permisosrefleja la matriz real (58 paraadmin, 7 paraseller) - [ ] La rotación de refresh invalida el token anterior
- [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Evaluación
Preguntas de comprensión
-
¿Por qué la respuesta es la misma para «usuario inexistente» y «contraseña incorrecta»? Para evitar la enumeración de usuarios. Si los mensajes difirieran, un atacante podría descubrir qué cuentas existen probando identificadores. El service lanza el mismo
401 "Invalid credentials"en ambos casos. -
¿Qué significa que
refresh«rota» el token y por qué mejora la seguridad? Significa que el refresh presentado se marca como usado y se emite uno nuevo de la misma familia. Así, el token robado solo sirve hasta que el legítimo se use; en cuanto uno de los dos aparezca «ya usado», el sistema detecta la reutilización y revoca la familia completa. -
¿Por qué
logoutes idempotente y por qué eso es deseable? Porque revoca el refresh presentado sin fallar si ya estaba revocado. Es deseable porque el cliente puede reintentar el cierre de sesión (por ejemplo, tras un timeout) sin recibir un error que le impida completar la operación. -
/api/sesion/perfilusa soloauthenticate, noauthorize. ¿Por qué? Porque el perfil es información del propio usuario: basta con saber quién es (autenticación). La autorización granular responde «¿puede este usuario tocar este recurso?», pregunta que no aplica al perfil propio. -
¿Qué devuelve
GET /api/permisosy por qué decimos que es ideal para depurar el RBAC? Devuelve los permisos efectivos —los pares(method, path)— ejecutando la misma consulta que usa el middlewareauthorize. Si un endpoint que esperas autorizado no aparece aquí, el fallo está en la matriz (role_users/resource_roles), no en el middleware. -
¿Por qué
refreshrevalida al usuario aunque el refresh token siga siendo válido? Porque un token válido no garantiza que la cuenta siga habilitada. Si el usuario fue desactivado, el service llama arevokeAllMiney corta la sesión con401 "User is not active", de modo que desactivar una cuenta invalida su acceso sin esperar a que expire el token. -
El ISS dice que OPEN no significa «sin base de datos». ¿Por qué? Porque OPEN solo describe la identidad: no hay access token previo. Pero
loginlee el hash deusers,refreshlee y escriberefresh_tokensylogoutrevoca. Es decir, las rutas OPEN acceden a la BD con normalidad; lo que no hacen es exigir una identidad ya autenticada. -
¿Qué diferencia hay entre el
403deselleren/api/clientesy el401que recibiría sin token?401es «no sé quién eres» (falta o falla la autenticación);403es «sé quién eres, pero no puedes». En el E2E,sellerestá autenticado, pero su rol no tiene la concesión paraPOST /api/clientes, así que recibe403.
Ejercicios
Ejercicio 1 — Traza el login.
Enumera, en orden, las llamadas que ejecuta SessionService.login desde que recibe el cuerpo hasta que devuelve el par de tokens, e indica en qué punto exacto se decide un 401.
Respuesta razonada
1. Valida que `identifier` y `password` existan (si no, `400`). 2. `usersRepository.findByIdentifierWithPassword(identifier)`. 3. Si no hay usuario o `status !== "active"` → `401 "Invalid credentials"` (**primer punto de decisión**). 4. `comparePassword(body.password, user.password)`. 5. Si no coincide → `401 "Invalid credentials"` (**segundo punto, mismo mensaje**). 6. `refreshTokensService.issue(user.id, deviceInfo)` (crea la familia en `refresh_tokens`). 7. `buildTokens` firma el access con `signAccessToken` y calcula `refresh_expires_in`. 8. Devuelve `SessionTokensDto`.Ejercicio 2 — Detecta el reuso.
Describe el recorrido de tokens del archivo session.refresh.http y explica por qué el paso 5 también devuelve 401.
Respuesta razonada
1. Login → tokens A (access) y R1 (refresh). 2. Refresh con R1 → tokens nuevos; R1 queda `used`, se emite R2. 3. El access nuevo sirve en `/api/sesion/perfil`. 4. Se reenvía **R1** (ya usado) → el sistema lo interpreta como **reutilización**: `401` y **revoca toda la familia**. 5. Se prueba **R2**, que era válido: como la familia fue revocada en el paso 4, también devuelve `401`. Ese encadenamiento es precisamente lo que demuestra que la detección de reúso es «de familia», no del token individual.Ejercicio 3 — Distingue modalidades.
Para cada petición, indica la modalidad y el resultado esperado: (a) GET /api/sesion/perfil sin token; (b) POST /api/sesion/logout con un refresh válido; (c) GET /api/permisos con token de seller; (d) POST /api/clientes con token de seller.
Respuesta razonada
- (a) **JWT** sin credencial → `401` (falta autenticación). - (b) **OPEN** con credencial de sesión → `200 { message: "Session closed" }`; idempotente. - (c) **JWT** → `200` con la lista de **7** recursos de `seller`. - (d) **JWT + RBAC** → `403` (autenticado, sin concesión). Observa que (a) y (d) son ambos rechazos, pero con significados distintos: `401` es «no sé quién eres»; `403` es «sé quién eres, y no puedes».Glosario
| Término | Significado |
|---|---|
| OPEN | Modalidad sin identidad previa (sin access token); puede tener su propia credencial en el cuerpo. |
| JWT | Modalidad que exige un access token validado por authenticate. |
| JWT + RBAC | Modalidad que exige token y una concesión en la matriz (authorize). |
| Access token | JWT de vida corta que viaja en Authorization: Bearer. |
| Refresh token | Token opaco de vida larga, guardado como hash; solo se devuelve en claro en login/refresh. |
| Rotación | Al renovar, el refresh presentado se invalida y se emite uno nuevo de la misma familia. |
| Detección de reúso | Si se presenta un refresh ya rotado, se revoca toda la familia. |
Familia (family_id) |
Conjunto de refresh tokens de una misma sesión. |
| Ventana deslizante | La vigencia del refresh se reinicia con cada rotación. |
| Permisos efectivos | Pares (method, path) que resultan de la matriz RBAC para un usuario. |
deviceInfo |
Fragmento del User-Agent (máx. 500) guardado para auditoría. |
GATE
Para cerrar el ISS-15, comprueba primero los tipos:
Resultado esperado: sin errores.
Después siembra y arranca:
Con el servidor en marcha, ejecuta el recorrido E2E de las tres modalidades:
BASE=http://localhost:4000
# 1) OPEN — login (no requiere identidad)
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"admin","password":"Admin123!"}'
# 2) JWT — perfil con el access token (sin permisos RBAC de por medio)
ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"admin","password":"Admin123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil
# 3) JWT + RBAC — el mismo token, ahora sí, autoriza negocio
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/clientes
# 4) JWT + RBAC (denegado) — seller no tiene POST /api/clientes
SELLER=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
-d '{"identifier":"seller","password":"Seller123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
-d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes # 403
# 5) Renovación — rotar el refresh token
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
-d '{"refresh_token":"<refresh_token del login>"}'
Resultado esperado (tabla del ISS):
| Comprobación | Esperado |
|---|---|
POST /api/sesion/login con credenciales válidas |
200 + access_token + refresh_token |
POST /api/sesion/login con contraseña errónea |
401 genérico |
GET /api/sesion/perfil sin token |
401 |
GET /api/permisos con token de seller |
lista de 7 recursos |
POST /api/clientes con token de seller |
403 |
POST /api/sesion/refresh con el refresh válido |
200 + nuevos tokens; el anterior queda used |
| Reusar el refresh anterior | 401 + familia revocada |
Detén el servidor con Ctrl+C antes de continuar.
Checklist de cierre:
- [ ] Criterios 20.1 … 20.7 cumplidos
- [ ] Las tres modalidades observables con
curl(401 sin token, 403 sin concesión, 200/201 con ella) - [ ]
GET /api/permisosrefleja la matriz real (58 paraadmin, 7 paraseller) - [ ] La rotación de refresh invalida el token anterior
- [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Con todos en verde, el ISS-15 está cumplido y puedes pasar al Cierre de Fase II, donde se registran las rutas del feature (incluido SessionRoutes) en la App y se cierra la fase de autenticación.
Anexo — Pruebas E2E de los endpoints de Business bajo las 3 modalidades de acceso
Cuándo leer este anexo. El GATE del ISS-15 ya está cerrado. Este anexo es práctica de verificación: coge los endpoints de negocio (
/api/clientes,/api/productos,/api/ventas…) y los somete a las tres modalidades de acceso —OPEN, JWT y JWT + RBAC—, primero con casos que deben funcionar y después provocando errores de todo tipo.Complementa a
../../auth-3-modalidades-errores.md, que es la guía de referencia de los tres accesos y sus errores: aquí esa guía se aplica al negocio y se ejecuta de verdad.Pregunta que responde: ¿cómo se comporta un endpoint de negocio cuando lo llamo sin credenciales, con identidad pero sin permiso, y con identidad y permiso? ¿y qué error exacto veo en cada fallo?
A.1 La idea que hay que entender antes de empezar
En este backend la modalidad no es global: se decide ruta a ruta, según qué middlewares se monten. Y hay un hecho que suele pillar desprevenido:
Ningún endpoint de negocio es OPEN. Todos los features de Business montan
authenticateyauthorize. Es decir: los endpoints de negocio son, sin excepción, modalidad JWT + RBAC.
Eso significa que la modalidad OPEN y la modalidad JWT no se usan en negocio, pero sí se observan en negocio: son los dos primeros peldaños de la escalera que una petición de negocio tiene que subir. Ese es justo el interés pedagógico del anexo: ver los tres estados aplicados al mismo recurso.
flowchart LR
subgraph OPEN["Modalidad 1 — OPEN (solo sesion y docs)"]
O1["POST /api/sesion/login"]
O2["POST /api/sesion/refresh"]
O3["GET /api/docs"]
end
subgraph JWT["Modalidad 2 — JWT (identidad, sin RBAC)"]
J1["GET /api/sesion/perfil"]
J2["GET /api/permisos"]
J3["GET /api/sesiones"]
end
subgraph RBAC["Modalidad 3 — JWT + RBAC (todo el negocio)"]
R1["GET /api/clientes"]
R2["POST /api/ventas"]
R3["GET /api/productos"]
R4["GET /api/usuarios"]
end
OPEN -->|"emite el access_token"| JWT
JWT -->|"el MISMO token, un paso mas"| RBAC
RBAC -.->|"una concesion mas y entra"| R2
Pregunta que responde: ¿en qué modalidad está cada endpoint y cómo se encadena una modalidad con la siguiente usando el mismo token?
La consecuencia práctica, y el motivo de que el anexo pruebe las tres:
| Lo que envías | Qué espera el servidor que demuestres | Resultado en un endpoint de negocio |
|---|---|---|
| Nada | — | 401 Missing Bearer token |
| Un token válido | quién eres | 403 si no tienes concesión · 200/201 si la tienes |
| Un token inválido | — | 401 |
A.2 Preparación: un laboratorio determinista
Antes de medir nada, hay que fijar el estado. Estos dos comandos dejan la matriz RBAC reproducible (son los mismos del GATE del ISS-15):
npm install
npm run db:seed # deja la matriz determinista: ADMIN 58, SELLER 7
npm run dev # http://localhost:4000
| Usuario | Contraseña | Rol | Permisos |
|---|---|---|---|
admin |
Admin123! |
ADMIN |
58 (todos) |
seller |
Seller123! |
SELLER |
7 (lecturas + registrar ventas) |
Y las dos credenciales con las que se trabaja en todo el anexo:
BASE=http://localhost:4000
ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H 'Content-Type: application/json' \
-d '{"identifier":"admin","password":"Admin123!"}' | jq -r .access_token)
SELLER=$(curl -s -X POST $BASE/api/sesion/login -H 'Content-Type: application/json' \
-d '{"identifier":"seller","password":"Seller123!"}' | jq -r .access_token)
Pregunta que responde: ¿por qué hay que resembrar antes de probar y qué estado deja el
db:seed?
Lo que el servidor cree que puedes hacer
Antes de provocar un 403 conviene saber qué permisos tiene realmente cada usuario. GET /api/permisos responde con la misma consulta que usa authorize, así que es la herramienta de depuración de referencia:
curl -s $BASE/api/permisos -H "Authorization: Bearer $ADMIN" | jq '.permissions | length' # 58
curl -s $BASE/api/permisos -H "Authorization: Bearer $SELLER" | jq '.permissions | length' # 7
Salida real de este laboratorio (SELLER completo, y el negocio de ADMIN):
SELLER (7):
GET /api/clientes
GET /api/clientes/:id
GET /api/productos
GET /api/productos/:id
GET /api/ventas
GET /api/ventas/:id
POST /api/ventas
ADMIN (58): los 7 anteriores y, además, todo el CRUD de
clientes · tipos-producto · productos · usuarios · roles ·
recursos · asignaciones-rol · concesiones-rol
Fíjate en el detalle que decide muchas pruebas: SELLER sí tiene GET /api/clientes/:id. Por eso GET /api/clientes/abc con token de seller devuelve 400 (pasa la autorización y falla la validación del :id), y no un 403. Volveremos sobre esto en §A.5.
A.3 Modalidad 1 — OPEN, aplicada a negocio
Sin cabecera Authorization. Un endpoint de negocio no perdona la falta de identidad.
| Caso | Petición | Código real | Cuerpo real |
|---|---|---|---|
| Listar clientes sin token | GET /api/clientes |
401 | {"error":"Missing Bearer token"} |
| Crear cliente sin token | POST /api/clientes |
401 | {"error":"Missing Bearer token"} |
| Listar productos sin token | GET /api/productos |
401 | {"error":"Missing Bearer token"} |
| Listar ventas sin token | GET /api/ventas |
401 | {"error":"Missing Bearer token"} |
| Listar usuarios sin token | GET /api/usuarios |
401 | {"error":"Missing Bearer token"} |
| Ver mis permisos sin token | GET /api/permisos |
401 | {"error":"Missing Bearer token"} |
Contraste: lo que sí es OPEN en este backend. Para que el 401 no se confunda con «el servidor está roto», comprueba que las rutas OPEN sí responden sin credenciales:
| Caso | Petición | Código real | Nota |
|---|---|---|---|
| Documentación | GET /api/docs |
301 → 200 | redirige a /api/docs/ (Location: /api/docs/); con curl -L son 200 |
| Iniciar sesión | POST /api/sesion/login |
200 | devuelve access_token + refresh_token |
# 401 en negocio (no hay identidad)
curl -i $BASE/api/clientes
# 301 + 200 en documentación (OPEN de verdad): usa -L para seguir el redirect
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/docs # 301
curl -s -o /dev/null -w '%{http_code}\n' -L $BASE/api/docs # 200
Pregunta que responde: ¿un endpoint de negocio se puede llamar sin credenciales, y cómo distingo ese 401 de un servidor mal arrancado?
A.4 Modalidad 2 — JWT: identidad sin RBAC
Esta modalidad existe de verdad, pero fuera del negocio: GET /api/sesion/perfil, GET /api/permisos y GET /api/sesiones solo piden quién eres, no comprueban concesiones. Es la modalidad que hay que dominar porque es el primer peldaño de cualquier llamada de negocio.
Casos correctos
| Caso | Petición | Código real | Cuerpo real (recortado) |
|---|---|---|---|
Perfil con token de admin |
GET /api/sesion/perfil |
200 | {"user":{"id":1,"username":"admin","email":"admin@storelab.local","avatar":null,"status":"active"}} |
Perfil con token de seller |
GET /api/sesion/perfil |
200 | mismo formato, con los datos de seller |
Permisos de admin |
GET /api/permisos |
200 | 58 elementos |
Permisos de seller |
GET /api/permisos |
200 | 7 elementos |
Errores simulables: tokens fabricados
Aquí está la parte interesante: authenticate no se fía del token. Fija el algoritmo en código, exige iss y aud, y comprueba a mano los claims sub y jti. Para probarlo hay que fabricar tokens malos. Guárdalo como forge-tokens.mjs dentro del proyecto (para que resuelva jsonwebtoken):
forge-tokens.mjs
import jwt from "jsonwebtoken";
import { readFileSync } from "node:fs";
const SECRET = readFileSync(".env", "utf8").match(/^JWT_SECRET=(.*)$/m)[1].trim();
const ISS = "app-storelab-express";
const AUD = "app-storelab-api";
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const good = (o = {}) =>
jwt.sign({ username: "admin" }, o.secret ?? SECRET, {
algorithm: "HS256",
subject: "1",
issuer: o.issuer ?? ISS,
audience: o.audience ?? AUD,
expiresIn: o.expiresIn ?? 900,
jwtid: o.jwtid === undefined ? "jti-ok" : o.jwtid,
});
export const bad = {
valid: good(),
garbage: "no.es.un.jwt",
wrongSecret: good({ secret: "otro-secreto-que-no-es-el-del-servidor-1234" }),
wrongIssuer: good({ issuer: "otro-emisor" }),
wrongAudience: good({ audience: "otra-audiencia" }),
expired: good({ expiresIn: -60 }),
// sin `jti`: se firma a mano omitiendo `jwtid`
noJti: jwt.sign({ username: "admin" }, SECRET, {
algorithm: "HS256", subject: "1", issuer: ISS, audience: AUD, expiresIn: 900,
}),
// sin `sub`
noSub: jwt.sign({ username: "admin" }, SECRET, {
algorithm: "HS256", issuer: ISS, audience: AUD, expiresIn: 900, jwtid: "jti-x",
}),
// firma válida, usuario inexistente
ghostUser: jwt.sign({ username: "ghost" }, SECRET, {
algorithm: "HS256", subject: "999999", issuer: ISS, audience: AUD,
expiresIn: 900, jwtid: "jti-g",
}),
// alg: none (sin firma)
algNone: `${b64({ alg: "none", typ: "JWT" })}.${b64({
sub: "1", username: "admin", iss: ISS, aud: AUD,
exp: Math.floor(Date.now() / 1000) + 900,
iat: Math.floor(Date.now() / 1000), jti: "jti-none",
})}.`,
// token válido con el último carácter cambiado
tampered: (() => { const t = good(); return t.slice(0, -1) + (t.slice(-1) === "A" ? "B" : "A"); })(),
};
console.log(JSON.stringify(bad, null, 2));
Resultados reales de este laboratorio contra GET /api/sesion/perfil:
| Token enviado | Código real | Cuerpo real |
|---|---|---|
| (sin cabecera) | 401 | {"error":"Missing Bearer token"} |
Authorization: Bearer (vacío) |
401 | {"error":"Missing Bearer token"} |
Authorization: Token <válido> (esquema distinto) |
401 | {"error":"Missing Bearer token"} |
no.es.un.jwt (basura) |
401 | {"error":"Invalid or expired access token"} |
| firma inválida (otro secreto) | 401 | {"error":"Invalid or expired access token"} |
iss incorrecto |
401 | {"error":"Invalid or expired access token"} |
aud incorrecta |
401 | {"error":"Invalid or expired access token"} |
sin jti |
401 | {"error":"Invalid or expired access token"} |
sin sub |
401 | {"error":"Invalid or expired access token"} |
| expirado | 401 | {"error":"Invalid or expired access token"} |
alg: none (sin firma) |
401 | {"error":"Invalid or expired access token"} |
| payload manipulado (1 carácter) | 401 | {"error":"Invalid or expired access token"} |
sub válido, usuario inexistente |
401 | {"error":"User is not active"} |
| TOKEN VÁLIDO (control) | 200 | el perfil del usuario |
Tres observaciones que merecen atención:
- Los 401 no se distinguen entre sí por diseño. Firma mala,
issmalo, expirado o manipulado devuelven el mismo mensaje: decir cuál falló sería regalar información a quien intenta atacar. - «sin
jti» y «sinsub» devuelven 401, no 200. Es la consecuencia del arreglo documentado en la guía de errores:jsonwebtokenno tiene opciónrequire(esa es dejose), así que los claims obligatorios se comprueban explícitamente trasjwt.verify. - El último caso es distinto y es el más sutil.
User is not activesignifica que el token era criptográficamente válido pero el usuario revalidado en BD no está operable. No es un fallo del token, es un fallo de estado del usuario.
sequenceDiagram
autonumber
participant C as Cliente
participant A as authenticate
participant J as jwt.verify
participant DB as Base de datos
C->>A: "Authorization: Bearer eyJ..."
A->>A: "extractBearerToken (esquema bearer, sin distinguir mayusculas)"
alt "sin cabecera / Bearer vacio / otro esquema"
A-->>C: "401 Missing Bearer token"
else "hay token"
A->>J: "verify con algorithms HS256 fijo, iss y aud"
J-->>A: "payload o excepcion"
alt "firma o claims invalidos"
A-->>C: "401 Invalid or expired access token"
else "payload valido"
A->>A: "comprobar sub (entero positivo) y jti (no vacio)"
A->>DB: "revalidar que el usuario existe y sigue active"
alt "usuario inexistente o inactive"
A-->>C: "401 User is not active"
else "usuario activo"
A->>C: "next() -> pasa a authorize"
end
end
end
Pregunta que responde: ¿en qué orden y con qué criterio rechaza
authenticateun token, y por qué unos fallos danMissing Bearer tokeny otrosInvalid or expired access token?
A.5 Modalidad 3 — JWT + RBAC sobre negocio
Es la modalidad de todos los endpoints de negocio. authorize corre después de authenticate y responde a una pregunta distinta: ¿puede esta identidad ejecutar method + path?
req.auth.user
→ role_users (status = active, user_id = ?)
→ roles (status = active)
→ resource_roles (status = active)
→ resources (status = active, method = ?, path ≈ ?)
Los cuatro eslabones deben estar activos. Si cualquiera falla, la fila no aparece y el resultado es 403 (deny by default).
Casos correctos (con concesión activa)
Códigos reales de este laboratorio:
| Petición | admin |
seller |
|---|---|---|
GET /api/clientes |
200 | 200 |
GET /api/clientes/:id |
200 | 200 |
POST /api/clientes |
201 | 403 |
PUT /api/clientes/:id |
200 | 403 |
PATCH /api/clientes/:id |
200 | 403 |
DELETE /api/clientes/:id |
200 | 403 |
GET /api/productos |
200 | 200 |
GET /api/ventas |
200 | 200 |
GET /api/detalle-ventas |
200 | 403 |
GET /api/usuarios |
200 | 403 |
GET /api/roles |
200 | 403 |
GET /api/recursos |
200 | 403 |
Un POST /api/clientes correcto devuelve:
{"client":{"id":20,"name":"E2E-1790688079","phone":"3001112233",
"email":"e2e-1790688079@example.com","status":"active",
"updatedAt":"2026-09-29T13:21:19.140Z","createdAt":"2026-09-29T13:21:19.140Z"}}
Observa que password no aparece en la respuesta: el DTO de salida lo elimina por proyección explícita (Omit).
Errores simulables: identidad válida sin concesión
El 403 es el error que más cuesta interiorizar, porque el token es perfecto. Mensajes reales:
| Caso | Petición | Código real | Cuerpo real |
|---|---|---|---|
seller crea cliente |
POST /api/clientes |
403 | {"error":"Forbidden: no grant for POST /api/clientes"} |
seller edita cliente |
PUT /api/clientes/20 |
403 | {"error":"Forbidden: no grant for PUT /api/clientes/20"} |
seller borra cliente |
DELETE /api/clientes/20 |
403 | {"error":"Forbidden: no grant for DELETE /api/clientes/20"} |
seller lista usuarios |
GET /api/usuarios |
403 | {"error":"Forbidden: no grant for GET /api/usuarios"} |
seller lista detalle de ventas |
GET /api/detalle-ventas |
403 | {"error":"Forbidden: no grant for GET /api/detalle-ventas"} |
seller lista concesiones |
GET /api/concesiones-rol |
403 | {"error":"Forbidden: no grant for GET /api/concesiones-rol"} |
Detalle deliberado. El mensaje del 403 incluye el path real de la petición, no el patrón concesionable:
no grant for DELETE /api/clientes/20. El patrón que se concede es/api/clientes/:id, pero el mensaje te dice exactamente qué URL falló. Es una decisión de facilidad de depuración, consciente del intercambio con la discreción.
flowchart TD
A["Peticion a un endpoint de negocio"] --> B{"authenticate: hay Bearer valido?"}
B -->|"no"| C["401 - no se quien eres"]
B -->|"si"| D{"authorize: concesion activa para method + path?"}
D -->|"no"| E["403 - se quien eres, pero no puedes"]
D -->|"si"| F["controller -> service -> repository"]
F --> G["200 / 201"]
C -.->|"arreglo: enviar token"| H["GET /api/permisos"]
E -.->|"arreglo: conceder el par (method, path)"| H
H -.->|"la lista refleja la matriz real"| D
Pregunta que responde: ¿cómo distingo si un fallo es de token o de permiso, y qué hago en cada caso?
El 403 no depende del token: depende de la matriz
Esta es la prueba de que los roles y permisos no viajan dentro del token. Si se retira la concesión, el mismo token deja de autorizar de inmediato, sin reiniciar el servidor:
RID=$(curl -s $BASE/api/roles -H "Authorization: Bearer $ADMIN" | jq -r '.roles[]|select(.name=="SELLER")|.id')
ERC=$(curl -s $BASE/api/recursos -H "Authorization: Bearer $ADMIN" \
| jq -r '.resources[]|select(.method=="POST" and .path=="/api/clientes")|.id')
# 1) conceder POST /api/clientes al rol SELLER -> el MISMO token pasa a poder
curl -s -X POST $BASE/api/concesiones-rol -H "Authorization: Bearer $ADMIN" \
-H 'Content-Type: application/json' -d "{\"role_id\":$RID,\"resource_id\":$ERC}"
# 2) retirar la concesión -> vuelve a 403 al instante
GID=$(curl -s $BASE/api/concesiones-rol -H "Authorization: Bearer $ADMIN" | jq -r '.grants[-1].id')
curl -s -X PATCH $BASE/api/concesiones-rol/$GID/deactivate -H "Authorization: Bearer $ADMIN"
npm run db:seed # restaurar la matriz original (ADMIN 58, SELLER 7)
Pregunta que responde: ¿por qué revocar un permiso surte efecto inmediato aunque el token siga vigente?
A.6 Errores transversales (400 / 404 / 409 / 500)
No son de la modalidad, pero aparecen constantemente al probar negocio.
| Caso | Petición | Código real | Cuerpo real |
|---|---|---|---|
:id no entero |
GET /api/clientes/abc |
400 | {"error":"Invalid id: must be a positive integer"} |
:id inexistente |
GET /api/clientes/999999 |
404 | {"error":"Client not found"} |
registro inactive |
GET de un cliente desactivado |
404 | {"error":"Client not found"} |
| JSON malformado | POST /api/clientes con {"name": |
400 | {"error":"Malformed JSON body"} |
| ruta inexistente | GET /api/no-existe |
404 | HTML de Express: Cannot GET /api/no-existe |
El orden de los middlewares, verificado
Este es el matiz más fácil de equivocar, así que se probó en las dos direcciones:
| Caso | Código real | Por qué |
|---|---|---|
seller + DELETE /api/clientes/abc |
403 | seller no tiene DELETE /api/clientes/:id → authorize corta antes de validar el :id |
seller + GET /api/clientes/abc |
400 | seller sí tiene GET /api/clientes/:id → pasa la autorización y falla el :id |
admin + GET /api/clientes/abc |
400 | admin tiene la concesión → llega al paramId |
La lección: el 403 gana al 400. Si no tienes concesión, no llegas ni a que se valide tu entrada. Y por eso un :id inválido puede devolver dos códigos distintos según quién lo pida, sin ninguna contradicción.
Pregunta que responde: si mando un
:idinválido en una ruta que no me corresponde, ¿qué error recibo primero, el de permiso o el de formato?
A.7 Hallazgos: dos defectos reales en los endpoints de negocio
Probar errores «raros» no es solo comprobar la API: es auditar. Ejecutar este anexo descubrió dos comportamientos reales de los endpoints de negocio que no se corresponden con lo que hacen los de autenticación. Se documentan tal cual, porque un cuaderno que solo enseña el camino feliz miente por omisión.
Hallazgo 1 — POST /api/clientes no valida campos obligatorios
| Petición | Código real | Resultado real |
|---|---|---|
POST /api/clientes con {} |
201 | {"client":{"id":26,"status":"active","updatedAt":"…","createdAt":"…"}} — creado con name, phone, email y password en null |
POST /api/clientes con {"name":"SoloNombre"} |
201 | creado, el resto en null |
POST /api/clientes con "email":"no-es-un-email" |
500 | {"error":"Internal server error","detail":"SequelizeValidationError: Validation error: Email must be a valid email address"} |
Es decir: el endpoint acepta y persiste un cliente vacío (basura en la base de datos), pero si el email trae un formato inválido, en vez de un 400 de validación se obtiene un 500 con el nombre del error de Sequelize.
Compáralo con lo que sí hacen otros features del mismo proyecto:
| Endpoint | Petición inválida | Código real |
|---|---|---|
POST /api/productos |
{} |
404 {"error":"Product type not found"} |
POST /api/ventas |
{} |
400 {"error":"Sale requires at least one item"} |
O sea: products y sales sí validan su entrada (y devuelven 404/400 razonables), mientras clients no. La inconsistencia está localizada en el feature clients.
Hallazgo 2 — el email duplicado en clients devuelve 500 en vez de 409
| Feature | Caso | Código real | Cuerpo real |
|---|---|---|---|
clients |
dos clientes con el mismo email | 500 | {"error":"Internal server error","detail":"SequelizeUniqueConstraintError: Validation error"} |
users |
username duplicado |
409 | {"error":"Username already in use"} |
users |
email duplicado |
409 | {"error":"Email already in use"} |
roles |
name duplicado |
409 | {"error":"Role name already in use"} |
recursos |
(method, path) duplicado |
409 | {"error":"Resource GET /api/clientes already exists"} |
Los features de autenticación traducen el conflicto de unicidad a un 409 con mensaje de negocio. clients no lo traduce: deja escapar el error del ORM como 500.
Por qué esto importa (más allá de la estética)
- Fuga de información. El campo
detailexpone el nombre de la capa de persistencia (SequelizeUniqueConstraintError,SequelizeValidationError). Un cliente de la API no debería saber que por debajo hay Sequelize, y menos aún el nombre interno de la restricción que falló. - Semántica HTTP incorrecta. Un conflicto de unicidad es 409, y una entrada mal formada es 400. Devolver 500 dice «el servidor está roto», cuando en realidad la petición era inválida: eso desvía el diagnóstico y ensucia la monitorización.
- Integridad de datos. Un
201con todos los campos ennulles peor que un error: mete ruido en la base de datos y no avisa a nadie. - Respuesta poco útil. El
detailde un registro recién creado solo muestraid,statusy timestamps: no confirma al cliente qué se guardó.
Pregunta que responde: ¿qué comportamientos de los endpoints de negocio no coinciden con los de autenticación, y por qué son un problema real y no un detalle cosmético?
Nota de honestidad. Estos dos hallazgos se descubrieron ejecutando el anexo, no leyendo el código. Eso es exactamente lo que justifica simular errores: la documentación y el comportamiento real divergen, y solo ejecutar lo revela.
A.8 Script integral reproducible
Guarda este script como scripts/verify-business-access.sh y ejecútalo con el servidor levantado y la base de datos recién sembrada. Recorre las tres modalidades sobre endpoints de negocio y termina en verde solo si todos los códigos son los esperados.
#!/usr/bin/env bash
# Verifica los endpoints de NEGOCIO bajo las 3 modalidades de acceso.
set -u
BASE="http://localhost:4000"
PASS=0; FAIL=0
c() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
chk() { if [ "$2" = "$3" ]; then printf ' PASS [%s] %s\n' "$3" "$1"; PASS=$((PASS+1));
else printf ' FAIL esperado=%s obtenido=%s :: %s\n' "$2" "$3" "$1"; FAIL=$((FAIL+1)); fi; }
login() { curl -s -X POST "$BASE/api/sesion/login" -H 'Content-Type: application/json' -d "$1"; }
AT=$(login '{"identifier":"admin","password":"Admin123!"}' | jq -r .access_token)
ST=$(login '{"identifier":"seller","password":"Seller123!"}' | jq -r .access_token)
AU="Authorization: Bearer $AT"
SU="Authorization: Bearer $ST"
J='Content-Type: application/json'
echo "=== Modalidad 1 — OPEN (negocio NO es OPEN) ==="
chk "sin token GET /api/clientes" 401 "$(c "$BASE/api/clientes")"
chk "sin token POST /api/clientes" 401 "$(c -X POST "$BASE/api/clientes" -H "$J" -d '{}')"
chk "sin token GET /api/productos" 401 "$(c "$BASE/api/productos")"
chk "sin token GET /api/ventas" 401 "$(c "$BASE/api/ventas")"
chk "sin token GET /api/usuarios" 401 "$(c "$BASE/api/usuarios")"
echo "--- contraste: lo que SÍ es OPEN ---"
chk "GET /api/docs (redirect)" 301 "$(c "$BASE/api/docs")"
chk "GET /api/docs (siguiendo)" 200 "$(c -L "$BASE/api/docs")"
chk "POST /api/sesion/login" 200 "$(c -X POST "$BASE/api/sesion/login" -H "$J" -d '{"identifier":"admin","password":"Admin123!"}')"
echo "=== Modalidad 2 — JWT (identidad sin RBAC) ==="
chk "perfil con token admin" 200 "$(c "$BASE/api/sesion/perfil" -H "$AU")"
chk "perfil con token seller" 200 "$(c "$BASE/api/sesion/perfil" -H "$SU")"
chk "perfil sin token" 401 "$(c "$BASE/api/sesion/perfil")"
chk "perfil token basura" 401 "$(c "$BASE/api/sesion/perfil" -H 'Authorization: Bearer basura')"
chk "admin 58 permisos" 58 "$(curl -s "$BASE/api/permisos" -H "$AU" | jq '.permissions|length')"
chk "seller 7 permisos" 7 "$(curl -s "$BASE/api/permisos" -H "$SU" | jq '.permissions|length')"
echo "=== Modalidad 3 — JWT + RBAC (con concesión) ==="
chk "admin GET /api/clientes" 200 "$(c "$BASE/api/clientes" -H "$AU")"
chk "seller GET /api/clientes" 200 "$(c "$BASE/api/clientes" -H "$SU")"
chk "admin POST /api/clientes" 201 "$(c -X POST "$BASE/api/clientes" -H "$AU" -H "$J" -d '{"name":"verify","phone":"3000000000","email":"verify@example.com","password":"Secret123"}')"
chk "seller GET /api/productos" 200 "$(c "$BASE/api/productos" -H "$SU")"
chk "seller GET /api/ventas" 200 "$(c "$BASE/api/ventas" -H "$SU")"
chk "admin GET /api/detalle-ventas" 200 "$(c "$BASE/api/detalle-ventas" -H "$AU")"
echo "=== Modalidad 3 — JWT + RBAC (sin concesión) ==="
chk "seller POST /api/clientes" 403 "$(c -X POST "$BASE/api/clientes" -H "$SU" -H "$J" -d '{"name":"x","phone":"1","email":"x@x.com","password":"p"}')"
chk "seller DELETE /api/clientes/1" 403 "$(c -X DELETE "$BASE/api/clientes/1" -H "$SU")"
chk "seller GET /api/usuarios" 403 "$(c "$BASE/api/usuarios" -H "$SU")"
chk "seller GET /api/detalle-ventas" 403 "$(c "$BASE/api/detalle-ventas" -H "$SU")"
echo "=== Errores transversales ==="
chk "admin :id no entero" 400 "$(c "$BASE/api/clientes/abc" -H "$AU")"
chk "admin :id inexistente" 404 "$(c "$BASE/api/clientes/999999" -H "$AU")"
chk "ruta inexistente" 404 "$(c "$BASE/api/no-existe" -H "$AU")"
chk "JSON malformado" 400 "$(c -X POST "$BASE/api/clientes" -H "$AU" -H "$J" -d '{"name":')"
chk "orden: seller DELETE :id invalido -> 403" 403 "$(c -X DELETE "$BASE/api/clientes/abc" -H "$SU")"
chk "orden: seller GET :id invalido -> 400" 400 "$(c "$BASE/api/clientes/abc" -H "$SU")"
echo
echo "======== $PASS PASS / $FAIL FAIL ========"
npm run db:seed > /dev/null # restaurar la matriz y los usuarios canónicos
[ "$FAIL" -eq 0 ]
Resultado real de la ejecución en este laboratorio (parte A del suite):
Los 4 «FAIL» no eran fallos del backend: dos eran expectativas mal puestas en el script (el 301 de /api/docs y el hecho de que seller sí tiene GET /api/clientes/:id) y dos destaparon los defectos reales de §A.7. Ese es el valor de un script de verificación: cuando falla, o está mal el test o está mal el código, y averiguar cuál es el trabajo.
Pregunta que responde: ¿cómo convierto todo este anexo en una comprobación que se pueda repetir en un segundo?
A.9 Cómo se ve en Swagger
El documento OpenAPI fija security: [{ bearerAuth: [] }] globalmente, así que toda operación muestra un candado 🔒. Las OPEN lo anulan con security: []:
En /api/docs |
Significa |
|---|---|
| Sin candado | OPEN |
| Con candado 🔒 | JWT o JWT + RBAC |
El candado no distingue entre JWT y JWT + RBAC; lo que distingue es el comportamiento: prueba la misma operación con seller y observa si responde 403. Swagger sirve para descubrir la modalidad; curl sirve para demostrarla.
A.10 Regla de oro para depurar un endpoint de negocio
401 -> problema de TOKEN (¿lo enviaste? ¿caducó? ¿el usuario sigue activo?)
403 -> problema de PERMISO (GET /api/permisos: ¿aparece el par method + path?)
400 -> problema de ENTRADA (body incompleto o mal formado, o :id no entero)
404 -> problema de RECURSO (no existe, está inactive, o no es tuyo)
409 -> CONFLICTO (duplicado... en los features que sí lo traducen: ver §A.7)
500 -> o hay un fallo no previsto, o topaste con un defecto conocido (§A.7)
A.11 Checklist del anexo
- [ ] Puedo explicar por qué ningún endpoint de negocio es OPEN.
- [ ] Consigo los tres códigos sobre
/api/clientes: 401 sin token, 403 conseller, 200 conadmin. - [ ] Distingo por el mensaje un 401 de token (
Missing Bearer token/Invalid or expired access token/User is not active). - [ ] Distingo un 401 de un 403 y sé qué mirar en cada caso.
- [ ] Sé por qué
DELETE /api/clientes/abcda 403 yGET /api/clientes/abcda 400. - [ ] Sé que
SELLERtiene 7 permisos yADMIN58, y puedo listarlos conGET /api/permisos. - [ ] Puedo reproducir el
500del email duplicado enclientsy explicar el409deusers. - [ ] Ejecuto
scripts/verify-business-access.shy termino connpm run db:seed.
Con este anexo cerrado, sabes probar lo que el ISS-15 construyó: las tres modalidades conviviendo en el mismo backend, con el negocio detrás de la tercera.
Navegación de la ruta: ← ISS-14 · 🛠 Construir · ↑ Ruta Express · → ISS-15 · 🛠 Construir