@nodefony/security 10.0.0-alpha.8 → 10.0.0-alpha.9

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.
Files changed (35) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +2 -2
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  13. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  14. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  15. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  16. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  17. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  18. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  19. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  20. package/docs/api-keys.md +62 -60
  21. package/docs/audit.md +77 -77
  22. package/docs/authenticators.md +55 -43
  23. package/docs/authorization.md +13 -13
  24. package/docs/cors.md +29 -29
  25. package/docs/csrf.md +26 -26
  26. package/docs/external-jwt.md +4 -4
  27. package/docs/firewall.md +29 -29
  28. package/docs/headers.md +26 -26
  29. package/docs/oauth2.md +60 -60
  30. package/docs/obtenir-un-jeton.md +2 -2
  31. package/docs/tokens.md +55 -55
  32. package/docs/totp.md +73 -72
  33. package/docs/webauthn.md +81 -81
  34. package/docs/webhooks.md +43 -37
  35. package/package.json +15 -12
package/docs/tokens.md CHANGED
@@ -87,11 +87,11 @@ défaut » en prod.
87
87
  ## La vision Nodefony — un service propriétaire, des endpoints minces
88
88
 
89
89
  `TokenService` est **propriétaire** du store et du keystore : à `TokenService.#build()`
90
- (`tokenService.ts:93`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
- `tokenStore` au container (`tokenService.ts:163`) puis crée le keystore et pose `jwtKeystore`
92
- (`tokenService.ts:167-173`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
90
+ (`tokenService.ts:101`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
+ `tokenStore` au container (`tokenService.ts:175`) puis crée le keystore et pose `jwtKeystore`
92
+ (`tokenService.ts:181-186`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
93
93
  **gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
94
- `tokenService.ts:175-181`).
94
+ `tokenService.ts:188-194`).
95
95
 
96
96
  Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
97
97
  nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
@@ -136,7 +136,7 @@ seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncera
136
136
  ### Les endpoints d'émission sont FOURNIS
137
137
 
138
138
  Dans une app `nodefony create app`, dès que le module security est chargé avec `jwt.enabled` (défaut),
139
- le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:132-147`) :
139
+ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:161-176`) :
140
140
 
141
141
  - `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
@@ -228,36 +228,36 @@ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController
228
228
 
229
229
  ### Émission (grant M2M/CLI)
230
230
 
231
- `issueForCredentials()` (`tokenService.ts:317`) vérifie l'identifiant/mot de passe via le
231
+ `issueForCredentials()` (`tokenService.ts:324`) vérifie l'identifiant/mot de passe via le
232
232
  service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
233
- (`tokenService.ts:733`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
234
- par `#auditGrant()` (`tokenService.ts:362-374`). Puis `issueTokens()` (`tokenService.ts:490`)
233
+ (`tokenService.ts:353`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
234
+ par `#auditGrant()` (`tokenService.ts:369-381`). Puis `issueTokens()` (`tokenService.ts:497`)
235
235
  produit :
236
236
 
237
237
  - un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
238
- `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:402-415`) ;
238
+ `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:649-662`) ;
239
239
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
240
240
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
241
- (`tokenService.ts:674-709`).
241
+ (`tokenService.ts:681-716`).
242
242
 
243
- La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:43-51`). Tout succès est audité
243
+ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`). Tout succès est audité
244
244
  `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:312-318`).
245
245
 
246
246
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
247
247
 
248
- `refresh()` (`tokenService.ts:555`) est le cœur défensif, dans l'ordre :
248
+ `refresh()` (`tokenService.ts:562`) est le cœur défensif, dans l'ordre :
249
249
 
250
- 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:564`).
250
+ 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
251
251
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
252
- famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:345-361`).
253
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:363-368`).
252
+ famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
253
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts:594`).
254
254
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:745`).
255
+ `#resolveUserForRefresh()` (`tokenService.ts:752`).
256
256
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
257
- (`tokenService.ts:592`).
257
+ (`tokenService.ts:600`).
258
258
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
259
- `"rotated"` (`tokenService.ts:698`). Si `rotateRefresh` est désactivé, l'access est réémis
260
- et le refresh courant reste valide (`tokenService.ts:627`).
259
+ `"rotated"` (`tokenService.ts:634-636`). Si `rotateRefresh` est désactivé, l'access est réémis
260
+ et le refresh courant reste valide (`tokenService.ts:620`).
261
261
 
262
262
  ### Mise en situation — ton refresh token a été volé
263
263
 
@@ -336,35 +336,35 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
336
336
 
337
337
  - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
338
338
  - `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
339
- (`MemoryTokenStore.ts:122-142`).
339
+ (`MemoryTokenStore.ts:165-185`).
340
340
  - Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
341
- (`MemoryTokenStore.ts:360`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
341
+ (`MemoryTokenStore.ts:393`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
342
342
  - `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
343
- (`MemoryTokenStore.ts:253-283`).
343
+ (`MemoryTokenStore.ts:324-354`).
344
344
  - Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
345
345
 
346
346
  ### `drizzle` — SQL, le défaut durable
347
347
 
348
- - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:220`).
348
+ - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
349
349
  - Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
350
- est chargé (`config.ts:394-399`).
350
+ est chargé (`config.ts:428-433`).
351
351
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
352
352
 
353
353
  ### `mongoose` — MongoDB
354
354
 
355
- - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:119`).
356
- - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:163-173`).
357
- - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:196`).
355
+ - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:170`).
356
+ - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:236-246`).
357
+ - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:170`).
358
358
 
359
359
  ### `redis` — cluster, TTL natif
360
360
 
361
- - Enregistré par le module redis (`redis/nodefony/registerStores.ts:49`).
362
- - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:254`) — l'expiration ne
361
+ - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
362
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:308`) — l'expiration ne
363
363
  dépend pas du gc.
364
364
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
365
- (`RedisTokenStore.ts:35-44`) — sans ordre global ni total, capacité réduite **assumée**.
365
+ (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
366
366
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
367
- (`RedisTokenStore.ts:432`).
367
+ (`RedisTokenStore.ts:483`).
368
368
 
369
369
  ### Le record — une seule table pour PAT et refresh
370
370
 
@@ -398,72 +398,72 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
398
398
  - **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
399
399
  (`ITokenStore.ts:242`).
400
400
  - **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
401
- `iat < invalidBefore` est rejeté (`ITokenStore.ts:226-234`) ; le seuil est **monotone**, deux
402
- logouts successifs ne le reculent pas (`MemoryTokenStore.ts:229`).
401
+ `iat < invalidBefore` est rejeté (`ITokenStore.ts:259-263`) ; le seuil est **monotone**, deux
402
+ logouts successifs ne le reculent pas (`MemoryTokenStore.ts:262-267`).
403
403
 
404
404
  ### La maintenance (gc)
405
405
 
406
406
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
407
- la rétention (`ITokenStore.ts:237-251`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
407
+ la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
408
408
  un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
409
409
 
410
410
  > [!TIP]
411
411
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
412
412
  > `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
413
- > (`MemoryTokenStore.ts:239-248`). Un store **local** (memory) est par-process : seul SON process
413
+ > (`MemoryTokenStore.ts:301-310`). Un store **local** (memory) est par-process : seul SON process
414
414
  > peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
415
415
 
416
416
  ## ⚙️ Configuration
417
417
 
418
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:334-390`) et `tokenStoreSchema`
419
- (`config.ts:392-425`), défauts inclus.
418
+ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
419
+ (`config.ts:426-458`), défauts inclus.
420
420
 
421
421
  ### `jwt.*`
422
422
 
423
423
  <!-- prettier-ignore -->
424
424
  | Option | Type | Défaut | Effet |
425
425
  | --- | --- | --- | --- |
426
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:317`) |
426
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
427
427
  | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
428
- | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:364`) |
429
- | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:369`) |
430
- | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:374`) |
428
+ | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:370`) |
429
+ | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
430
+ | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
431
431
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
432
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:384`) |
432
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:390`) |
433
433
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
434
- | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:398`) |
435
- | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:376-381`) |
434
+ | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
435
+ | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
436
436
 
437
437
  ### `tokenStore.*`
438
438
 
439
439
  | Option | Type | Défaut | Effet |
440
440
  | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
441
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:394-399`) |
442
- | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:428`) |
443
- | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:436`) |
444
- | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:442`) |
441
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:428-433`) |
442
+ | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:434`) |
443
+ | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:442`) |
444
+ | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:448`) |
445
445
 
446
446
  ## 📜 Normes appliquées
447
447
 
448
448
  | Domaine | Norme | Ancrage |
449
449
  | -------------------------------- | --------------- | ------------------------------------------------------- |
450
- | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:43-51`) |
451
- | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:555`) |
452
- | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:402-415`) |
453
- | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:406-414`) |
450
+ | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:49-57`) |
451
+ | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:562`) |
452
+ | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:649-662`) |
453
+ | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:649-657`) |
454
454
  | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:156-158`) |
455
455
  | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
456
456
  | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
457
- | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:346`) |
457
+ | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:353`) |
458
458
 
459
459
  ## ⚡ Performance & mémoire
460
460
 
461
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:648`)
461
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:726`)
462
462
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
463
463
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
464
464
  vérification (hot path) vit chez le `JwtAuthenticator`.
465
465
  - **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
466
- simultanés (`tokenService.ts:175-181`) ; denylist mémoire bornée par purge amortie
466
+ simultanés (`tokenService.ts:188-194`) ; denylist mémoire bornée par purge amortie
467
467
  (`MemoryTokenStore.ts:331-341`).
468
468
  - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
469
469
 
package/docs/totp.md CHANGED
@@ -110,9 +110,9 @@ reste le second facteur **universel** — celui qui marche sans matériel dédi
110
110
 
111
111
  Trois partis pris, tous vérifiables dans le code.
112
112
 
113
- **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:71`)
113
+ **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:74`)
114
114
  résout au boot deux choses seulement : le **store** de secrets et la **clé de chiffrement**
115
- (`TotpService.#build()`, `totp.ts:89`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
115
+ (`TotpService.#build()`, `totp.ts:92`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
116
116
  fonctions sans I/O ni container (`totpOperations.ts`), qui reçoivent leurs dépendances en argument
117
117
  (`ITotpDeps`, `totpOperations.ts:22`). Conséquence directe : la logique critique se teste **sans
118
118
  serveur, sans base, avec une horloge injectée** — et c'est pour ça qu'elle est couverte à ~100 %.
@@ -125,14 +125,14 @@ différence de traitement, pas une négligence.
125
125
 
126
126
  **3. Le TOTP n'est pas un authenticator du firewall — c'est un step-up de login.** Il n'apparaît
127
127
  jamais dans `area.authenticators` : il s'insère **dans le flux de session BFF**, entre le mot de
128
- passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:254`). Même dessin que
128
+ passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:257`). Même dessin que
129
129
  WebAuthn et OAuth : le firewall n'a qu'un seul mécanisme à connaître, la **session**.
130
130
 
131
131
  > [!IMPORTANT]
132
132
  > Le couplage est fait **par nom de service**, jamais par import : `AuthFlow` ne connaît du 2FA
133
133
  > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:44`). 2FA désactivé
134
134
  > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
- > `authFlow.ts:455`).
135
+ > `authFlow.ts:458`).
136
136
 
137
137
  ## 🚀 Démarrage rapide
138
138
 
@@ -143,7 +143,7 @@ activer un second facteur depuis sa page « ma sécurité », et que le login l'
143
143
 
144
144
  Le secret TOTP est chiffré au repos → il faut une clé **stable** (sinon les secrets deviennent
145
145
  illisibles au redémarrage). La commande la génère et te dit exactement où la coller
146
- (`nodefony security:secrets`, `security-secrets.ts:36`) :
146
+ (`nodefony security:secrets`, `security-secrets.ts:79`) :
147
147
 
148
148
  ```bash
149
149
  npx nodefony security:secrets --write
@@ -154,7 +154,7 @@ npx nodefony security:secrets --write
154
154
  # 3. Fichier nodefony.config.ts — le câblage vers le module security
155
155
  ```
156
156
 
157
- Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:122`) et
157
+ Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:136`) et
158
158
  **n'écrase jamais** une valeur existante — une rotation reste un geste manuel et conscient.
159
159
 
160
160
  ### 2. Déclarer puis câbler la clé
@@ -301,7 +301,7 @@ Ce que le code garantit à chaque étape :
301
301
 
302
302
  > [!TIP]
303
303
  > Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
304
- > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:97`).
304
+ > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:78`).
305
305
  > La cause fine reste côté serveur.
306
306
 
307
307
  ### La vérification au login
@@ -333,13 +333,13 @@ sequenceDiagram
333
333
  A-->>U: 200 { user }
334
334
  ```
335
335
 
336
- Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:254`) :
336
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) :
337
337
 
338
338
  1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
339
339
  (`authFlow.ts:18`), posée par le login, **consommée** avant l'ouverture de session
340
- (`authFlow.ts:298`).
340
+ (`authFlow.ts:301`).
341
341
  2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
342
- (`AuthFlow.#resolveThrottler()`, `authFlow.ts:264`) : 10⁶ combinaisons se forcent brute en
342
+ (`AuthFlow.#resolveThrottler()`, `authFlow.ts:447`) : 10⁶ combinaisons se forcent brute en
343
343
  quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
344
344
  3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
345
345
  n'a pas à refaire son mot de passe.
@@ -403,13 +403,13 @@ Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecr
403
403
 
404
404
  ### La politique de clé — bruyante en dev, fail-closed en production
405
405
 
406
- `TotpService.#resolveKey()` (`totp.ts:204`) tranche au boot :
406
+ `TotpService.#resolveKey()` (`totp.ts:211`) tranche au boot :
407
407
 
408
408
  | Situation | Environnement | Comportement |
409
409
  | ---------------------------- | ------------- | ------------------------------------------------------------ |
410
- | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:207`). |
411
- | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:221`). |
412
- | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:212`). |
410
+ | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:214`). |
411
+ | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:228`). |
412
+ | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:220`). |
413
413
  | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
414
414
 
415
415
  Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
@@ -452,20 +452,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
452
452
 
453
453
  ## ⚙️ Configuration et mises en situation
454
454
 
455
- La section `totp` du schéma Zod (`config.ts:1138`) — validée au boot, donc une valeur hors bornes
455
+ La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
456
456
  échoue **au démarrage**, pas au premier login :
457
457
 
458
- | Option | Type | Défaut | Effet |
459
- | --------------- | ---------------------------- | ------ | ----------------------------------------------------------------- |
460
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:98`). |
461
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:537`). |
463
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:538`). |
466
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:546`). |
467
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:574`). |
468
- | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:580`). |
458
+ | Option | Type | Défaut | Effet |
459
+ | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------ |
460
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:101`). |
461
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
463
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
466
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
467
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
468
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
469
469
 
470
470
  ### Situation 1 — un utilisateur active la 2FA sur son compte
471
471
 
@@ -492,7 +492,7 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
492
492
  redemande le code : c'est le _sudo mode_.
493
493
 
494
494
  Nodefony fournit le step-up **de login** ; la re-vérification en cours de session, elle, se compose
495
- dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:262`) :
495
+ dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:269`) :
496
496
 
497
497
  ```typescript
498
498
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -557,28 +557,30 @@ dialecte via le colKit :
557
557
  | `period` | Durée d'un code (s) | `integer` | `integer` | `int` |
558
558
  | `recoveryCodes` | Condensats des codes **non consommés** | `text` (mode json) | `jsonb` | `json` |
559
559
  | `confirmedAt` | Activation (epoch ms) ou `null` = en attente | `integer` | `bigint` | `bigint` |
560
- | `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `integer` | `int` |
560
+ | `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `bigint` | `bigint` |
561
561
  | `createdAt` | Création (epoch ms) | `integer` | `bigint` | `bigint` |
562
562
  | `lastUsedAt` | Dernier usage réussi (epoch ms) ou `null` | `integer` | `bigint` | `bigint` |
563
563
 
564
564
  Deux pièges de lecture, signalés dans l'entité elle-même :
565
565
 
566
566
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
567
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:53`).
567
+ partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:61`).
568
568
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
569
569
  (SQLite, lui, a des INTEGER 64 bits).
570
570
 
571
- ### Les backends disponibles — et ceux qui manquent
571
+ ### Les backends disponibles
572
572
 
573
573
  | Backend | Enregistré par | Durabilité | État |
574
574
  | ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
575
575
  | `memory` | builtin (`totpSecretStoreRegistry.ts:54`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
576
- | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:279`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
577
- | `mongoose` | — | — | ⏳ manquant, à venir |
576
+ | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:320`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
577
+ | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
578
578
  | `redis` | — | — | ⏳ manquant, à venir |
579
579
 
580
- Ces deux absences sont des **manques**, pas des choix de périmètre —
581
- mais elles se comblent à deux régimes différents.
580
+ Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
581
+ application choisit son ORM, elle ne choisit pas de se passer du second facteur.
582
+
583
+ Reste une absence, et c'est un **manque**, pas un choix de périmètre.
582
584
 
583
585
  `redis` le portera **en opt-in explicite, jamais choisi par `auto`** — exactement le régime des
584
586
  passkeys qu'il porte déjà. Un secret TOTP est de la même famille qu'un credential passkey : une
@@ -587,20 +589,19 @@ dehors. Porter l'un et refuser l'autre au nom du « cache évincible » serait i
587
589
  est identique, et il est déjà assumé, avec son avertissement — sur Redis, la persistance devient la
588
590
  responsabilité de l'exploitant (AOF, pas d'éviction sur ces clés).
589
591
 
590
- `mongoose` le portera **au régime normal** : une application choisit son ORM, elle ne choisit pas de
591
- se passer du 2FA — l'objectif est de pouvoir tourner entièrement sur Mongo, sans drizzle. Aujourd'hui, une application MongoDB qui active le 2FA **retombe sur `memory`** (avec la
592
- raison annoncée dans les journaux de boot, et un avertissement en production) : ses secrets ne
593
- survivent pas au redémarrage, et ses utilisateurs se retrouvent verrouillés hors de leur second
594
- facteur. **En attendant** : charger `@nodefony/drizzle` à côté de Mongo — même en SQLite local — suffit
595
- à rendre le store durable, les deux modules cohabitent sans conflit.
592
+ Côté `mongoose`, le store est **100 % portable** lui aussi : tout passe par le contrat
593
+ `IRepository` et le helper `paginate()` d'orm-core, sans query native
594
+ (`MongooseTotpSecretStore.ts`). Le modèle « un secret par utilisateur » y est porté par la clé
595
+ primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document, sans index construit
596
+ en tâche de fond.
596
597
 
597
598
  Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
598
- (`registerStores.ts:92`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
599
- tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:38`)
599
+ (`registerStores.ts:94`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
600
+ tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:44`)
600
601
  — c'est ce qui rend la portabilité gratuite.
601
602
 
602
603
  **Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
603
- adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:134`) :
604
+ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
604
605
 
605
606
  1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
606
607
  2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
@@ -608,7 +609,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:134`) :
608
609
  4. sinon **repli `memory`, annoncé** — jamais silencieux.
609
610
 
610
611
  Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
611
- production (`totp.ts:167`). Une faute de frappe ne dégrade jamais la sécurité en douce.
612
+ production (`totp.ts:174`). Une faute de frappe ne dégrade jamais la sécurité en douce.
612
613
 
613
614
  ## Le listing paginé des enrôlements
614
615
 
@@ -621,14 +622,14 @@ d'un seul utilisateur.
621
622
  - **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
622
623
  (`createdAt` DESC, départagé par `userId` ASC) ;
623
624
  - **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
624
- donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:153`) ;
625
+ donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:176`) ;
625
626
  - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
626
627
  n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
627
628
  (`recoveryCodesLeft`).
628
629
 
629
630
  Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
630
631
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
631
- (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:18`). C'est ce qu'exerce le banc de contrat partagé.
632
+ (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
632
633
 
633
634
  `countEnrollments()` (`ITotpSecretStore.ts:90`) donne le KPI de couverture sans énumérer une seule
634
635
  ligne.
@@ -637,23 +638,23 @@ ligne.
637
638
 
638
639
  Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
639
640
 
640
- **Le service** (`TotpService`, `totp.ts:71`), résolu par nom dans le container (`"totp"`) :
641
+ **Le service** (`TotpService`, `totp.ts:74`), résolu par nom dans le container (`"totp"`) :
641
642
 
642
- | Méthode | Rôle |
643
- | ----------------------------------- | ------------------------------------------------------------ |
644
- | `isEnabled()` (`totp.ts:247`) | 2FA opérationnel (activé en config **et** boot réussi). |
645
- | `beginEnrollment()` (`totp.ts:252`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
646
- | `confirmEnrollment()` (`:257`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
647
- | `verifyLogin()` (`totp.ts:262`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
648
- | `disable()` (`totp.ts:267`) | Retire secret et codes. |
649
- | `status()` (`totp.ts:272`) | `{ enabled, pending, recoveryCodesRemaining }`. |
650
- | `isEnabledFor()` (`totp.ts:299`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
651
- | `listPage()` (`totp.ts:283`) | Page d'enrôlements (data plane admin). |
652
- | `countEnrollments()` (`:294`) | Compte filtré, sans énumération. |
643
+ | Méthode | Rôle |
644
+ | ------------------------------------- | ------------------------------------------------------------ |
645
+ | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
646
+ | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
647
+ | `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
648
+ | `verifyLogin()` (`totp.ts:269`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
649
+ | `disable()` (`totp.ts:274`) | Retire secret et codes. |
650
+ | `status()` (`totp.ts:279`) | `{ enabled, pending, recoveryCodesRemaining }`. |
651
+ | `isEnabledFor()` (`totp.ts:306`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
652
+ | `listPage()` (`totp.ts:290`) | Page d'enrôlements (data plane admin). |
653
+ | `countEnrollments()` (`totp.ts:301`) | Compte filtré, sans énumération. |
653
654
 
654
655
  **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
655
656
  elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
656
- `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:72`).
657
+ `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:200`).
657
658
 
658
659
  **Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
659
660
  (`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
@@ -698,7 +699,7 @@ vérifie que ta projection n'expose ni secret ni condensat.
698
699
  | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
699
700
  | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
700
701
  | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
701
- | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:264`) |
702
+ | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) |
702
703
  | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:145`) |
703
704
 
704
705
  Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
@@ -708,18 +709,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
708
709
 
709
710
  Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
710
711
 
711
- - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:455`)
712
+ - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:458`)
712
713
  résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
713
714
  **zéro accès au store**, zéro allocation par login.
714
715
  - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
715
716
  jamais dans le pipeline HTTP/WS.
716
717
  - **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
717
- (`MemoryTotpSecretStore.ts:62`) n'existe que si le 2FA est activé — le service ne construit rien
718
- quand `totp.enabled` est `false` (`totp.ts:98`).
718
+ (`MemoryTotpSecretStore.ts:74`) n'existe que si le 2FA est activé — le service ne construit rien
719
+ quand `totp.enabled` est `false` (`totp.ts:101`).
719
720
  - **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
720
721
  AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
721
722
  qui l'a précédé.
722
- - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:234`)
723
+ - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:241`)
723
724
  le déclenche à `onTerminate` — aucune écriture en attente perdue.
724
725
 
725
726
  ## 📡 Observabilité — Studio
@@ -733,34 +734,34 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
733
734
 
734
735
  Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
735
736
 
736
- - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:606`).
737
+ - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:636`).
737
738
  Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
738
739
  la console doit pouvoir afficher « 2FA désactivé ».
739
- - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:658`).
740
+ - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:686`).
740
741
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
741
- (`SecurityAdminApi.ts:683`).
742
+ (`SecurityAdminApi.ts:711`).
742
743
 
743
744
  L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
744
745
  l'utilisateur, lui seul peut l'armer.
745
746
 
746
747
  Côté journal d'audit, quatre actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
747
- `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:299`), `login.failure` avec
748
+ `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
748
749
  `reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
749
- (`SecurityAdminApi.ts:707`).
750
+ (`SecurityAdminApi.ts:735`).
750
751
 
751
752
  ## ⚠️ Pièges (symptôme → cause → correction)
752
753
 
753
754
  | Symptôme | Cause (dans le code) | Correction |
754
755
  | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
755
- | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:212`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
756
+ | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:220`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
756
757
  | Tous les secrets illisibles après déploiement | Clé éphémère (dev) ou `TOTP_DERIVATION` modifié | Clé **stable** partagée ; ne jamais toucher au contexte HKDF |
757
758
  | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
758
759
  | Code « juste » systématiquement refusé | Horloge décalée de plus d'un pas (fenêtre = ±30 s) | Synchroniser NTP serveur **et** téléphone |
759
760
  | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
760
761
  | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
761
762
  | `401` sur `…/auth/me` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts:170`) | Terminer le step-up |
762
- | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:264`) | Respecter `Retry-After` — attendu sous attaque |
763
- | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:128`) | Vérifier `totp.enabled` + la clé + les logs de boot |
763
+ | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) | Respecter `Retry-After` — attendu sous attaque |
764
+ | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
764
765
  | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
765
766
  | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
766
767
  | Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts:186`) | Régénérer un lot en ré-enrôlant si le stock est bas |