@aranzatech/aranza-auth 0.2.0 → 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 +60 -2
- package/README.md +180 -16
- package/SECURITY.md +11 -0
- package/dist/{auth-repository.interface-9PpDVOs8.d.cts → auth-repository.interface--1rv0RCD.d.cts} +22 -3
- package/dist/{auth-repository.interface-9PpDVOs8.d.ts → auth-repository.interface--1rv0RCD.d.ts} +22 -3
- package/dist/index.cjs +621 -186
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -11
- package/dist/index.d.ts +142 -11
- package/dist/index.js +617 -188
- package/dist/index.js.map +1 -1
- package/dist/mongo/index.cjs +34 -6
- package/dist/mongo/index.cjs.map +1 -1
- package/dist/mongo/index.d.cts +4 -1
- package/dist/mongo/index.d.ts +4 -1
- package/dist/mongo/index.js +34 -6
- package/dist/mongo/index.js.map +1 -1
- package/package.json +12 -1
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
|
-
## [
|
|
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.
|
|
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.
|
|
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({
|
|
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,
|
|
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,
|
|
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` | `
|
|
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` | `
|
|
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.
|
|
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,
|
|
526
|
-
| `@aranzatech/aranza-auth/mongo` | `MongoAuthModule`, `MongoAuthRepository`,
|
|
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.
|
|
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`.
|
package/dist/{auth-repository.interface-9PpDVOs8.d.cts → auth-repository.interface--1rv0RCD.d.cts}
RENAMED
|
@@ -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?:
|
|
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?:
|
|
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 {
|
|
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 };
|
package/dist/{auth-repository.interface-9PpDVOs8.d.ts → auth-repository.interface--1rv0RCD.d.ts}
RENAMED
|
@@ -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?:
|
|
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?:
|
|
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 {
|
|
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 };
|