@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.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.
Files changed (74) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/nodefony/command/security-token.js +4 -4
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/service/apiKeys.js +2 -2
  9. package/dist/nodefony/service/auditService.js +3 -3
  10. package/dist/nodefony/service/authFlow.js +2 -2
  11. package/dist/nodefony/service/authorization.js +3 -4
  12. package/dist/nodefony/service/firewall.js +21 -9
  13. package/dist/nodefony/service/oauth2.js +5 -3
  14. package/dist/nodefony/service/securityHeaders.js +13 -2
  15. package/dist/nodefony/service/tokenService.js +27 -18
  16. package/dist/nodefony/service/webAuthn.js +38 -31
  17. package/dist/nodefony/service/webhooks.js +50 -41
  18. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  19. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  20. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  21. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  22. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  23. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  24. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  25. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  26. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  27. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  28. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  29. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  30. package/dist/nodefony/src/csp.js +4 -5
  31. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  32. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  33. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  34. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
  35. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  36. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  37. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  38. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  39. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
  40. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  41. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  42. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  43. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  44. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  45. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  46. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  47. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  48. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  49. package/dist/types/nodefony/service/securityHeaders.d.ts +7 -6
  50. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  51. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  52. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  53. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  54. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  55. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  56. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  57. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  58. package/docs/api-keys.md +16 -16
  59. package/docs/audit.md +27 -20
  60. package/docs/authenticators.md +8 -8
  61. package/docs/authorization.md +15 -15
  62. package/docs/cors.md +7 -7
  63. package/docs/csrf.md +8 -8
  64. package/docs/external-jwt.md +19 -10
  65. package/docs/firewall.md +28 -28
  66. package/docs/headers.md +20 -20
  67. package/docs/index.md +6 -4
  68. package/docs/lexique.md +15 -15
  69. package/docs/oauth2.md +23 -20
  70. package/docs/tokens.md +11 -10
  71. package/docs/totp.md +15 -14
  72. package/docs/webauthn.md +51 -54
  73. package/docs/webhooks.md +27 -30
  74. package/package.json +13 -13
package/docs/firewall.md CHANGED
@@ -52,8 +52,8 @@ flowchart TD
52
52
  AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
53
53
  ```
54
54
 
55
- `Firewall.isSecure()` (`firewall.ts:721`) rattache la requête à une **zone** via
56
- `Firewall.matchPath()` (`firewall.ts:712`) ; `Firewall.handleSecurity()` (`firewall.ts:754`) décide.
55
+ `Firewall.isSecure()` (`firewall.ts:728`) rattache la requête à une **zone** via
56
+ `Firewall.matchPath()` (`firewall.ts:719`) ; `Firewall.handleSecurity()` (`firewall.ts:761`) décide.
57
57
  Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
58
58
  le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
59
59
 
@@ -73,11 +73,11 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
73
73
 
74
74
  ### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
75
75
 
76
- Le scaffold déclare deux zones dans `nodefony.config.ts` — c'est la forme canonique (un **objet par
76
+ Le scaffold déclare trois zones (`main`, `secure`, `machine`) dans `nodefony/config/security.ts`, importé par `nodefony.config.ts` ; en voici deux — c'est la forme canonique (un **objet par
77
77
  nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
78
78
 
79
79
  ```typescript
80
- // nodefony.config.ts (extrait généré par `nodefony create app`)
80
+ // nodefony/config/security.ts (extrait généré par `nodefony create app`)
81
81
  use("@nodefony/security", {
82
82
  areas: {
83
83
  // Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
@@ -151,7 +151,7 @@ curl -si http://localhost:5151/api/secure/account/me | head -1
151
151
 
152
152
  # 2) Login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
153
153
  curl -si -c /tmp/jar -H 'Content-Type: application/json' \
154
- -d '{"username":"admin","password":"admin"}' \
154
+ -d '{"username":"admin","password":"nodefony-dev-42"}' \
155
155
  http://localhost:5151/nodefony/security/api/auth/login | head -1
156
156
  # HTTP/1.1 200 OK
157
157
 
@@ -280,7 +280,7 @@ Sans lui, zone protégée + aucune preuve = 401.
280
280
 
281
281
  Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
282
282
  porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
283
- handshake des zones protégées `realtime` (`firewall.ts:300`).
283
+ handshake des zones protégées `realtime` (`firewall.ts:279`).
284
284
 
285
285
  - **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
286
286
  l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
@@ -377,7 +377,7 @@ Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte
377
377
 
378
378
  > [!TIP]
379
379
  > Un nom d'authenticator inconnu en config **fait échouer le boot** —
380
- > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:429`) : jamais de zone
380
+ > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:435`) : jamais de zone
381
381
  > « protégée » silencieusement ouverte à cause d'une faute de frappe.
382
382
 
383
383
  ## 🧑‍⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
@@ -463,12 +463,12 @@ inchangé : la zone n'exige qu'une identité.
463
463
  C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
464
464
  explicite du code — jamais un contournement :
465
465
 
466
- | Ce qui décide à la place | Pourquoi |
467
- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
468
- | Une **garde d'action** (`@IsGranted`, `@RequireScope`) | L'action a déjà dit ce qu'elle exige. Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
- | Une route **`selfGuarded`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
- | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
- | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
466
+ | Ce qui décide à la place | Pourquoi |
467
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
468
+ | Une **garde de rôle** (`@IsGranted`) | L'action a déjà dit quel rôle elle exige (un `@RequireScope` seul ne dispense pas du rôle de zone). Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
+ | Une route **`areaRoleExempt`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
+ | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
+ | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
472
472
 
473
473
  Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
474
474
  une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
@@ -477,7 +477,7 @@ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rô
477
477
  puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
478
478
  destinataire.
479
479
 
480
- > ⚠️ **`selfGuarded` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
480
+ > ⚠️ **`areaRoleExempt` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
481
481
  > qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
482
482
  > que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
483
483
  > Dans le doute, une garde d'action explicite est toujours préférable.
@@ -505,7 +505,7 @@ registerVoterFactory(
505
505
  // l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
506
506
  ```
507
507
 
508
- Le voter est **découvert automatiquement** par l'`AuthorizationService` — aucun changement dans le
508
+ Le voter est **découvert automatiquement** par le service `authorization` — aucun changement dans le
509
509
  cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
510
510
  `@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
511
511
  **est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
@@ -517,7 +517,7 @@ scopes, métier), un même jury, combinables.
517
517
  (opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:300`)
518
518
  **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:348`). Même résolution de zone que HTTP.
519
519
  Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
520
- `firewall.ts:1207`) : le **code de fermeture** suffit.
520
+ `firewall.ts:1216`) : le **code de fermeture** suffit.
521
521
 
522
522
  ## 🛡️ En-têtes de sécurité, CSRF, CORS
523
523
 
@@ -527,13 +527,13 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
527
527
  - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
528
528
  (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:971`), puis double-submit `x-csrf-token` ≡
529
529
  cookie + HMAC (`firewall.ts:778`).
530
- - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
530
+ - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1014`).
531
531
 
532
532
  ## 📜 Normes appliquées
533
533
 
534
534
  | Domaine | Norme | Ancrage |
535
535
  | ---------------------- | --------------- | ------------------------------------------------------ |
536
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
536
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
537
537
  | Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
538
538
  | JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
539
539
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
@@ -553,16 +553,16 @@ ne paie quasiment rien.
553
553
 
554
554
  ## ⚠️ Pièges (symptôme → cause → correction)
555
555
 
556
- | Symptôme | Cause (dans le code) | Correction |
557
- | ---------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
558
- | Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
559
- | Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
560
- | 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
561
- | Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
562
- | API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
563
- | JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
564
- | WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet à la reconnexion ; pour l'immédiat, canal JWT (J4) |
565
- | 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
556
+ | Symptôme | Cause (dans le code) | Correction |
557
+ | ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
558
+ | Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
559
+ | Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
560
+ | 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
561
+ | Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
562
+ | API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
563
+ | JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
564
+ | WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet en une fenêtre (tick du hub + avant chaque `api.request`, fermeture 4001) |
565
+ | 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
566
566
 
567
567
  ## 📡 Observabilité — Studio
568
568
 
package/docs/headers.md CHANGED
@@ -122,10 +122,10 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
122
122
  statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
123
123
  se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
124
124
  - Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
125
- (`http-kernel.ts:335`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
- boot ; `onHttpRequest` (`http-kernel.ts:949`) ne fait plus que trois `setHeader`.
125
+ (`http-kernel.ts:358`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
+ boot ; `onHttpRequest` (`http-kernel.ts:1009`) ne fait plus que trois `setHeader`.
127
127
  - Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
128
- (`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) qu'à la première lecture,
128
+ (`Context.ts:253`) ne tire ses 128 bits (16 octets CSPRNG, en base64) d'un pool amorti (`randomFillSync`, 4 Ko pour 256 nonces) qu'à la première lecture,
129
129
  puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
130
130
 
131
131
  Le second parti pris est la **séparation d'autorité** décrite plus haut : un seul émetteur par
@@ -275,7 +275,7 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
275
275
  victime est capté par ton interface.
276
276
 
277
277
  Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
278
- (`http-kernel.ts:267`) — et configuré côté `@nodefony/http` avec `frameOptions`
278
+ (`http-kernel.ts:1049`) — et configuré côté `@nodefony/http` avec `frameOptions`
279
279
  (`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
280
280
  s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
281
281
  pour un HTML statique servi directement depuis `public/`.
@@ -287,7 +287,7 @@ pour un HTML statique servi directement depuis `public/`.
287
287
  avec tes cookies.
288
288
 
289
289
  Valeur unique reconnue : `nosniff`, posée depuis le cache `secContentTypeOptions`
290
- (`http-kernel.ts:963`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
290
+ (`http-kernel.ts:1046`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
291
291
  vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404
292
292
  (`security-headers.test.ts:38`).
293
293
 
@@ -297,10 +297,10 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
297
297
  installer un intercepteur.
298
298
 
299
299
  La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
300
- (`http-kernel.ts:335`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
300
+ (`http-kernel.ts:358`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
301
301
 
302
302
  Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
303
- de serveur (`http-kernel.ts:969`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
303
+ de serveur (`http-kernel.ts:1052`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
304
304
  soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
305
305
  inclus.
306
306
 
@@ -397,7 +397,7 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
397
397
  ### Socle transport — `use("@nodefony/http", { securityHeaders })`
398
398
 
399
399
  Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
400
- **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:385`)
400
+ **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:408`)
401
401
  recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
402
402
 
403
403
  | Option | Type | Défaut | Effet |
@@ -411,7 +411,7 @@ recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
411
411
 
412
412
  > [!WARNING]
413
413
  > Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
414
- > (`config.ts:212`, `config.ts:223`, `config.ts:247`, `config.ts:257`) mais **ne pilotent rien** : la couche
414
+ > (`config.ts:257`, `config.ts:257`, `config.ts:257`, `config.ts:257`) mais **ne pilotent rien** : la couche
415
415
  > applicative ne les lit pas (`securityHeaders.ts:6`), elles ne servent qu'à l'introspection
416
416
  > affichée dans Studio. Pour changer réellement `X-Frame-Options`, c'est `securityHeaders.frameOptions`
417
417
  > **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:282`) : Nodefony n'émet aucun
@@ -446,13 +446,13 @@ Le chemin complet, sans surprise :
446
446
  `<script nonce="…">` portent forcément la même valeur. C'est le motif employé par le contrôleur
447
447
  de Studio (`StudioController.ts:62`).
448
448
 
449
- `SecurityHeaders.hasNonce` (`securityHeaders.ts:88`) est le drapeau qui décide du régime : à `false`,
449
+ `SecurityHeaders.hasNonce` (`securityHeaders.ts:100`) est le drapeau qui décide du régime : à `false`,
450
450
  pas une seule opération crypto. Il n'y a **aucun setter** pour `cspNonce` : un jeton serveur doit
451
451
  rester imprévisible, jamais pilotable par le client — contrairement au `requestId`, qui, lui, accepte
452
452
  une corrélation entrante.
453
453
 
454
454
  **Placement dans le pipeline** : `applySecurityHeaders` est appelé **après le resolve** et **avant**
455
- le repli statique et le `writeHead` (`http-kernel.ts:1386`). Cet ordre n'est pas cosmétique : il
455
+ le repli statique et le `writeHead` (`http-kernel.ts:1624`). Cet ordre n'est pas cosmétique : il
456
456
  faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
457
457
  être avant l'écriture des en-têtes pour pouvoir en poser.
458
458
 
@@ -478,7 +478,7 @@ les tests fiables.
478
478
  **Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
479
479
  (`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1101`), jamais par requête. Le merge d'une route
480
480
  `@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
481
- `securityHeaders.ts:115`) ; le cas courant reste le simple `join`.
481
+ `securityHeaders.ts:131`) ; le cas courant reste le simple `join`.
482
482
 
483
483
  ## 🧩 Extension — déclarer un fragment CSP depuis son module
484
484
 
@@ -502,10 +502,10 @@ Trois propriétés à retenir :
502
502
 
503
503
  - **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
504
504
  `registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
505
- - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1090`) retire le fragment et
505
+ - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1099`) retire le fragment et
506
506
  reconstruit le CSP de base. C'est ce que fait `@nodefony/frontend` à l'arrêt du serveur Vite.
507
507
  - **Idempotent** : la reconstruction repart **toujours** du `headers.csp` d'origine
508
- (`firewall.ts:1104`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
508
+ (`firewall.ts:1113`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
509
509
  enregistrements.
510
510
 
511
511
  L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
@@ -523,8 +523,8 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
523
523
  | Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
524
524
  | Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:267`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
- | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
527
- | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:335`) |
526
+ | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1046`) |
527
+ | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:358`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
530
530
 
@@ -533,13 +533,13 @@ Le coût est concentré au boot, par construction :
533
533
  - **En-têtes constants** : une seule table, gelée (`securityHeaders.ts:77`). Par requête, une boucle
534
534
  `for…in` sur un objet de 1 à 6 entrées et autant de `setHeader`. Aucune allocation.
535
535
  - **CSP statique** : rien de plus — la chaîne est dans la table.
536
- - **CSP à nonce** : `randomBytes(16)` plus un `join` par requête. C'est le seul coût variable, et il
537
- n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:88`)
536
+ - **CSP à nonce** : un tirage dans le pool plus une concaténation `préfixe + nonce + suffixe` par requête. C'est le seul coût variable, et il
537
+ n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:100`)
538
538
  court-circuite entièrement ce chemin sinon. La paresse de `Context.cspNonce` (`Context.ts:253`)
539
539
  protège en plus les chemins internes qui n'atteignent jamais le firewall.
540
540
  - **Merge CSP** : jamais dans le chemin chaud. Le fragment d'un module est fusionné à
541
541
  l'enregistrement (`firewall.ts:1086`) ; celui d'une route ne coûte que sur les routes `@Csp`.
542
- - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:958-966`), avec
542
+ - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:1047-1053`), avec
543
543
  un test `!== null` qui annule le coût des en-têtes désactivés.
544
544
 
545
545
  Le module n'attache aucun écouteur d'événement et ne conserve aucun état par requête : il n'entre pas
@@ -550,7 +550,7 @@ dans le périmètre du gate mémoire, qu'il ne peut structurellement pas dégrad
550
550
  L'écran **Firewall** de Studio affiche la section « En-têtes de sécurité » — pilotée par
551
551
  `headers.enabled` (`FirewallDefenses.tsx:219`) — avec le CSP effectif, l'état du nonce par requête, la
552
552
  Referrer-Policy et les valeurs d'isolation. Les données
553
- viennent de `Firewall.describe()` (`firewall.ts:549`), qui projette la config **sans aucun secret**,
553
+ viennent de `Firewall.describe()` (`firewall.ts:555`), qui projette la config **sans aucun secret**,
554
554
  exposée par `GET /nodefony/security/api/firewall`.
555
555
 
556
556
  L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Zod — chaque champ y
package/docs/index.md CHANGED
@@ -43,7 +43,7 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
43
43
  **Je découvre la sécurité Nodefony** — comprendre le modèle avant de configurer quoi que ce soit.
44
44
 
45
45
  1. [Firewall](firewall.md) — zones, Zero Trust, la chaîne de décision. **Tout part d'ici.**
46
- 2. [Authenticators](authenticators.md) — les six façons de prouver **qui** appelle.
46
+ 2. [Authenticators](authenticators.md) — les sept façons de prouver **qui** appelle.
47
47
  3. [Autorisation](authorization.md) — rôles, scopes, voters : **ce qu'il a le droit** de faire.
48
48
  4. [Jetons](tokens.md) — ce qui matérialise une identité prouvée, et comment on la révoque.
49
49
 
@@ -98,7 +98,7 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
98
98
  "desc": "Le pare-feu applicatif : trois questions pour chaque requête — est-ce une zone protégée ? qui es-tu ? as-tu le droit ? Le chemin chaud (détecter) est séparé du chemin froid (décider), donc une route publique ne paie rien.",
99
99
  "meta": "à lire en premier — toutes les autres briques s'y branchent" },
100
100
  { "icon": "🪪", "title": "authenticators", "href": "authenticators.md",
101
- "desc": "Prouver qui appelle : six stratégies au même contrat — session, userpassword, jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
101
+ "desc": "Prouver qui appelle : sept stratégies au même contrat — session, userpassword, jwt, external-jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
102
102
  "meta": "commence par « Ordre et modes » : les pièges de configuration sont là" },
103
103
  { "icon": "⚖️", "title": "authorization", "href": "authorization.md",
104
104
  "desc": "Décider des droits : rôles hiérarchisés, scopes, et voters métier qui portent le vrai pouvoir applicatif. Le jury vote, la stratégie tranche.",
@@ -126,7 +126,9 @@ flowchart LR
126
126
  ```
127
127
 
128
128
  Le module consomme `@nodefony/user` (jamais l'inverse) et n'importe `@nodefony/http` / `framework`
129
- qu'en **type-only** — le couplage runtime resterait une dette.
129
+ qu'en **type-only**, à une exception près : `schemaMismatchOf` (`webhooks.ts:49`), importé de
130
+ `@nodefony/http` pour reconnaître une base au schéma en retard avec la même règle que le cœur HTTP,
131
+ plutôt qu'avec une copie.
130
132
 
131
133
  ## 🧰 Surface publique
132
134
 
@@ -165,7 +167,7 @@ un mot de passe parce qu'il est perdu ou compromis.
165
167
  ## ⚙️ Configuration
166
168
 
167
169
  Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
168
- au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `loginThrottle`
170
+ au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `rateLimit`
169
171
  (backoff NIST), puis un bloc par brique (`jwt`, `apiKeys`, `totp`, `webhooks`, `audit`, `passkeys`,
170
172
  `oauth2`). **Chaque page de brique détaille son bloc**, avec une table dérivée du schéma.
171
173
 
package/docs/lexique.md CHANGED
@@ -126,21 +126,21 @@ source: "src/packages/@nodefony/security/docs/lexique.md"
126
126
  > L'**autorisation** (authz) répond « as-tu le DROIT de faire ça ? » (les voters, `@IsGranted`).
127
127
  > On peut être authentifié ET refusé (un `user` connecté sur une route `ROLE_ADMIN` → **403**, pas 401).
128
128
 
129
- | Sigle / terme | Développé | En clair |
130
- | ----------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
- | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
- | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
- | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
- | **`AuthorizationService.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
- | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
- | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
- | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
- | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
- | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
- | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
- | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
- | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
129
+ | Sigle / terme | Développé | En clair |
130
+ | ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
+ | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
+ | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
+ | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
+ | **`Authorization.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
+ | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
+ | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
+ | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
+ | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
+ | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
+ | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
+ | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
+ | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
144
144
 
145
145
  ### Décorateurs sécurité (panoplie J7)
146
146
 
package/docs/oauth2.md CHANGED
@@ -144,12 +144,13 @@ d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewal
144
144
  c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
145
145
 
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
- dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
147
+ dans le module (`OAuth2Client`, `oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
148
148
  l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
149
- mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:290`) : c'est là, une seule fois par
150
- processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
151
- que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
152
- est **404**, pas « désactivée ».
149
+ mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
150
+ processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes sont montées dès
151
+ que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
152
+ `…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
153
+ rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
153
154
 
154
155
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
155
156
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
@@ -215,8 +216,8 @@ export default defineConfig<typeof env>((ctx) => ({
215
216
  ### Les routes sont FOURNIES — tu n'écris aucun controller
216
217
 
217
218
  `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
218
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`), et **seulement si** le service `oauth2`
219
- est présent (`framework/index.ts:460`) :
219
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
220
+ enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
220
221
 
221
222
  | Route | Rôle |
222
223
  | ---------------------------- | --------------------------------------------------------------------- |
@@ -287,7 +288,7 @@ défense :
287
288
  1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
288
289
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
289
290
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
290
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:174-182`). Un seul écart → `302` vers
291
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
291
292
  `failureRedirect`, **sans jamais contacter le fournisseur** ;
292
293
  3. seulement ensuite, `exchangeAndProvision()`.
293
294
 
@@ -488,14 +489,14 @@ que le mapping du profil. Exemple sans réseau dans le dépôt :
488
489
  Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
489
490
  (`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
490
491
 
491
- | Option | Type | Défaut | Effet |
492
- | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
493
- | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
494
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
495
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
496
- | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
497
- | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
498
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
492
+ | Option | Type | Défaut | Effet |
493
+ | ----------------- | -------------------- | --------------- | ----------------------------------------------------------- |
494
+ | `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
495
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
496
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
497
+ | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
498
+ | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
499
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
499
500
 
500
501
  Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
501
502
 
@@ -608,15 +609,17 @@ session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l
608
609
  provisionné dans l'écran **Users**, avec ses rôles réels.
609
610
 
610
611
  > [!NOTE]
611
- > L'événement d'audit du login social porte la raison par défaut `federated`
612
- > (`authFlow.ts:218`) : le controller n'affine pas le facteur. Pour distinguer OAuth de WebAuthn dans
613
- > un filtre d'audit, s'appuyer sur le contexte de la requête plutôt que sur cette seule valeur.
612
+ > L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
613
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:194`), comme WebAuthn passe `webauthn`.
614
+ > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
615
+ > (`authFlow.ts:215`).
614
616
 
615
617
  ## ⚠️ Pièges (symptôme → cause → correction)
616
618
 
617
619
  | Symptôme | Cause (dans le code) | Correction |
618
620
  | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
619
- | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
621
+ | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
622
+ | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
620
623
  | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
621
624
  | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
622
625
  | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
package/docs/tokens.md CHANGED
@@ -25,7 +25,7 @@ tags:
25
25
  ]
26
26
  version: "doc"
27
27
  status: stable
28
- updated: 2026-07-19
28
+ updated: 2026-10-02
29
29
  source: "src/packages/@nodefony/security/docs/tokens.md"
30
30
  ---
31
31
 
@@ -142,8 +142,9 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
143
143
 
144
144
  > [!IMPORTANT]
145
- > Ces routes n'existent **que si** le service `tokenService` est présent — sinon 404, zéro surface
146
- > (`TokenAuthController.ts:46-48`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
145
+ > Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
146
+ > (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
147
148
  > elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
148
149
  > authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
149
150
 
@@ -250,9 +251,9 @@ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`).
250
251
  1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
251
252
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
252
253
  famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
253
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:594`).
254
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts:711`).
254
255
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:752`).
256
+ `#resolveUserForRefresh()` (`tokenService.ts:770`).
256
257
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
257
258
  (`tokenService.ts:600`).
258
259
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
@@ -359,7 +360,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
359
360
  ### `redis` — cluster, TTL natif
360
361
 
361
362
  - 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
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:41`) — l'expiration ne
363
364
  dépend pas du gc.
364
365
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
365
366
  (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
@@ -390,7 +391,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
390
391
  - `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
391
392
  (`ITokenStore.ts:222`).
392
393
  - Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
393
- (`apiKeys.ts:216`), jamais un listAll matérialisé.
394
+ (`apiKeys.ts:209`), jamais un listAll matérialisé.
394
395
 
395
396
  ### Révoquer — trois portées
396
397
 
@@ -429,7 +430,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
429
430
  | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
430
431
  | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
431
432
  | `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:390`) |
433
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:396`) |
433
434
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
434
435
  | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
435
436
  | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
@@ -458,7 +459,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
458
459
 
459
460
  ## ⚡ Performance & mémoire
460
461
 
461
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:726`)
462
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
462
463
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
463
464
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
464
465
  vérification (hot path) vit chez le `JwtAuthenticator`.
@@ -479,7 +480,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
479
480
 
480
481
  | Symptôme | Cause (dans le code) | Correction |
481
482
  | --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
482
- | 404 sur `/nodefony/security/api/token` | Routes montées seulement si `tokenService` existe | Charger `@nodefony/security` + `jwt.enabled: true` |
483
+ | 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
483
484
  | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
484
485
  | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
485
486
  | JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
package/docs/totp.md CHANGED
@@ -190,8 +190,9 @@ export default defineConfig<typeof env>((ctx) => ({
190
190
 
191
191
  ### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
192
192
 
193
- `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service, **et seulement si**
194
- le service `totp` existe (security chargé + 2FA activé) — sinon zéro surface, 404 :
193
+ `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service dès que
194
+ `@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
195
+ service non initialisé : elles répondent `503 2FA unavailable` :
195
196
 
196
197
  | Route | Corps | Réponse |
197
198
  | ------------------------------------------ | ---------- | ---------------------------------------------- |
@@ -211,7 +212,7 @@ Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
211
212
  > [!WARNING]
212
213
  > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
213
214
  > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
214
- > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:138`) — jamais un
215
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:142`) — jamais un
215
216
  > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
216
217
 
217
218
  ### 4. Ce qu'on observe
@@ -455,17 +456,17 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
455
456
  La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
456
457
  échoue **au démarrage**, pas au premier login :
457
458
 
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`). |
459
+ | Option | Type | Défaut | Effet |
460
+ | --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
461
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
462
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
463
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
464
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
465
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
466
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
467
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
468
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
469
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
469
470
 
470
471
  ### Situation 1 — un utilisateur active la 2FA sur son compte
471
472