@aranzatech/aranza-auth 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,63 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.2] - 2026-07-03
8
+
9
+ ### Added
10
+
11
+ - **OpenAPI / Swagger**: `@ApiProperty` en DTOs, `setupAuthSwagger()`, `MeResponseDto`, `ApiAuthUnauthorizedResponse`, `@ApiExtraModels`, feature flags en descripción OpenAPI, `exportPath` para `openapi.json`.
12
+ - **`POST /auth/resend-verification`**: reenvío anti-enumeración.
13
+ - **JWT claims**: `typ: "access" | "refresh"`, `jti` en refresh, opcional `jwtIssuer` / `jwtAudience`.
14
+ - **Refresh payload mínimo**: sin PII en refresh JWT.
15
+ - **`jwtValidationCacheTtlMs`**: cache opcional en `JwtStrategy` (0–5 min).
16
+ - **`AUTH_RATE_LIMIT_ROUTES`**: mapa ruta → preset throttler.
17
+ - **Cookie helpers**: `buildRefreshTokenCookie()`, `buildClearRefreshTokenCookie()`.
18
+ - **Mongo admin**: `setAccountDisabled()`, `findUnverifiedByEmail()`, índice `{ email, disabled }`.
19
+ - **E2E**: Supertest smoke tests + flujo login → me → refresh → logout → change-password → resend-verification.
20
+ - **`AuthHooksConstructor`**: tipado sin `any[]`.
21
+ - **`AuthErrorCode.UNAUTHORIZED`**: token ausente o inválido en rutas protegidas.
22
+
23
+ ### Fixed
24
+
25
+ - **P0 lockout bypass**: `lockedUntil` en JWT validate, refresh y login.
26
+ - **P0 access tokens tras password change**: claim `pwdAt`.
27
+ - **P0 refresh rotation race**: `rotateRefreshTokenHashIfMatch` atómico.
28
+ - **P0 lockout timing leak**: bcrypt antes del lockout check.
29
+ - **P0 error codes**: `AuthErrorCode` en todos los mensajes.
30
+ - **Reset password**: `PASSWORD_UNCHANGED` si la contraseña es igual.
31
+ - **Login HTTP 200** (no 201).
32
+ - **`routePrefix`**: sin `@Controller("auth")` hardcodeado.
33
+ - **Lockout race**: `$inc` atómico.
34
+ - **Hooks DI unificado** en `forRoot` / `forRootAsync`.
35
+ - **`DefaultAuthHooks`**: sin `sub` duplicado en `buildJwtPayload`.
36
+ - **`JwtAuthGuard`**: rutas protegidas sin Bearer devuelven `UNAUTHORIZED`.
37
+ - **Docs**: README/SECURITY alineados con v0.2.2; **`publish.yml`** usa `npm run ci`.
38
+
39
+ ### Changed
40
+
41
+ - **Breaking (error messages)**: `INVALID_CREDENTIALS`, `INVALID_REFRESH_TOKEN` son códigos.
42
+ - **Breaking (refresh JWT)**: payload reducido — re-login tras upgrade desde ≤0.2.1.
43
+ - **Breaking (refresh hash)**: HMAC-SHA256 en lugar de bcrypt — invalida hashes almacenados; re-login requerido.
44
+ - CI: `npm audit --audit-level=high` falla el pipeline.
45
+ - README: `forbidNonWhitelisted: true`.
46
+
47
+ ### Security
48
+
49
+ - Warning en boot si `refreshTokenRotation: false`.
50
+ - Refresh hash con HMAC + `timingSafeEqual` (más rápido que bcrypt en cada refresh).
51
+
52
+ ### Tests / CI
53
+
54
+ - **113 tests** (unit + e2e Supertest).
55
+ - Coverage gates: AuthService ≥80%, TokenService ≥75%, MongoAuthRepository ≥70%, total ≥65%.
56
+ - `npm run ci` — pipeline local completo.
57
+ - `overrides` en `package.json` para audit limpio (`esbuild`, `multer`).
58
+
59
+ ## [0.2.1] - 2026-07-03
60
+
61
+ ### Fixed
62
+
63
+ - CI coverage gate: Vitest escribe rutas absolutas en `coverage-summary.json` — match por sufijo de archivo.
8
64
 
9
65
  ## [0.2.0] - 2026-07-03
10
66
 
@@ -78,7 +134,9 @@ First public release.
78
134
  - **Adapter MongoDB** (`@aranzatech/aranza-auth/mongo`).
79
135
  - Build dual ESM/CJS, tests (`vitest`), CI GitHub Actions.
80
136
 
81
- [Unreleased]: https://github.com/aranzatech/aranza-auth/compare/v0.2.0...HEAD
137
+ [Unreleased]: https://github.com/aranzatech/aranza-auth/compare/v0.2.2...HEAD
138
+ [0.2.2]: https://github.com/aranzatech/aranza-auth/compare/v0.2.1...v0.2.2
139
+ [0.2.1]: https://github.com/aranzatech/aranza-auth/compare/v0.2.0...v0.2.1
82
140
  [0.2.0]: https://github.com/aranzatech/aranza-auth/compare/v0.1.2...v0.2.0
83
141
  [0.1.2]: https://github.com/aranzatech/aranza-auth/compare/v0.1.1...v0.1.2
84
142
  [0.1.1]: https://github.com/aranzatech/aranza-auth/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -16,13 +16,14 @@ Ideal para reutilizar auth en todos tus proyectos AranzaTech: instalas, configur
16
16
  - [Endpoints](#endpoints)
17
17
  - [Proteger rutas propias](#proteger-rutas-propias)
18
18
  - [Rate limiting (producción)](#rate-limiting-producción)
19
+ - [Swagger / OpenAPI](#swagger--openapi)
19
20
  - [Seguridad en producción](#seguridad-en-producción)
20
21
  - [Extender con AuthHooks](#extender-con-authhooks)
21
22
  - [Extender el schema MongoDB](#extender-el-schema-mongodb)
22
23
  - [Flujos opcionales (email y password)](#flujos-opcionales-email-y-password)
23
24
  - [Exports públicos](#exports-públicos)
24
25
  - [Requisitos del proyecto consumidor](#requisitos-del-proyecto-consumidor)
25
- - [Limitaciones conocidas (v0.1.0)](#limitaciones-conocidas-v010)
26
+ - [Limitaciones conocidas (v0.2.x)](#limitaciones-conocidas-v02x)
26
27
  - [Roadmap](#roadmap)
27
28
  - [Licencia](#licencia)
28
29
 
@@ -39,7 +40,7 @@ npm install @aranzatech/aranza-auth
39
40
  ```bash
40
41
  npm install @nestjs/common @nestjs/core @nestjs/jwt @nestjs/passport \
41
42
  @nestjs/mongoose mongoose passport passport-jwt bcryptjs \
42
- class-validator class-transformer reflect-metadata
43
+ class-validator class-transformer reflect-metadata @nestjs/swagger
43
44
  ```
44
45
 
45
46
  > NestJS **11+** recomendado.
@@ -94,7 +95,11 @@ Los DTOs usan `class-validator`. Activa el pipe global en `main.ts`:
94
95
  import { ValidationPipe } from "@nestjs/common";
95
96
 
96
97
  app.useGlobalPipes(
97
- new ValidationPipe({ whitelist: true, transform: true }),
98
+ new ValidationPipe({
99
+ whitelist: true,
100
+ forbidNonWhitelisted: true,
101
+ transform: true,
102
+ }),
98
103
  );
99
104
  ```
100
105
 
@@ -127,8 +132,13 @@ Con eso ya tienes auth funcional para un POC.
127
132
  | `MONGODB_URI` | Sí | Connection string de MongoDB |
128
133
  | `JWT_SECRET` | Sí | Secret para access tokens |
129
134
  | `JWT_REFRESH_SECRET` | Sí | Secret para refresh tokens (distinto al anterior) |
135
+ | `JWT_EXPIRES_IN` | No | TTL access token (default `1h` si lo mapeas en config) |
136
+ | `JWT_REFRESH_EXPIRES_IN` | No | TTL refresh token (default `7d`) |
137
+ | `JWT_ISSUER` / `JWT_AUDIENCE` | No | Solo si configuras `jwtIssuer` / `jwtAudience` |
130
138
  | `FRONTEND_URL` | No | Base URL para links en emails (verify/reset) |
131
139
 
140
+ Ver `.env.example` en el repo como plantilla para apps consumidoras (la lib no lee `.env` directamente).
141
+
132
142
  ---
133
143
 
134
144
  ## Configuración del módulo
@@ -153,19 +163,46 @@ AuthModule.forRootAsync({
153
163
  features: {
154
164
  emailVerification: false, // default: false
155
165
  passwordReset: false, // default: false
156
- refreshTokenRotation: true, // default: true
166
+ refreshTokenRotation: true, // default: true
167
+ accountLockout: false, // default: false
168
+ },
169
+
170
+ // ── Lockout (si accountLockout: true) ────────────────
171
+ lockout: {
172
+ maxAttempts: 5, // default: 5
173
+ lockoutDurationMs: 15 * 60_000, // default: 15 min
157
174
  },
158
175
 
176
+ // ── JWT issuer/audience (opcional) ───────────────────
177
+ jwtIssuer: "my-api",
178
+ jwtAudience: "my-app",
179
+
180
+ // ── Performance (opcional) ───────────────────────────
181
+ jwtValidationCacheTtlMs: 0, // 0 = siempre validar en DB; max 300000 (5 min)
182
+
183
+ // ── Ruta del controller ──────────────────────────────
184
+ routePrefix: "auth", // default: "auth" → /auth/login
185
+
186
+ // ── Password policy ──────────────────────────────────
187
+ bcryptRounds: 10, // 10–14, default 10
188
+ passwordComplexity: false, // upper + lower + digit
189
+
159
190
  // ── TTL de tokens de email/reset ─────────────────────
160
191
  emailVerificationTokenTtlMs: 24 * 60 * 60 * 1000, // 24h
161
192
  passwordResetTokenTtlMs: 15 * 60 * 1000, // 15min
162
193
 
163
194
  // ── Hooks personalizados ─────────────────────────────
164
- hooks: AppAuthHooks, // default: DefaultAuthHooks
195
+ hooks: AppAuthHooks, // default: DefaultAuthHooks (Nest DI vía ModuleRef)
196
+ // hooksProvider: { ... }, // alternativa: provider Nest completo
165
197
  }),
198
+ // routePrefix y hooks también pueden ir aquí (nivel forRootAsync):
199
+ // routePrefix: "auth",
200
+ // hooks: AppAuthHooks,
166
201
  }),
167
202
  ```
168
203
 
204
+ `AuthModule.forRoot(options)` acepta las mismas opciones de forma síncrona (sin `imports`/`inject`).
205
+
169
206
  ### Orden de imports
170
207
 
171
208
  `MongoAuthModule.forFeature()` debe importarse **dentro** de `AuthModule.forRootAsync`:
@@ -198,6 +235,7 @@ Todo está **desactivado por defecto**. Sin declarar `features`, obtienes el mí
198
235
  | `emailVerification` | `false` | Envía email al register, bloquea login hasta verificar |
199
236
  | `passwordReset` | `false` | Habilita `forgot-password` y `reset-password` |
200
237
  | `refreshTokenRotation` | `true` | Guarda hash del refresh token en DB y lo rota |
238
+ | `accountLockout` | `false` | Bloquea cuenta tras N intentos fallidos de login |
201
239
 
202
240
  ### Modo POC (solo login/register)
203
241
 
@@ -212,7 +250,7 @@ AuthModule.forRootAsync({
212
250
  ```
213
251
 
214
252
  - Register crea cuenta con `emailVerified: true` → login inmediato.
215
- - Endpoints `verify-email`, `forgot-password`, `reset-password` responden **404**.
253
+ - Endpoints `verify-email`, `resend-verification`, `forgot-password`, `reset-password` responden **404**.
216
254
 
217
255
  ### Modo producción
218
256
 
@@ -234,11 +272,12 @@ hooks: AppAuthHooks, // debe implementar sendEmail
234
272
  | Método | Ruta | Auth | Feature | Body | Respuesta |
235
273
  |--------|------|------|---------|------|-----------|
236
274
  | `POST` | `/auth/register` | — | — | `{ email?, username?, password }` | `{ registered: true }` |
237
- | `POST` | `/auth/login` | — | — | `{ email?, username?, password }` | `{ accessToken, refreshToken }` |
275
+ | `POST` | `/auth/login` | — | — | `{ email?, username?, password }` | `{ accessToken, refreshToken }` (**HTTP 200**) |
238
276
  | `POST` | `/auth/refresh` | — | — | `{ refreshToken }` | `{ accessToken, refreshToken }` |
239
277
  | `POST` | `/auth/logout` | Bearer | — | — | `{ loggedOut: true }` |
240
278
  | `GET` | `/auth/me` | Bearer | — | — | objeto enriquecido via hooks |
241
279
  | `POST` | `/auth/verify-email` | — | `emailVerification` | `{ token }` | `{ verified: true }` |
280
+ | `POST` | `/auth/resend-verification` | — | `emailVerification` | `{ email }` | `{ sent: true }` |
242
281
  | `POST` | `/auth/forgot-password` | — | `passwordReset` | `{ email }` | `{ sent: true }` |
243
282
  | `POST` | `/auth/reset-password` | — | `passwordReset` | `{ token, newPassword }` | `{ reset: true }` |
244
283
  | `POST` | `/auth/change-password` | Bearer | — | `{ currentPassword, newPassword }` | `{ changed: true }` |
@@ -249,13 +288,15 @@ hooks: AppAuthHooks, // debe implementar sendEmail
249
288
 
250
289
  | Código | Mensaje | Causa |
251
290
  |--------|---------|-------|
252
- | `401` | `Invalid credentials` | Email/username o password incorrectos |
291
+ | `401` | `INVALID_CREDENTIALS` | Email/username o password incorrectos |
253
292
  | `401` | `EMAIL_NOT_VERIFIED` | Feature `emailVerification` activa y email sin verificar |
254
293
  | `401` | `ACCOUNT_DISABLED` | Cuenta desactivada |
255
294
  | `401` | `ACCOUNT_LOCKED` | Demasiados intentos fallidos (`features.accountLockout`) |
295
+ | `401` | `PASSWORD_CHANGED` | Access token emitido antes del último cambio de contraseña |
256
296
  | `401` | `REFRESH_TOKEN_REUSE` | Refresh reutilizado; sesiones revocadas |
257
297
  | `401` | `INVALID_CURRENT_PASSWORD` | Contraseña actual incorrecta en change-password |
258
- | `401` | `Invalid refresh token` | Refresh expirado, revocado o inválido |
298
+ | `401` | `INVALID_REFRESH_TOKEN` | Refresh expirado, revocado o inválido |
299
+ | `401` | `UNAUTHORIZED` | Sin Bearer token o token inválido en ruta protegida |
259
300
  | `404` | — | Feature desactivada (endpoint no disponible) |
260
301
  | `400` | `TOKEN_INVALID_OR_EXPIRED` | Token de verify/reset inválido o expirado |
261
302
  | `400` | `PASSWORD_UNCHANGED` | Nueva contraseña igual a la actual |
@@ -327,10 +368,73 @@ Presets exportados:
327
368
  |--------|-----------------|--------|
328
369
  | `default` | Rutas auth generales | 10 req/min |
329
370
  | `credentials` | `/auth/login`, `/auth/register`, `/auth/refresh` | 5 req/min |
330
- | `passwordReset` | `/auth/forgot-password` | 3 req/min |
371
+ | `passwordReset` | `/auth/forgot-password`, `/auth/reset-password`, `/auth/resend-verification` | 3 req/min |
372
+
373
+ Mapa por ruta (`AUTH_RATE_LIMIT_ROUTES`):
374
+
375
+ ```typescript
376
+ import { AUTH_RATE_LIMIT_ROUTES } from "@aranzatech/aranza-auth";
377
+ // AUTH_RATE_LIMIT_ROUTES.login → preset credentials
378
+ // AUTH_RATE_LIMIT_ROUTES["resend-verification"] → preset passwordReset
379
+ ```
331
380
 
332
381
  > Aplica `@Throttle()` por controlador o ruta según tu política de seguridad.
333
382
 
383
+ ### Refresh token en cookie (opcional)
384
+
385
+ ```typescript
386
+ import { buildRefreshTokenCookie } from "@aranzatech/aranza-auth";
387
+
388
+ @Post("login")
389
+ async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
390
+ const tokens = await this.authService.login(dto);
391
+ res.setHeader("Set-Cookie", buildRefreshTokenCookie(tokens.refreshToken));
392
+ return { accessToken: tokens.accessToken };
393
+ }
394
+ ```
395
+
396
+ ---
397
+
398
+ ## Swagger / OpenAPI
399
+
400
+ Instala `@nestjs/swagger` en tu app (peer dependency):
401
+
402
+ ```bash
403
+ npm install @nestjs/swagger
404
+ ```
405
+
406
+ En `main.ts`:
407
+
408
+ ```typescript
409
+ import { setupAuthSwagger } from "@aranzatech/aranza-auth";
410
+
411
+ async function bootstrap() {
412
+ const app = await NestFactory.create(AppModule);
413
+
414
+ setupAuthSwagger(app, {
415
+ title: "Mi API",
416
+ description: "Documentación OpenAPI",
417
+ path: "api", // → http://localhost:3000/api
418
+ version: "1.0",
419
+ features: {
420
+ emailVerification: true,
421
+ passwordReset: true,
422
+ accountLockout: true,
423
+ },
424
+ exportPath: "./openapi.json", // opcional: escribe el spec al arrancar
425
+ });
426
+
427
+ await app.listen(3000);
428
+ }
429
+ ```
430
+
431
+ Los endpoints `/auth/*` aparecen bajo el tag **`auth`**. Rutas protegidas usan **Bearer JWT** (`access-token`).
432
+
433
+ Para probar en Swagger UI:
434
+ 1. `POST /auth/login` → copia `accessToken`
435
+ 2. Click **Authorize** → pega el token
436
+ 3. Llama `GET /auth/me` o tus rutas con `@ApiBearerAuth('access-token')`
437
+
334
438
  ---
335
439
 
336
440
  ## Seguridad en producción
@@ -352,6 +456,11 @@ AuthModule.forRootAsync({
352
456
  emailVerification: true,
353
457
  passwordReset: true,
354
458
  refreshTokenRotation: true,
459
+ accountLockout: true,
460
+ },
461
+ lockout: {
462
+ maxAttempts: 5,
463
+ lockoutDurationMs: 15 * 60_000,
355
464
  },
356
465
  }),
357
466
  });
@@ -359,8 +468,19 @@ AuthModule.forRootAsync({
359
468
 
360
469
  ### Refresh tokens en cookies (recomendado)
361
470
 
471
+ Usa los helpers exportados (HttpOnly, Secure, SameSite=strict):
472
+
473
+ ```typescript
474
+ import { buildRefreshTokenCookie, buildClearRefreshTokenCookie } from "@aranzatech/aranza-auth";
475
+
476
+ res.setHeader("Set-Cookie", buildRefreshTokenCookie(tokens.refreshToken));
477
+ // logout:
478
+ res.setHeader("Set-Cookie", buildClearRefreshTokenCookie());
479
+ ```
480
+
481
+ Alternativa manual:
482
+
362
483
  ```typescript
363
- // En login/refresh handler de tu app — no devolver refresh en JSON body
364
484
  res.cookie("refreshToken", tokens.refreshToken, {
365
485
  httpOnly: true,
366
486
  secure: true,
@@ -399,7 +519,7 @@ interface MyAuthAccount extends BaseAuthAccount {
399
519
  export class AppAuthHooks implements AuthHooks<MyAuthAccount> {
400
520
  constructor(private readonly orgService: OrgService) {}
401
521
 
402
- /** Campos extra en el JWT */
522
+ /** Campos extra en el JWT (no incluyas `sub` — lo añade la lib) */
403
523
  async buildJwtPayload(account: MyAuthAccount) {
404
524
  return {
405
525
  orgId: account.orgId,
@@ -495,6 +615,17 @@ MongoAuthModule.forFeature({
495
615
 
496
616
  > `identifierField` en `MongoAuthModule.forFeature()` debe coincidir con el de `AuthModule`.
497
617
 
618
+ ### Métodos extra en `MongoAuthRepository`
619
+
620
+ Útiles para admin o scripts (inyecta `MongoAuthRepository` o implementa en tu propio repo):
621
+
622
+ | Método | Uso |
623
+ |--------|-----|
624
+ | `setAccountDisabled(id, disabled)` | Deshabilitar/habilitar cuenta |
625
+ | `findUnverifiedByEmail(email)` | Buscar cuenta pendiente de verificación |
626
+
627
+ Índice compuesto `{ email: 1, disabled: 1 }` incluido para lookups frecuentes.
628
+
498
629
  ---
499
630
 
500
631
  ## Flujos opcionales (email y password)
@@ -504,7 +635,8 @@ MongoAuthModule.forFeature({
504
635
  1. Activa `features.emailVerification: true`
505
636
  2. Implementa `sendEmail` en hooks
506
637
  3. Register envía email con token → usuario visita link → `POST /auth/verify-email` con `{ token }`
507
- 4. Login bloqueado hasta `emailVerified: true`
638
+ 4. Si el usuario no recibió el email: `POST /auth/resend-verification` con `{ email }` → siempre `{ sent: true }`
639
+ 5. Login bloqueado hasta `emailVerified: true`
508
640
 
509
641
  Si usas `identifierField: "username"`, el register **debe incluir `email`** además del username.
510
642
 
@@ -522,8 +654,19 @@ Si usas `identifierField: "username"`, el register **debe incluir `email`** adem
522
654
 
523
655
  | Import | Contenido |
524
656
  |--------|-----------|
525
- | `@aranzatech/aranza-auth` | `AuthModule`, `AuthService`, guards, DTOs, interfaces, tokens, `AUTH_RATE_LIMIT_PRESETS` |
526
- | `@aranzatech/aranza-auth/mongo` | `MongoAuthModule`, `MongoAuthRepository`, schemas |
657
+ | `@aranzatech/aranza-auth` | `AuthModule`, `AuthService`, guards, DTOs, `setupAuthSwagger`, `AuthErrorCode`, cookie helpers, rate-limit presets |
658
+ | `@aranzatech/aranza-auth/mongo` | `MongoAuthModule`, `MongoAuthRepository`, `baseAuthAccountSchema` |
659
+
660
+ Principales exports:
661
+
662
+ | Símbolo | Uso |
663
+ |---------|-----|
664
+ | `AuthErrorCode` | Códigos de error (`INVALID_CREDENTIALS`, `UNAUTHORIZED`, …) |
665
+ | `AUTH_RATE_LIMIT_PRESETS` / `AUTH_RATE_LIMIT_ROUTES` | Throttling con `@nestjs/throttler` |
666
+ | `buildRefreshTokenCookie` / `buildClearRefreshTokenCookie` | Cookies HttpOnly para refresh |
667
+ | `MeResponseDto` / `ResendVerificationDto` | Swagger + tipos |
668
+ | `AuthHooksConstructor` | Tipado de hooks custom con Nest DI |
669
+ | `setupAuthSwagger` | Swagger UI + Bearer JWT |
527
670
 
528
671
  ### Tokens de inyección
529
672
 
@@ -549,24 +692,45 @@ import { AUTH_MODULE_OPTIONS, AUTH_HOOKS, AUTH_REPOSITORY } from "@aranzatech/ar
549
692
 
550
693
  ---
551
694
 
552
- ## Limitaciones conocidas (v0.1.x)
695
+ ## Limitaciones conocidas (v0.2.x)
553
696
 
554
697
  | Limitación | Workaround / versión futura |
555
698
  |------------|----------------------------|
556
699
  | Solo adapter MongoDB | Prisma en v0.4.0 |
557
700
  | Sin OAuth (Google, GitHub, etc.) | v0.3.0 |
701
+ | Una sesión refresh por cuenta (`refreshTokenHash` único) | Multi-device en roadmap |
702
+ | `jwtValidationCacheTtlMs > 0` retrasa revoke/disable en access tokens | Usar `0` si necesitas revocación inmediata |
703
+
704
+ ---
705
+
706
+ ## Migración desde ≤0.2.1
707
+
708
+ 1. Actualiza a `@aranzatech/aranza-auth@0.2.2`.
709
+ 2. **Re-login obligatorio**: refresh JWT y hashes almacenados cambiaron (payload mínimo + HMAC-SHA256).
710
+ 3. Los clientes deben parsear `AuthErrorCode` en `message` (no strings legibles).
711
+ 4. Activa `forbidNonWhitelisted: true` en `ValidationPipe`.
712
+ 5. Opcional: `jwtIssuer` / `jwtAudience`, cookies con `buildRefreshTokenCookie()`.
558
713
 
559
714
  ---
560
715
 
561
716
  ## Roadmap
562
717
 
563
718
  - [x] **0.2.0** — Security hardening, `change-password`, hooks con DI, audit trail, account lockout, `routePrefix`
719
+ - [x] **0.2.2** — Swagger, P0/P1/P2 audit, JWT claims, resend-verification, E2E smoke tests
564
720
  - [ ] **0.3.0** — OAuth (Google, GitHub)
565
721
  - [ ] **0.4.0** — Adapter Prisma
566
722
  - [ ] **0.5.0** — Migración de AranzaFlow como caso de referencia
567
723
 
568
724
  ---
569
725
 
726
+ ## Desarrollo
727
+
728
+ ```bash
729
+ npm install
730
+ npm run ci # lint + tests + coverage gates + audit + build
731
+ npm test # solo tests
732
+ ```
733
+
570
734
  ## Licencia
571
735
 
572
736
  MIT © [AranzaTech](https://github.com/aranzatech)
package/SECURITY.md CHANGED
@@ -30,9 +30,15 @@ We aim to respond within **72 hours** and publish a fix or mitigation within **1
30
30
  | Behavior | Mitigation |
31
31
  |----------|------------|
32
32
  | **Logout** clears refresh token hash only; access JWT remains valid until expiry | Use short access TTL (15–60 min) |
33
+ | **Access tokens after password change** | Invalidated via `pwdAt` claim (v0.2.2+) when `passwordChangedAt` is newer |
33
34
  | **Register duplicate** returns `{ registered: true }` without revealing existence | By design (anti-enumeration) |
34
35
  | **Forgot password** always returns `{ sent: true }` | By design (anti-enumeration) |
35
36
  | **Refresh token reuse** revokes all refresh sessions for the account | Monitor `REFRESH_TOKEN_REUSE` errors |
37
+ | **`refreshTokenRotation: false`** | Refresh is stateless until JWT expiry — stolen tokens cannot be revoked; warning logged at boot |
38
+ | **Refresh JWT payload** | Minimal claims only (`sub`, `typ`, `jti`, `pwdAt`) — no PII in refresh token |
39
+ | **Refresh hash storage** | HMAC-SHA256 (v0.2.2+) — upgrade from ≤0.2.1 requires re-login |
40
+ | **Single `refreshTokenHash`** | One active refresh session per account (document in your app if multi-device needed) |
41
+ | **`jwtValidationCacheTtlMs`** | Optional in-memory cache; disable (`0`) for immediate disable/revoke on access |
36
42
 
37
43
  ## Hardening Checklist (Production)
38
44
 
@@ -48,11 +54,16 @@ We aim to respond within **72 hours** and publish a fix or mitigation within **1
48
54
 
49
55
  | Code | Meaning |
50
56
  |------|---------|
57
+ | `PASSWORD_CHANGED` | Access token issued before last password change |
58
+ | `PASSWORD_UNCHANGED` | New password equals current on change/reset |
59
+ | `INVALID_CREDENTIALS` | Wrong email/username or password |
60
+ | `INVALID_REFRESH_TOKEN` | Refresh expired, revoked, or invalid |
51
61
  | `REFRESH_TOKEN_REUSE` | Stale refresh token used — all refresh sessions revoked |
52
62
  | `ACCOUNT_LOCKED` | Too many failed login attempts |
53
63
  | `INVALID_CURRENT_PASSWORD` | Wrong password on change-password |
54
64
  | `EMAIL_NOT_VERIFIED` | Login or access blocked until email verified |
55
65
  | `ACCOUNT_DISABLED` | Account disabled by admin |
56
66
  | `TOKEN_INVALID_OR_EXPIRED` | Email verify or password reset token invalid |
67
+ | `UNAUTHORIZED` | Missing or invalid Bearer token on protected routes |
57
68
 
58
69
  Export: `AuthErrorCode` from `@aranzatech/aranza-auth`.
@@ -8,6 +8,7 @@ interface BaseAuthAccount {
8
8
  disabled: boolean;
9
9
  lastLoginAt?: Date;
10
10
  passwordChangedAt?: Date;
11
+ lockedUntil?: Date | null;
11
12
  createdAt?: Date;
12
13
  updatedAt?: Date;
13
14
  }
@@ -36,6 +37,9 @@ interface AuthHooks<TAccount extends BaseAuthAccount = BaseAuthAccount> {
36
37
  sendEmail?(type: "verify" | "reset", to: string, token: string): Promise<void>;
37
38
  }
38
39
 
40
+ /** Constructor type for custom `AuthHooks` implementations with Nest DI. */
41
+ type AuthHooksConstructor = abstract new (...args: never[]) => AuthHooks;
42
+
39
43
  type AuthIdentifierField = "email" | "username";
40
44
  interface AuthFeatures {
41
45
  /** Require verified email before login. Sends verification email on register. Default: `false`. */
@@ -58,6 +62,10 @@ interface AuthJwtConfig {
58
62
  expiresIn?: string;
59
63
  refreshSecret: string;
60
64
  refreshExpiresIn?: string;
65
+ /** Optional JWT `iss` claim — validated on access and refresh tokens. */
66
+ jwtIssuer?: string;
67
+ /** Optional JWT `aud` claim — validated on access and refresh tokens. */
68
+ jwtAudience?: string;
61
69
  }
62
70
  interface AuthModuleOptions extends AuthJwtConfig {
63
71
  /** Field used for login/register lookup. Default: `email`. */
@@ -76,8 +84,13 @@ interface AuthModuleOptions extends AuthJwtConfig {
76
84
  passwordComplexity?: boolean;
77
85
  /** Lockout settings when `features.accountLockout` is enabled. */
78
86
  lockout?: AuthLockoutOptions;
87
+ /**
88
+ * Cache JWT user validation in memory for this many ms (0 = always hit DB).
89
+ * Useful for high-traffic APIs; disable when immediate revoke/disable is critical.
90
+ */
91
+ jwtValidationCacheTtlMs?: number;
79
92
  /** Custom hooks class (`useClass` — supports Nest DI). Defaults to `DefaultAuthHooks`. */
80
- hooks?: new (...args: any[]) => AuthHooks;
93
+ hooks?: AuthHooksConstructor;
81
94
  /** Full Nest provider for hooks. Takes precedence over `hooks`. */
82
95
  hooksProvider?: _nestjs_common.Provider;
83
96
  }
@@ -85,7 +98,7 @@ interface AuthModuleAsyncOptions {
85
98
  /** Route prefix for auth controller. Default: `auth`. */
86
99
  routePrefix?: string;
87
100
  /** Custom hooks class (`useClass` with Nest DI via `ModuleRef`). */
88
- hooks?: new (...args: any[]) => AuthHooks;
101
+ hooks?: AuthHooksConstructor;
89
102
  /** Full Nest provider for hooks. Takes precedence over `hooks`. */
90
103
  hooksProvider?: _nestjs_common.Provider;
91
104
  imports?: unknown[];
@@ -106,6 +119,8 @@ interface IAuthRepository<TAccount extends BaseAuthAccount = BaseAuthAccount> {
106
119
  findById(id: string): Promise<BaseAuthAccount | null>;
107
120
  findByIdWithSecrets(id: string): Promise<AuthAccountWithSecrets | null>;
108
121
  updateRefreshTokenHash(id: string, hash: string | null): Promise<void>;
122
+ /** Atomically replaces refresh hash when it still matches `expectedHash`. */
123
+ rotateRefreshTokenHashIfMatch(id: string, expectedHash: string, newHash: string): Promise<boolean>;
109
124
  updatePasswordHash(id: string, passwordHash: string): Promise<void>;
110
125
  setEmailVerificationToken(id: string, tokenHash: string, expiresAt: Date): Promise<void>;
111
126
  findByEmailVerificationTokenHash(tokenHash: string): Promise<BaseAuthAccount | null>;
@@ -115,6 +130,10 @@ interface IAuthRepository<TAccount extends BaseAuthAccount = BaseAuthAccount> {
115
130
  clearResetToken(id: string): Promise<void>;
116
131
  recordLoginSuccess(id: string): Promise<void>;
117
132
  recordLoginFailure(id: string, lockout?: AuthLockoutOptions): Promise<void>;
133
+ /** Enable or disable an account (admin / hooks use). */
134
+ setAccountDisabled(id: string, disabled: boolean): Promise<void>;
135
+ /** Find unverified account by email (email verification flows). */
136
+ findUnverifiedByEmail(email: string): Promise<BaseAuthAccount | null>;
118
137
  }
119
138
 
120
- export type { AuthModuleOptions as A, BaseAuthAccount as B, CreateAccountData as C, IAuthRepository as I, RegisterInput as R, AuthModuleAsyncOptions as a, AuthHooks as b, AuthTokens as c, AuthAccountWithSecrets as d, AuthFeatures as e, AuthIdentifierField as f, AuthJwtConfig as g, AuthLockoutOptions as h };
139
+ export type { AuthFeatures as A, BaseAuthAccount as B, CreateAccountData as C, IAuthRepository as I, RegisterInput as R, AuthModuleOptions as a, AuthModuleAsyncOptions as b, AuthHooks as c, AuthTokens as d, AuthAccountWithSecrets as e, AuthHooksConstructor as f, AuthIdentifierField as g, AuthJwtConfig as h, AuthLockoutOptions as i };
@@ -8,6 +8,7 @@ interface BaseAuthAccount {
8
8
  disabled: boolean;
9
9
  lastLoginAt?: Date;
10
10
  passwordChangedAt?: Date;
11
+ lockedUntil?: Date | null;
11
12
  createdAt?: Date;
12
13
  updatedAt?: Date;
13
14
  }
@@ -36,6 +37,9 @@ interface AuthHooks<TAccount extends BaseAuthAccount = BaseAuthAccount> {
36
37
  sendEmail?(type: "verify" | "reset", to: string, token: string): Promise<void>;
37
38
  }
38
39
 
40
+ /** Constructor type for custom `AuthHooks` implementations with Nest DI. */
41
+ type AuthHooksConstructor = abstract new (...args: never[]) => AuthHooks;
42
+
39
43
  type AuthIdentifierField = "email" | "username";
40
44
  interface AuthFeatures {
41
45
  /** Require verified email before login. Sends verification email on register. Default: `false`. */
@@ -58,6 +62,10 @@ interface AuthJwtConfig {
58
62
  expiresIn?: string;
59
63
  refreshSecret: string;
60
64
  refreshExpiresIn?: string;
65
+ /** Optional JWT `iss` claim — validated on access and refresh tokens. */
66
+ jwtIssuer?: string;
67
+ /** Optional JWT `aud` claim — validated on access and refresh tokens. */
68
+ jwtAudience?: string;
61
69
  }
62
70
  interface AuthModuleOptions extends AuthJwtConfig {
63
71
  /** Field used for login/register lookup. Default: `email`. */
@@ -76,8 +84,13 @@ interface AuthModuleOptions extends AuthJwtConfig {
76
84
  passwordComplexity?: boolean;
77
85
  /** Lockout settings when `features.accountLockout` is enabled. */
78
86
  lockout?: AuthLockoutOptions;
87
+ /**
88
+ * Cache JWT user validation in memory for this many ms (0 = always hit DB).
89
+ * Useful for high-traffic APIs; disable when immediate revoke/disable is critical.
90
+ */
91
+ jwtValidationCacheTtlMs?: number;
79
92
  /** Custom hooks class (`useClass` — supports Nest DI). Defaults to `DefaultAuthHooks`. */
80
- hooks?: new (...args: any[]) => AuthHooks;
93
+ hooks?: AuthHooksConstructor;
81
94
  /** Full Nest provider for hooks. Takes precedence over `hooks`. */
82
95
  hooksProvider?: _nestjs_common.Provider;
83
96
  }
@@ -85,7 +98,7 @@ interface AuthModuleAsyncOptions {
85
98
  /** Route prefix for auth controller. Default: `auth`. */
86
99
  routePrefix?: string;
87
100
  /** Custom hooks class (`useClass` with Nest DI via `ModuleRef`). */
88
- hooks?: new (...args: any[]) => AuthHooks;
101
+ hooks?: AuthHooksConstructor;
89
102
  /** Full Nest provider for hooks. Takes precedence over `hooks`. */
90
103
  hooksProvider?: _nestjs_common.Provider;
91
104
  imports?: unknown[];
@@ -106,6 +119,8 @@ interface IAuthRepository<TAccount extends BaseAuthAccount = BaseAuthAccount> {
106
119
  findById(id: string): Promise<BaseAuthAccount | null>;
107
120
  findByIdWithSecrets(id: string): Promise<AuthAccountWithSecrets | null>;
108
121
  updateRefreshTokenHash(id: string, hash: string | null): Promise<void>;
122
+ /** Atomically replaces refresh hash when it still matches `expectedHash`. */
123
+ rotateRefreshTokenHashIfMatch(id: string, expectedHash: string, newHash: string): Promise<boolean>;
109
124
  updatePasswordHash(id: string, passwordHash: string): Promise<void>;
110
125
  setEmailVerificationToken(id: string, tokenHash: string, expiresAt: Date): Promise<void>;
111
126
  findByEmailVerificationTokenHash(tokenHash: string): Promise<BaseAuthAccount | null>;
@@ -115,6 +130,10 @@ interface IAuthRepository<TAccount extends BaseAuthAccount = BaseAuthAccount> {
115
130
  clearResetToken(id: string): Promise<void>;
116
131
  recordLoginSuccess(id: string): Promise<void>;
117
132
  recordLoginFailure(id: string, lockout?: AuthLockoutOptions): Promise<void>;
133
+ /** Enable or disable an account (admin / hooks use). */
134
+ setAccountDisabled(id: string, disabled: boolean): Promise<void>;
135
+ /** Find unverified account by email (email verification flows). */
136
+ findUnverifiedByEmail(email: string): Promise<BaseAuthAccount | null>;
118
137
  }
119
138
 
120
- export type { AuthModuleOptions as A, BaseAuthAccount as B, CreateAccountData as C, IAuthRepository as I, RegisterInput as R, AuthModuleAsyncOptions as a, AuthHooks as b, AuthTokens as c, AuthAccountWithSecrets as d, AuthFeatures as e, AuthIdentifierField as f, AuthJwtConfig as g, AuthLockoutOptions as h };
139
+ export type { AuthFeatures as A, BaseAuthAccount as B, CreateAccountData as C, IAuthRepository as I, RegisterInput as R, AuthModuleOptions as a, AuthModuleAsyncOptions as b, AuthHooks as c, AuthTokens as d, AuthAccountWithSecrets as e, AuthHooksConstructor as f, AuthIdentifierField as g, AuthJwtConfig as h, AuthLockoutOptions as i };