Skip to content

DEV-88 BE — Middleware de autenticación JWT — feat(auth): add a global JWT guard with explicit public opt-out - #45

Open
LucianABC wants to merge 1 commit into
mainfrom
DEV-88/jwt-auth-guard
Open

DEV-88 BE — Middleware de autenticación JWT — feat(auth): add a global JWT guard with explicit public opt-out#45
LucianABC wants to merge 1 commit into
mainfrom
DEV-88/jwt-auth-guard

Conversation

@LucianABC

Copy link
Copy Markdown
Collaborator

Summary

  • JwtAuthGuard: valida Authorization: Bearer <token> y deja el actor en req.user.
  • @CurrentUser() para leerlo en un handler, @Public() para abrir una ruta.
  • Nada cambia para los clientes todavía: todas las rutas actuales quedan marcadas públicas.

La decisión de diseño

El guard se registra globalmente (APP_GUARD) en vez de aplicarse ruta por ruta.

Con opt-in, olvidarse del guard deja un endpoint abierto y nada lo delata. Con opt-out, olvidarse de @Public() devuelve 401 en el primer request. El modo de fallar por descuido pasa a ser "de más" en vez de "de menos" — que es lo que querés en la capa de auth.

Los @Public() que agrego, y por qué:

Ruta Motivo
/ (health) Permanente — Railway lo consulta sin credenciales (DEV-164)
/games, /playbooks Permanente — datos de referencia sin dueño; el front los necesita antes de que haya sesión
/auth/register, /auth/login Permanente por necesidad lógica — son los endpoints que se usan para obtener un token
/characters ⚠️ TEMPORAL — ver abajo

El de characters es el único que hay que sacar. Protegerlo hoy daría 401 al front sin arreglar nada de fondo: las queries siguen devolviendo los personajes de todos a cualquiera que tenga token, y create() todavía toma ownerId del body. El arreglo real es scopear por el actor — DEV-59 (listado) y DEV-64 (detalle/edición/borrado). Está marcado con un comentario grande en el controller.

Otra decisión: el guard no consulta la base

El principal sale del token y nada más. Agregar un lookup por request encarecería todos los endpoints para cubrir un caso que hoy no existe: no hay baja de usuarios. La consecuencia a tener presente es que, si un usuario se borrara, su token seguiría válido hasta expirar — cuando exista esa baja hay que revisar esto. /auth/me (DEV-86), que sí necesita el usuario completo, lo busca por sub.

Verificación

Hay un problema propio de este ticket: como todavía no existe ninguna ruta realmente protegida, el guard global no se puede verificar de punta a punta. Un APP_GUARD mal registrado pasaría desapercibido hasta DEV-86. Dos cosas para cubrirlo:

  1. Integration spec con controllers sonda (jwt-auth.guard.integration.spec.ts) — levanta AuthModule con una ruta protegida y una @Public(), y comprueba que el guard intercepta de verdad. Queda como regresión en CI.
  2. Contra el build real, quitando temporalmente el @Public() de /games (después restaurado):
Sin header 401 Token de autenticación requerido
Token real 200 con los 4 games
Token falsificado (otro secreto) 401
Token vencido 401
Token sin firma (alg: none) 401

Un intento anterior de verificar contra una ruta inexistente dio 404 en todos los casos: los guards globales de Nest no corren si no hay ruta que matchee. Por eso la prueba se hizo sobre una ruta que existe.

Test plan

  • jwt-auth.guard.spec.ts — 14 casos: token válido/falsificado/vencido/alg:none/sin sub, y parseo del header (sin esquema, Basic, minúscula, partes de más, vacío)
  • jwt-auth.guard.integration.spec.ts — el guard global intercepta; /auth/login sigue accesible (afirmando sobre el mensaje, porque un 401 del guard y uno de credenciales no se distinguen por status)
  • public.decorator.spec.ts — el decorador escribe la metadata que el guard lee, en handler y en clase
  • current-user.decorator.spec.ts — devuelve el actor; tira 401 en vez de undefined (que terminaría como ownerId: undefined en una query, sin scopear nada)
  • npm run lint, build, test:cov (153 tests) y test:e2e (11) verdes
  • Sin regresión: /, /games, /playbooks, /characters y /auth/login siguen respondiendo 200 sin token

Refs: DEV-88

Validates the Bearer token and leaves the actor in req.user, with
@currentuser() to read it and @public() to opt a route out.

The guard is registered globally rather than applied per route. Opting
in means a forgotten guard leaves an endpoint open and nothing says so;
opting out means a forgotten @public() returns 401 on the first request.
The way this fails by accident is now closed rather than open.

Every current route is marked public, so nothing changes for callers
yet. Games, playbooks and the healthcheck are permanently public — they
carry no owner. Register and login are public by necessity, since they
are how a token is obtained. Characters is the one temporary case, and
it is flagged as such: protecting it today would 401 the frontend
without fixing anything, because the queries still return every user's
characters to whoever holds a token. That is DEV-59 and DEV-64.

The principal comes from the token alone, with no database lookup, so
every request does not pay for a case that cannot happen yet: there is
no way to delete a user. When there is, a deleted user's token staying
valid until expiry needs revisiting.

Because no real route is protected yet, the global registration is
covered by an integration spec with probe controllers — otherwise a
broken APP_GUARD would go unnoticed until the first protected route.
Verified against the running build by temporarily unprotecting /games:
missing, forged, expired and alg:none tokens all get 401; a real one
gets 200.

Refs: DEV-88
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant