@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3

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 (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. package/package.json +17 -15
package/docs/cors.md CHANGED
@@ -103,13 +103,13 @@ Trois conséquences que beaucoup de développeurs découvrent trop tard :
103
103
  `Cors` (`cors.ts:33`) est une classe **pure et synchrone** : elle ne touche ni au réseau, ni à la
104
104
  requête — elle prend une origine et renvoie la table des en-têtes à poser, ou `null`. Elle est
105
105
  instanciée **une seule fois au boot** par le firewall, si et seulement si la section est activée
106
- (`firewall.ts:239-240`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
106
+ (`firewall.ts:240-241`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
107
107
  requête se réduit à une lecture de `Set` (voir Performance).
108
108
 
109
109
  Trois invariants de sécurité, tenus par construction :
110
110
 
111
111
  - **Jamais `*` avec credentials.** La combinaison est **rejetée au boot** par un `refine` Zod
112
- (`config.ts:156-159`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
112
+ (`config.ts:162-165`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
113
113
  instanciée à la main avec cette combinaison, `Cors.#allowOrigin()` **reflète l'origine** au lieu
114
114
  d'émettre `*` (`cors.ts:58`).
115
115
  - **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
@@ -204,8 +204,8 @@ curl -si http://localhost:5151/api/articles -H 'Origin: https://evil.com'
204
204
 
205
205
  ## ⚙️ Configuration et mises en situation
206
206
 
207
- La section `cors` de la config du module (`corsSchema`, `config.ts:129` ; branchée à la racine en
208
- `config.ts:1137`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
207
+ La section `cors` de la config du module (`corsSchema`, `config.ts:179` ; branchée à la racine en
208
+ `config.ts:1143`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
209
209
 
210
210
  <!-- prettier-ignore -->
211
211
  | Option | Type | Défaut | Effet |
@@ -242,7 +242,7 @@ cors: {
242
242
  | `Origin: https://app.example.com:8443` | rien — le **port** fait partie de l'origine, match exact |
243
243
  | `Origin: https://sub.app.example.com` | rien — un sous-domaine n'est **pas** couvert par le parent |
244
244
  | `Origin: http://app.example.com` | rien — le **scheme** fait partie de l'origine (downgrade refusé) |
245
- | aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:1013`) |
245
+ | aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:1019`) |
246
246
 
247
247
  ### Situation 2 — une API publique en lecture seule (le joker `*`)
248
248
 
@@ -273,7 +273,7 @@ cors: { origins: ["https://app.example.com", "https://admin.example.com"], crede
273
273
 
274
274
  > [!WARNING]
275
275
  > `origins: ["*"]` **avec** `credentials: true` est rejeté par le schéma Zod au démarrage
276
- > (`config.ts:156-159`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
276
+ > (`config.ts:194-197`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
277
277
  > combinaison, si un serveur la contournait en reflétant chaque origine, laisserait **tout site du
278
278
  > web** lire les réponses authentifiées de tes utilisateurs. Ne « corrige » jamais cette erreur en
279
279
  > reflétant l'origine reçue sans la valider.
@@ -322,23 +322,23 @@ sequenceDiagram
322
322
  R-->>B: 200 + Access-Control-*
323
323
  ```
324
324
 
325
- `Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
- (`http-kernel.ts:1456`), à la ligne `http-kernel.ts:1456` — **avant le routing**. La raison est
325
+ `Firewall.handleCors()` (`firewall.ts:1098`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
+ (`http-kernel.ts:1553`), à la ligne `http-kernel.ts:1553` — **avant le routing**. La raison est
327
327
  concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
328
328
  router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
329
329
  credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
330
330
 
331
- Quatre sorties en no-op, dans cet ordre (`firewall.ts:1007`) :
331
+ Quatre sorties en no-op, dans cet ordre (`firewall.ts:1013`) :
332
332
 
333
333
  1. CORS désactivé ⇒ `#cors` est `null`, retour immédiat ;
334
- 2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:1013`) ;
334
+ 2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:1019`) ;
335
335
  3. la réponse n'expose pas `setHeader` ⇒ c'est un **WebSocket**, il n'y a pas d'en-tête HTTP à poser
336
- (`firewall.ts:83`) ;
336
+ (`firewall.ts:94`) ;
337
337
  4. origine hors allowlist ⇒ la table est `null`, aucun en-tête n'est posé — mais un preflight reste
338
- court-circuité en 204 (`firewall.ts:1032`).
338
+ court-circuité en 204 (`firewall.ts:1038`).
339
339
 
340
340
  **La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
341
- `Access-Control-Request-Method` (`firewall.ts:1018-1020`). Un `OPTIONS` nu — celui d'un client qui interroge
341
+ `Access-Control-Request-Method` (`firewall.ts:1054-1056`). Un `OPTIONS` nu — celui d'un client qui interroge
342
342
  les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
343
343
 
344
344
  ### Ce que chaque moment pose
@@ -354,7 +354,7 @@ Deux nuances utiles :
354
354
  `Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
355
355
  point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
356
356
  - **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
357
- (`http-kernel.ts:1630`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
357
+ (`http-kernel.ts:1661`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
358
358
  tes routes.
359
359
 
360
360
  Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
@@ -371,12 +371,12 @@ Trois briques voisines, souvent confondues. Une seule ligne chacune :
371
371
  | **[En-têtes](./headers.md)** (CSP, COOP) | ce que la **page** a le droit de faire | dans le navigateur | XSS, injection, fenêtres croisées |
372
372
 
373
373
  Les deux premières se parlent. Au boot, la liste des origines de confiance CSRF est l'**union** de
374
- `csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:605`) : ce que tu autorises explicitement en
374
+ `csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:640`) : ce que tu autorises explicitement en
375
375
  CORS ne peut pas être, au même instant, traité comme une tentative CSRF.
376
376
 
377
377
  L'inverse n'est pas vrai, et c'est délibéré : `csrf.trustedOrigins` déclare un **alias de domaine**
378
378
  légitime de ton app (une façade multi-domaine) sans pour autant exposer tes réponses au JS de cette
379
- origine (`config.ts:188`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
379
+ origine (`config.ts:194`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
380
380
  `csrf.trustedOrigins`.
381
381
 
382
382
  ## 🔌 Et le WebSocket ?
@@ -384,10 +384,10 @@ origine (`config.ts:188`). Ajouter une origine à `cors.origins` est **plus** pe
384
384
  **Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
385
385
  `new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
386
386
  victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
387
- (`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
387
+ (`firewall.ts:1098`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
388
388
 
389
389
  La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
390
- (`http-kernel.ts:621`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
390
+ (`http-kernel.ts:724`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
391
391
  si elle est refusée. Sa doctrine :
392
392
 
393
393
  - **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
@@ -405,11 +405,11 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
405
405
  | Sujet | Norme | Comment le code s'y conforme |
406
406
  | --------------------------------- | --------------------------- | --------------------------------------------------------------------------- |
407
407
  | Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts:33`) — preflight vs requête réelle séparés |
408
- | Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:1032`) |
409
- | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
408
+ | Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:1038`) |
409
+ | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:162-165`) + reflet défensif (`cors.ts:58`) |
410
410
  | Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
411
411
  | Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
412
- | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
412
+ | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:724`) |
413
413
 
414
414
  ## ⚡ Performance & mémoire
415
415
 
@@ -419,18 +419,18 @@ ne reste à l'exécution qu'un `Set.has()` sur l'origine.
419
419
 
420
420
  Le coût par requête est donc :
421
421
 
422
- - **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:1013`) ;
423
- - **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:83`) ;
424
- - **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:166`) ;
422
+ - **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:1019`) ;
423
+ - **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:94`) ;
424
+ - **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:184`) ;
425
425
  - **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
426
426
  origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts:74`).
427
427
 
428
428
  ## 📡 Observabilité — Studio
429
429
 
430
430
  La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
431
- `Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
432
- (`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
- `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
431
+ `Firewall.describe()` (`firewall.ts:588`), qui délègue à `Firewall.#describeDefenses()`
432
+ (`firewall.ts:643`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
+ `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:645`) — aucun secret ne transite par
434
434
  cette surface.
435
435
 
436
436
  - **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:328`), protégé
@@ -447,7 +447,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
447
447
 
448
448
  | Symptôme | Cause | Correction |
449
449
  | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
450
- | Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:156-159`) | Lister les origines, ou passer `credentials:false` |
450
+ | Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:194-197`) | Lister les origines, ou passer `credentials:false` |
451
451
  | Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent | Écrire l'origine complète `scheme://host:port`, une entrée par variante |
452
452
  | `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
453
453
  | Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"` | Activer les deux côtés (et un cookie `SameSite=None; Secure` en cross-site) |
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
455
455
  | Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
456
456
  | Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
457
457
  | `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
458
- | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:621`) — vérifier `allowedOrigins` |
458
+ | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:724`) — vérifier `allowedOrigins` |
459
459
  | Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
460
460
 
461
461
  ## 🧪 Tests & couverture
package/docs/csrf.md CHANGED
@@ -91,9 +91,9 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
91
91
  ## La vision Nodefony
92
92
 
93
93
  - **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
94
- couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
94
+ couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:169-174`).
95
95
  - **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
96
- branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1747`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1854`) arrive **après** le
97
97
  resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
98
98
  attaquant ne coûte ni lecture de session ni authentification).
99
99
  - **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
@@ -148,13 +148,13 @@ export default ProfileController;
148
148
 
149
149
  (Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
150
150
  le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
151
- `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1650-1655`.)
151
+ `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1750-1755`.)
152
152
 
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
155
155
  1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
156
- (`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
157
- exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:482-497`).
156
+ (`firewall.ts:994-1000`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
157
+ exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:570-590`).
158
158
  2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
159
159
  `x-csrf-token` sur chaque mutation.
160
160
 
@@ -199,7 +199,7 @@ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
199
199
  > [!IMPORTANT]
200
200
  > En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
201
201
  > absent, un secret **éphémère** est généré (dev) : un redémarrage invalide les tokens en cours, et
202
- > chaque pod rejette les tokens des autres (`firewall.ts:225-234`). Générer et câbler :
202
+ > chaque pod rejette les tokens des autres (`firewall.ts:226-235`). Générer et câbler :
203
203
  > `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
204
204
  > `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
205
205
 
@@ -241,12 +241,12 @@ autorisation :
241
241
  ```
242
242
 
243
243
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1142`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
244
+ > `@CsrfExempt` (`routerDecorators.ts:1165`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
245
245
  > webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
246
246
 
247
247
  Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
248
248
  déclarer l'alias dans `csrf.trustedOrigins` (match exact d'origine), pas dans `cors.origins` — CORS
249
- ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:188-193`).
249
+ ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:194-199`).
250
250
 
251
251
  ## 🏗️ Architecture interne
252
252
 
@@ -257,7 +257,7 @@ sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans
257
257
 
258
258
  1. **Origine de confiance** — `csrf.trustedOrigins` ∪ whitelist CORS → passe même en cross-site : ce
259
259
  que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts:45`, union construite par le
260
- firewall au boot, `firewall.ts:217-220`).
260
+ firewall au boot, `firewall.ts:218-221`).
261
261
  2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
262
262
  `same-site` → OK sauf `strictSameSite` (`csrf.ts:102-104`) ; `cross-site` → **403** ; valeur
263
263
  inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts:98-109`).
@@ -268,10 +268,10 @@ sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans
268
268
  Lectures durcies côté firewall :
269
269
 
270
270
  - en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
271
- `headerValue()`, `firewall.ts:103`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
272
- parse du contexte (`cookieValue()`, `firewall.ts:116-129`) ;
271
+ `headerValue()`, `firewall.ts:114`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
272
+ parse du contexte (`cookieValue()`, `firewall.ts:117-130`) ;
273
273
  - hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
274
- (`firewall.ts:977-982`) ;
274
+ (`firewall.ts:983-988`) ;
275
275
  - le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
276
276
  whitelist) ne fuite jamais au client (`CsrfError.ts:17-21`).
277
277
 
@@ -296,28 +296,30 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
296
296
  1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
297
297
  `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
298
298
  2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
299
- (`Resolver.ts:156-186`) — champs portés par le `Context` de base, HTTP comme WS
299
+ (`Resolver.ts:160-190`) — champs portés par le `Context` de base, HTTP comme WS
300
300
  (`Context.ts:241-243`).
301
- 3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
301
+ 3. `Firewall.enforceCsrf()` (`firewall.ts:1042`) fait les trois rôles : **émission** du token sur
302
302
  requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
303
- `@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
304
- (`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
305
- (`firewall.ts:967`).
303
+ `@CsrfProtect`. Seules les `@CsrfExempt` sortent, après la barrière méthode sûre
304
+ (`firewall.ts:1062`). Une route `bypassFirewall` n'est **pas** exemptée : le bypass retire
305
+ l'authentification, pas la provenance — les routes de connexion sont en bypass, et c'est là
306
+ qu'un formulaire tiers ouvrirait une session sur le compte de l'attaquant (login CSRF,
307
+ CWE-352, `firewall.ts:1035`).
306
308
  4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
307
- avec le cookie de session (`HttpContext.ts:482-497`).
309
+ avec le cookie de session (`HttpContext.ts:636-655`).
308
310
 
309
- ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:161-204`)
311
+ ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:167-210`)
310
312
 
311
313
  <!-- prettier-ignore -->
312
314
  | Option | Type · défaut | Effet |
313
315
  | --- | --- | --- |
314
- | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:163-168`). |
315
- | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
316
- | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
317
- | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
318
- | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:542`). |
319
- | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
320
- | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
316
+ | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:169-174`). |
317
+ | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:207-212`). |
318
+ | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:182-187`). |
319
+ | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:220-225`). |
320
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:650`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:634`). |
321
+ | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:226-231`). |
322
+ | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:232-238`). |
321
323
 
322
324
  ## 📜 Normes appliquées
323
325
 
@@ -334,19 +336,19 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
334
336
  ## ⚡ Performance & mémoire
335
337
 
336
338
  - **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts:88`) ; seule exception, une
337
- route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts:946`).
339
+ route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts:1025`).
338
340
  - **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
339
341
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
- allouée « au cas où » (`firewall.ts:164`).
342
+ allouée « au cas où » (`firewall.ts:165`).
341
343
  - Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
342
- marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:205`).
344
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:209`).
343
345
 
344
346
  ## 📡 Observabilité — Studio
345
347
 
346
348
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
347
349
  `Firewall.tsx:313-314`). La projection est **sans secret par construction** :
348
- `Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
349
- n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
350
+ `Firewall.#describeDefenses()` (`firewall.ts:643`) publie la config résolue, et `synchronizerToken`
351
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:653`).
350
352
 
351
353
  ## ⚠️ Pièges (symptôme → cause → correction)
352
354
 
@@ -354,8 +356,8 @@ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:60
354
356
  | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
355
357
  | Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
356
358
  | Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts:113`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
357
- | `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts:778-783`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
358
- | Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:227-237`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
359
+ | `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts:839-844`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
360
+ | Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:233-243`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
359
361
  | `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
360
362
  | `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts:130-136`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
361
363
  | Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
@@ -33,9 +33,9 @@ Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions r
33
33
  sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
34
34
  d'accès » :
35
35
 
36
- 1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:666-671`). Un jeton dont l'`iss` n'y figure pas est
36
+ 1. **La liste des émetteurs acceptés** est une allowlist (`trustedIssuerSchema`, `config.ts:710`). Un jeton dont l'`iss` n'y figure pas est
37
37
  refusé **avant toute requête sortante** — l'application ne va pas interroger un émetteur inconnu.
38
- 2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:672-677`). Par défaut, il
38
+ 2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:678-683`). Par défaut, il
39
39
  doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
40
40
  toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
41
41
  du pare-feu.
@@ -89,7 +89,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
89
89
  Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
90
90
  paire `(émetteur, sujet)`, jamais le sujet seul.
91
91
 
92
- En mode `prefixed` (`config.ts:656-660`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
92
+ En mode `prefixed` (`config.ts:723-727`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
93
93
  pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
94
94
  identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
95
95
  l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
@@ -104,13 +104,45 @@ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des i
104
104
  Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
105
105
  utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
106
106
  - **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
107
- avec les rôles d'`ephemeralRoles` (`config.ts:678`). C'est le mode de l'appelant **purement machine** : un agent, un
107
+ avec les rôles d'`ephemeralRoles` (`config.ts:745`). C'est le mode de l'appelant **purement machine** : un agent, un
108
108
  service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
109
109
  — qui viennent du jeton, donc bornés par le serveur d'autorisation.
110
110
 
111
111
  > Écrire un rôle dans `ephemeralRoles` accorde un pouvoir local à quiconque détient un jeton valide
112
112
  > pour cette ressource. À faire sciemment, jamais par confort.
113
113
 
114
+ ### Une politique PAR ZONE
115
+
116
+ `resourceServer.subjectPolicy` vaut pour toute l'application. Une zone qui liste `external-jwt` peut
117
+ la **surcharger** — c'est ce qui fait cohabiter une API d'appelants machine et une zone de personnes :
118
+
119
+ ```ts
120
+ areas: {
121
+ partner: {
122
+ pattern: "^/api/partner",
123
+ stateless: true,
124
+ authenticators: ["jwt", "external-jwt"],
125
+ resource: "https://app.example/api/partner",
126
+ subjectPolicy: "ephemeral", // l'appelant machine n'a pas de compte local
127
+ ephemeralRoles: ["ROLE_PARTNER"], // facultatif — sinon : scopes seulement
128
+ },
129
+ staff: {
130
+ pattern: "^/staff",
131
+ authenticators: ["external-jwt"],
132
+ resource: "https://app.example/staff", // suit la globale (« require »)
133
+ },
134
+ }
135
+ ```
136
+
137
+ Trois règles, toutes vérifiées au boot ou au rattachement :
138
+
139
+ - la zone qui ne dit rien suit la politique globale ;
140
+ - `subjectPolicy` sur une zone **sans** `external-jwt` est refusé au boot : le réglage n'aurait
141
+ aucun effet, et l'auteur croirait sa zone ouverte aux machines ;
142
+ - `ephemeralRoles` exige `subjectPolicy: "ephemeral"` écrit **dans la même zone**, et une zone
143
+ `ephemeral` muette n'accorde **aucun** rôle — jamais ceux de `resourceServer.ephemeralRoles`. Un
144
+ pouvoir local ne s'hérite pas d'un réglage écrit ailleurs.
145
+
114
146
  ## Comment ça s'articule
115
147
 
116
148
  Le service `accessTokenVerifier` n'est posé au conteneur **que si `issuers` n'est pas vide** — il n'y
@@ -133,6 +165,36 @@ areas: {
133
165
  }
134
166
  ```
135
167
 
168
+ ## Côté appelant — le jeton d'un programme
169
+
170
+ Un script, un service partenaire ou un agent obtient son jeton **en son propre nom** par le flux
171
+ `client_credentials` (RFC 6749 §4.4) : pas d'utilisateur, pas de navigateur, un client confidentiel
172
+ et son secret. Le framework le porte, pour qu'aucun appelant ne recopie la plomberie OAuth :
173
+
174
+ ```ts
175
+ import { requestClientCredentialsToken } from "@nodefony/security";
176
+
177
+ const tokens = await requestClientCredentialsToken({
178
+ issuer: "https://sso.example.com/realms/acme", // le point de jeton en est DÉCOUVERT (RFC 8414)
179
+ clientId: "acme-machine",
180
+ clientSecret: process.env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
181
+ resource: "https://app.example/api/partner", // audience visée (RFC 8707), facultative
182
+ });
183
+ await fetch("https://app.example/api/partner/whoami", {
184
+ headers: { Authorization: `Bearer ${tokens.accessToken()}` },
185
+ });
186
+ ```
187
+
188
+ - Le secret vide est refusé **avant** tout appel, en nommant le client : la cause réelle est une
189
+ variable absente, pas un `invalid_client`.
190
+ - Un refus garde son code normalisé et son statut (`OAuth2RequestError`) : Keycloak rend
191
+ `unauthorized_client` en 401 sur un secret faux.
192
+ - L'authentification du client est la MÊME que celle de l'échange de code (`postTokenRequest`,
193
+ `oauth2Client.ts`) : `client_secret_basic` par défaut, `client_secret_post` sur déclaration.
194
+ - Le `sub` d'un compte de service Keycloak est un UUID tiré à la création du realm : il ne se sème
195
+ pas dans un compte local. La zone qui reçoit cet appelant déclare donc
196
+ `subjectPolicy: "ephemeral"` (§ « Une politique PAR ZONE »).
197
+
136
198
  ## Ce que l'application publie d'elle-même
137
199
 
138
200
  Une ressource protégée doit dire **où obtenir un jeton valable pour elle**. C'est l'objet des
package/docs/firewall.md CHANGED
@@ -52,10 +52,10 @@ flowchart TD
52
52
  AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
53
53
  ```
54
54
 
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.
55
+ `Firewall.isSecure()` (`firewall.ts:792`) rattache la requête à une **zone** via
56
+ `Firewall.matchPath()` (`firewall.ts:783`) ; `Firewall.handleSecurity()` (`firewall.ts:825`) décide.
57
57
  Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
58
- le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
58
+ le plus long gagne, pas le premier déclaré (`firewall.ts:258`).
59
59
 
60
60
  ## 📖 Lexique
61
61
 
@@ -64,7 +64,7 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
64
64
  | Zone | Un motif d'URL (+ host) avec sa politique (`config.areas`, un objet par nom). |
65
65
  | Authenticator | Une stratégie d'identification (session, userpassword, jwt, apikey, anonymous). |
66
66
  | Zero Trust | Sans preuve valide sur une zone protégée → 401. |
67
- | Challenge | En-tête `WWW-Authenticate` (RFC 7235) qui dit comment s'authentifier. |
67
+ | Challenge | En-tête `WWW-Authenticate` (RFC 9110 §11) qui dit comment s'authentifier. |
68
68
  | BFF | Backend-For-Frontend : le serveur gère session/jetons pour le front web. |
69
69
  | PAT | Personal Access Token : une clé d'API opaque, révocable côté serveur. |
70
70
  | Bearer | Schéma `Authorization: Bearer <token>` (RFC 6750). |
@@ -74,7 +74,7 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
74
74
  ### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
75
75
 
76
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
- nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
77
+ nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1443-1444`) :
78
78
 
79
79
  ```typescript
80
80
  // nodefony/config/security.ts (extrait généré par `nodefony create app`)
@@ -182,6 +182,39 @@ Authorization: Bearer eyJhbGciOiJFZERTQS␣…␣.␣…␣.␣… # un J
182
182
  Authorization: Bearer nf_9a2c… # une clé API (préfixe nf_)
183
183
  ```
184
184
 
185
+ ### Protéger des pages : conduire le visiteur à la connexion (`entryPoint`)
186
+
187
+ Sans réglage, un visiteur anonyme qui ouvre une page protégée reçoit un `401` brut.
188
+ `entryPoint` lui donne la page de connexion à la place :
189
+
190
+ ```typescript
191
+ use("@nodefony/security", {
192
+ areas: {
193
+ pages: {
194
+ pattern: "^/compte(/|$)",
195
+ authenticators: ["session"],
196
+ entryPoint: "/login", // la page de connexion du framework (loginPage.path)
197
+ },
198
+ },
199
+ });
200
+ ```
201
+
202
+ | La requête refusée | Réponse |
203
+ | ---------------------------------------------------------------------------- | ------------------------------------ |
204
+ | `GET`/`HEAD`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` ou absent | `302` → `/login?from=%2Fcompte` |
205
+ | appel d'API (`Accept: application/json`), `fetch()` (`Sec-Fetch-Mode: cors`) | `401` + `WWW-Authenticate`, inchangé |
206
+ | `POST`, `PUT`, `DELETE`…, WebSocket | refus inchangé |
207
+
208
+ - La page de connexion ramène au chemin demandé (`from`, requête comprise) une fois connecté.
209
+ `from` passe par `safeRedirectPath`, la garde anti-redirection ouverte du cœur.
210
+ - C'est toujours un **refus** : l'audit enregistre `auth.denied` / `no_credentials`, et le hook
211
+ `onAuthFailure` se déclenche, comme pour un `401`.
212
+ - `entryPoint` doit être un chemin de l'origine : une adresse externe (`https://…`, `//…`) est
213
+ refusée au démarrage.
214
+ - La page de connexion elle-même n'est jamais redirigée vers elle-même : si elle tombe dans la
215
+ zone, sa requête garde son `401`.
216
+ - Absent (le défaut), tous les clients reçoivent le `401`.
217
+
185
218
  ## 🔐 Les authenticators intégrés
186
219
 
187
220
  Tous respectent le **même contrat** (`IAuthenticator`) : `supports(context)` (test bon marché : la
@@ -280,7 +313,7 @@ Sans lui, zone protégée + aucune preuve = 401.
280
313
 
281
314
  Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
282
315
  porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
283
- handshake des zones protégées `realtime` (`firewall.ts:279`).
316
+ handshake des zones protégées `realtime` (`firewall.ts:302`).
284
317
 
285
318
  - **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
286
319
  l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
@@ -320,7 +353,7 @@ laisser chercher pourquoi `context.session` est nul.
320
353
  ## ⚙️ Ordre et modes (`mode: "first"` vs `"all"`)
321
354
 
322
355
  La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
323
- (`firewall.ts:1128`). Le `mode` dit comment la parcourir. Trois situations concrètes :
356
+ (`firewall.ts:1236`). Le `mode` dit comment la parcourir. Trois situations concrètes :
324
357
 
325
358
  ### Situation 1 — humains ET machines sur la même API (`first`, le mode courant)
326
359
 
@@ -342,7 +375,7 @@ Ce qui se passe, requête par requête :
342
375
  | --- | --- | --- |
343
376
  | le cookie de session | `session` | identifié, `apikey` jamais consulté |
344
377
  | `Authorization: Bearer nf_…` | `apikey` | identifié (session ne matche pas, on passe) |
345
- | une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1128`) |
378
+ | une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1236`) |
346
379
  | rien | aucun | **401** (Zero Trust) |
347
380
 
348
381
  ### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
@@ -373,11 +406,11 @@ danger: {
373
406
 
374
407
  Le client doit présenter **les deux preuves** dans la même requête (cookie + `Authorization:
375
408
  Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte l'identité
376
- (`firewall.ts:1199`) — ici la preuve mot de passe, la plus fraîche.
409
+ (`firewall.ts:1311`) — ici la preuve mot de passe, la plus fraîche.
377
410
 
378
411
  > [!TIP]
379
412
  > Un nom d'authenticator inconnu en config **fait échouer le boot** —
380
- > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:435`) : jamais de zone
413
+ > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:468`) : jamais de zone
381
414
  > « protégée » silencieusement ouverte à cause d'une faute de frappe.
382
415
 
383
416
  ## 🧑‍⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
@@ -513,34 +546,34 @@ scopes, métier), un même jury, combinables.
513
546
 
514
547
  ## 🔌 HTTP et WebSocket — le même firewall
515
548
 
516
- `Firewall.#wireRealtime()` (`firewall.ts:279`) câble, pour toute zone protégée `realtime !== false`
517
- (opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:300`)
518
- **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:348`). Même résolution de zone que HTTP.
549
+ `Firewall.#wireRealtime()` (`firewall.ts:301`) câble, pour toute zone protégée `realtime !== false`
550
+ (opt-out, `firewall.ts:278`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:313`)
551
+ **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:361`). Même résolution de zone que HTTP.
519
552
  Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
520
- `firewall.ts:1216`) : le **code de fermeture** suffit.
553
+ `firewall.ts:1345`) : le **code de fermeture** suffit.
521
554
 
522
555
  ## 🛡️ En-têtes de sécurité, CSRF, CORS
523
556
 
524
- - **`Firewall.applySecurityHeaders()`** (`firewall.ts:1045`) : CSP, Referrer-Policy, COOP/COEP/CORP
525
- au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
557
+ - **`Firewall.applySecurityHeaders()`** (`firewall.ts:1152`) : CSP, Referrer-Policy, COOP/COEP/CORP
558
+ au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1081`) :
526
559
  alloué seulement si une directive en a besoin.
527
- - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
528
- (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:971`), puis double-submit `x-csrf-token` ≡
529
- cookie + HMAC (`firewall.ts:778`).
530
- - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1014`).
560
+ - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:1042`) : Fetch Metadata
561
+ (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:977`), puis double-submit `x-csrf-token` ≡
562
+ cookie + HMAC (`firewall.ts:628`).
563
+ - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1098`).
531
564
 
532
565
  ## 📜 Normes appliquées
533
566
 
534
567
  | Domaine | Norme | Ancrage |
535
568
  | ---------------------- | --------------- | ------------------------------------------------------ |
536
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
569
+ | Challenge d'auth (401) | RFC 9110 §11 | `Firewall.#setChallenge()` (`firewall.ts:1345`) |
537
570
  | Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
538
571
  | JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
539
572
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
540
- | Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:777-780`) |
573
+ | Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:838-841`) |
541
574
  | Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
542
- | CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
543
- | Modèle | Zero Trust | `firewall.ts:818-833` (aucune preuve → 401) |
575
+ | CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:1042`) |
576
+ | Modèle | Zero Trust | `firewall.ts:819-834` (aucune preuve → 401) |
544
577
 
545
578
  ## ⚡ Performance & mémoire
546
579