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

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 (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/csrf.md CHANGED
@@ -91,13 +91,13 @@ 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#areaSchema`).
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#HttpKernel.armAndGuard`) 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 —
100
- testable sans serveur, instanciée une fois au boot (`csrf.ts:56`).
100
+ testable sans serveur, instanciée une fois au boot (`csrf.ts#Csrf`).
101
101
  - La couche 2 (`@CsrfProtect`) est la ceinture-et-bretelles des mutations à haute valeur ; la
102
102
  couche 3 côté cookies (attribut `SameSite`) reste portée par les émetteurs de cookies.
103
103
 
@@ -107,11 +107,11 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
107
107
 
108
108
  Rien à écrire ni à configurer : toute mutation dont la provenance est un site tiers reçoit **403**,
109
109
  sur toutes tes routes. Les clients non-navigateurs (curl, CI) passent — ils n'embarquent pas les
110
- cookies d'une victime, ils sont hors vecteur (`csrf.ts:113`).
110
+ cookies d'une victime, ils sont hors vecteur (`csrf.ts#Csrf.enforce`).
111
111
 
112
112
  ### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
113
113
 
114
- Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:101-102`) :
114
+ Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts`) :
115
115
 
116
116
  ```typescript
117
117
  // nodefony/controllers/ProfileController.ts — complet, compile tel quel
@@ -148,13 +148,16 @@ 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#computeActionMeta`.)
152
152
 
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
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`).
155
+ 1. **Obtenir** : `GET /nodefony/security/api/auth/me` sème TOUJOURS le token — c'est la requête sûre
156
+ que tout client connaît, et elle évite de décorer une lecture pour le seul plaisir d'en obtenir
157
+ un (le token est sans état : celui-ci vaut pour toute route protégée). Plus généralement, une
158
+ requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
159
+ (`firewall.ts#Firewall.#recordAuth`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
160
+ exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts#HttpContext.writeHead`).
158
161
  2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
159
162
  `x-csrf-token` sur chaque mutation.
160
163
 
@@ -199,8 +202,8 @@ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
199
202
  > [!IMPORTANT]
200
203
  > En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
201
204
  > 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 :
203
- > `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
205
+ > chaque pod rejette les tokens des autres (`firewall.ts#Firewall.#build`). Générer et câbler :
206
+ > `npx nodefony security:secrets` (`security-secrets.ts#ROLES`) → `NF_CSRF_SECRET` →
204
207
  > `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
205
208
 
206
209
  ## ⚙️ Choisir sa défense — trois situations
@@ -241,129 +244,131 @@ autorisation :
241
244
  ```
242
245
 
243
246
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1142`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
247
+ > `@CsrfExempt` (`routerDecorators.ts#CsrfExempt`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
245
248
  > webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
246
249
 
247
250
  Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
248
251
  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`).
252
+ ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts#corsSchema`).
250
253
 
251
254
  ## 🏗️ Architecture interne
252
255
 
253
256
  ### Couche 1 — la chaîne de décision (`Csrf.enforce()`)
254
257
 
255
- `Csrf.enforce()` (`csrf.ts:85`) est **pure, synchrone, zéro I/O**, no-op immédiat sur les méthodes
256
- sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans l'ordre :
258
+ `Csrf.enforce()` (`csrf.ts#Csrf.enforce`) est **pure, synchrone, zéro I/O**, no-op immédiat sur les méthodes
259
+ sûres → coût nul sur le GET dominant (`csrf.ts#Csrf.enforce`). Pour une mutation, dans l'ordre :
257
260
 
258
261
  1. **Origine de confiance** — `csrf.trustedOrigins` ∪ whitelist CORS → passe même en cross-site : ce
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`).
262
+ que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts#Csrf`, union construite par le
263
+ firewall au boot, `firewall.ts#Firewall.#build`).
261
264
  2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
262
- `same-site` → OK sauf `strictSameSite` (`csrf.ts:102-104`) ; `cross-site` → **403** ; valeur
263
- inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts:98-109`).
265
+ `same-site` → OK sauf `strictSameSite` (`csrf.ts#Csrf.enforce`) ; `cross-site` → **403** ; valeur
266
+ inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts#Csrf.enforce`).
264
267
  3. **Repli `Origin`/`Referer`** (vieux navigateur, ou `Sec-Fetch-Site` absent) : **aucune** des
265
268
  deux → client non-navigateur, hors vecteur → OK ; sinon **same-host** exigé, mismatch → **403**
266
- (`csrf.ts:112-116`).
269
+ (`csrf.ts#Csrf.enforce`).
267
270
 
268
271
  Lectures durcies côté firewall :
269
272
 
270
273
  - 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`) ;
274
+ `headerValue()`, `firewall.ts#headerValue`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
275
+ parse du contexte (`cookieValue()`, `firewall.ts#cookieValue`) ;
273
276
  - hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
274
- (`firewall.ts:977-982`) ;
277
+ (`firewall.ts#Firewall`) ;
275
278
  - le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
276
- whitelist) ne fuite jamais au client (`CsrfError.ts:17-21`).
279
+ whitelist) ne fuite jamais au client (`CsrfError.ts#CsrfError`).
277
280
 
278
281
  ### Couche 2 — le token signé (`CsrfTokenManager`)
279
282
 
280
- `CsrfTokenManager` (`csrfToken.ts:23`) implémente le **signed double-submit** (OWASP Cheat Sheet) :
283
+ `CsrfTokenManager` (`csrfToken.ts#CsrfTokenManager`) implémente le **signed double-submit** (OWASP Cheat Sheet) :
281
284
 
282
- - token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts:27`),
283
- émis par `issue()` (`csrfToken.ts:34`) ;
285
+ - token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts#CsrfTokenManager.#NONCE_BYTES`),
286
+ émis par `issue()` (`csrfToken.ts#CsrfTokenManager.issue`) ;
284
287
  - `verify()` exige en-tête **et** cookie **présents**, **égaux** (double-submit) et la **signature
285
- valide** (`csrfToken.ts:49-63`) — comparaisons à **temps constant**, jamais d'exception
286
- (`csrfToken.ts:71-76`).
288
+ valide** (`csrfToken.ts#CsrfTokenManager.verify`) — comparaisons à **temps constant**, jamais d'exception
289
+ (`csrfToken.ts#CsrfTokenManager.#timingSafeEqual`).
287
290
 
288
291
  Pourquoi ça tient : le secret HMAC empêche un script tiers de **forger** un token (il ne peut pas
289
292
  calculer la signature) ; le double-submit l'empêche d'en **injecter** un (il ne peut ni écrire
290
293
  l'en-tête custom — préflight CORS — ni lire le cookie de la victime — SameSite + SOP). Et c'est
291
294
  **stateless** : aucune session requise → couvre le BFF web **et** l'API JWT sans coupler au stockage
292
- de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
295
+ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts#CsrfTokenManager`).
293
296
 
294
297
  ### Le câblage dans le pipeline (du décorateur au 403)
295
298
 
296
299
  1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
297
- `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
300
+ `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts#resolveSessionIntent`).
298
301
  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
300
- (`Context.ts:241-243`).
301
- 3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
302
+ (`Resolver.ts#Resolver.match`) — champs portés par le `Context` de base, HTTP comme WS
303
+ (`Context.ts#Context`).
304
+ 3. `Firewall.enforceCsrf()` (`firewall.ts#Firewall.enforceCsrf`) fait les trois rôles : **émission** du token sur
302
305
  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`).
306
+ `@CsrfProtect`. Seules les `@CsrfExempt` sortent, après la barrière méthode sûre
307
+ (`firewall.ts#Firewall.enforceCsrf`). Une route `bypassFirewall` n'est **pas** exemptée : le bypass retire
308
+ l'authentification, pas la provenance — les routes de connexion sont en bypass, et c'est là
309
+ qu'un formulaire tiers ouvrirait une session sur le compte de l'attaquant (login CSRF,
310
+ CWE-352, `firewall.ts#Firewall.enforceCsrf`).
306
311
  4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
307
- avec le cookie de session (`HttpContext.ts:482-497`).
312
+ avec le cookie de session (`HttpContext.ts#HttpContext`).
308
313
 
309
- ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:161-204`)
314
+ ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts#csrfSchema`)
310
315
 
311
316
  <!-- prettier-ignore -->
312
317
  | Option | Type · défaut | Effet |
313
318
  | --- | --- | --- |
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`). |
319
+ | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts#areaSchema`). |
320
+ | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts`). |
321
+ | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts#corsSchema`). |
322
+ | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts#csrfSchema`). |
323
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts#Firewall.#describeDefenses`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts#HttpContext.writeHead`). |
324
+ | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts#csrfSchema`). |
325
+ | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts#csrfSchema`). |
321
326
 
322
327
  ## 📜 Normes appliquées
323
328
 
324
- | Domaine | Norme | Ancrage |
325
- | ------------------------------ | --------------------------------- | -------------------------------------- |
326
- | Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
327
- | Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
328
- | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
329
- | Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
330
- | Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
331
- | Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
332
- | Cookies (SameSite) | RFC 6265bis §8.8.1 | TSDoc `Csrf` (`csrf.ts:54`) |
329
+ | Domaine | Norme | Ancrage |
330
+ | ------------------------------ | --------------------------------- | ---------------------------------------------------- |
331
+ | Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts#SAFE_METHODS`) |
332
+ | Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts#Csrf.enforce`) |
333
+ | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts#Csrf.enforce` |
334
+ | Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts#CsrfTokenManager`) |
335
+ | Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts#CsrfError`) |
336
+ | Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts#Csrf`) |
337
+ | Cookies (SameSite) | RFC 6265bis §8.8.1 | TSDoc `Csrf` (`csrf.ts#Csrf`) |
333
338
 
334
339
  ## ⚡ Performance & mémoire
335
340
 
336
- - **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`).
341
+ - **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts#Csrf.enforce`) ; seule exception, une
342
+ route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts#Firewall.enforceCsrf`).
338
343
  - **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
339
344
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
- allouée « au cas où » (`firewall.ts:164`).
345
+ allouée « au cas où » (`firewall.ts#Firewall`).
341
346
  - 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`).
347
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts#Resolver.match`).
343
348
 
344
349
  ## 📡 Observabilité — Studio
345
350
 
346
351
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
347
- `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`).
352
+ `Firewall.tsx#Firewall`). La projection est **sans secret par construction** :
353
+ `Firewall.#describeDefenses()` (`firewall.ts#Firewall.#describeDefenses`) publie la config résolue, et `synchronizerToken`
354
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts#Firewall.#describeDefenses`).
350
355
 
351
356
  ## ⚠️ Pièges (symptôme → cause → correction)
352
357
 
353
- | Symptôme | Cause (dans le code) | Correction |
354
- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
355
- | 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
- | 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
- | `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
360
- | `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
- | Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
358
+ | Symptôme | Cause (dans le code) | Correction |
359
+ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
360
+ | Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
361
+ | Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts#ICsrfRequest.origin`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
362
+ | `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts#Firewall.handleSecurity`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
363
+ | Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts#Firewall.#build`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
364
+ | `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts#Csrf.enforce`) | Le désactiver si les sous-domaines sont de confiance |
365
+ | `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts#Csrf.#sameHost`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
366
+ | Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
362
367
 
363
368
  > [!TIP]
364
369
  > Un banc d'intégration **live** du repo exerce exactement ce flow (émission GET, double-submit,
365
370
  > exemption) sur les routes `/csrf/token`, `/csrf/submit` et `/csrf/webhook` du module de test
366
- > (`FrameworkController.ts:139-160`) — la référence exécutable si un comportement te surprend.
371
+ > (`FrameworkController.ts#FrameworkController.csrfWebhook`) — la référence exécutable si un comportement te surprend.
367
372
 
368
373
  ## 🧪 Tests & couverture
369
374
 
@@ -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#trustedIssuerSchema`). 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#remoteJwtAlgSchema`). 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#trustedIssuerSchema`), 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,21 +104,57 @@ 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#trustedIssuerSchema`). 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
- Le service `accessTokenVerifier` n'est posé au conteneur **que si `issuers` n'est pas vide** — il n'y
117
- a pas de drapeau `enabled` qui permettrait « activé sans émetteur ». Une porte protégée par des
118
- jetons tiers, dans une application qui n'en déclare aucun, **refuse de servir** plutôt que d'accepter
119
- des porteurs qu'elle ne sait pas lire.
148
+ Le service `accessTokenVerifier` est posé au conteneur dès qu'il a **quelque chose à vérifier** :
149
+ les jetons de l'application elle-même (capacité `jwt` active, le défaut), et ceux des émetteurs de
150
+ `issuers`. Il aiguille sur l'émetteur revendiqué : celui de l'application est jugé par les règles de
151
+ l'authentificateur `jwt` (révocation et compte compris), tout autre par cette liste fermée — règle
152
+ complète et contrôles, dans l'ordre : [Ce que la porte vérifie](obtenir-un-jeton.md#-ce-que-la-porte-vérifie--un-jeton-de-cette-application-face-à-une-ressource).
153
+ Capacité `jwt` coupée ET `issuers` vide : rien n'est posé, et une porte protégée **refuse de servir**
154
+ (`503`) plutôt que d'accepter des porteurs qu'elle ne sait pas lire. `issuers` mal configurée :
155
+ rien n'est posé non plus, jetons de l'application compris.
120
156
 
121
- L'authentificateur `external-jwt` (`authenticatorRegistry.ts:118`) reconnaît les jetons qui le
157
+ L'authentificateur `external-jwt` (`authenticatorRegistry.ts`) reconnaît les jetons qui le
122
158
  concernent d'après la liste d'émetteurs, puis délègue la vérification au service — qui refait le contrôle sur sa propre liste.
123
159
  La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
124
160
 
@@ -133,6 +169,38 @@ areas: {
133
169
  }
134
170
  ```
135
171
 
172
+ ## Côté appelant — le jeton d'un programme
173
+
174
+ Un script, un service partenaire ou un agent obtient son jeton **en son propre nom** par le flux
175
+ `client_credentials` (RFC 6749 §4.4) : pas d'utilisateur, pas de navigateur, un client confidentiel
176
+ et son secret. Le framework le porte, pour qu'aucun appelant ne recopie la plomberie OAuth :
177
+
178
+ ```ts
179
+ import { requestClientCredentialsToken } from "@nodefony/security";
180
+ // `env.ts` est le seul lecteur de l'environnement : le secret y est déclaré (NF_KEYCLOAK_MACHINE_SECRET).
181
+ import { env } from "../../env";
182
+
183
+ const tokens = await requestClientCredentialsToken({
184
+ issuer: "https://sso.example.com/realms/acme", // le point de jeton en est DÉCOUVERT (RFC 8414)
185
+ clientId: "acme-machine",
186
+ clientSecret: env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
187
+ resource: "https://app.example/api/partner", // audience visée (RFC 8707), facultative
188
+ });
189
+ await fetch("https://app.example/api/partner/whoami", {
190
+ headers: { Authorization: `Bearer ${tokens.accessToken()}` },
191
+ });
192
+ ```
193
+
194
+ - Le secret vide est refusé **avant** tout appel, en nommant le client : la cause réelle est une
195
+ variable absente, pas un `invalid_client`.
196
+ - Un refus garde son code normalisé et son statut (`OAuth2RequestError`) : Keycloak rend
197
+ `unauthorized_client` en 401 sur un secret faux.
198
+ - L'authentification du client est la MÊME que celle de l'échange de code (`postTokenRequest`,
199
+ `oauth2Client.ts`) : `client_secret_basic` par défaut, `client_secret_post` sur déclaration.
200
+ - Le `sub` d'un compte de service Keycloak est un UUID tiré à la création du realm : il ne se sème
201
+ pas dans un compte local. La zone qui reçoit cet appelant déclare donc
202
+ `subjectPolicy: "ephemeral"` (§ « Une politique PAR ZONE »).
203
+
136
204
  ## Ce que l'application publie d'elle-même
137
205
 
138
206
  Une ressource protégée doit dire **où obtenir un jeton valable pour elle**. C'est l'objet des
@@ -153,8 +221,12 @@ l'audience à demander.
153
221
  l'aiguillage se fait sur l'`iss` du jeton. Inutile de les ranger « dans le bon ordre ».
154
222
  - **`HS256` est impossible en configuration**, et ce n'est pas un oubli : un secret partagé ferait de
155
223
  l'application un émetteur autant qu'un vérificateur. Seules des clés publiques sont acceptées.
156
- - **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut, et il fait qu'une zone
157
- protégée par jetons tiers refuse de servir plutôt que d'accepter des porteurs illisibles.
224
+ - **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut. La zone `external-jwt`
225
+ ne prend alors AUCUN jeton — elle n'aiguille que les émetteurs qu'elle déclare —, et les jetons de
226
+ l'application restent lisibles par le vérificateur pour les ressources qui le consultent.
227
+ - **Déclarer l'émetteur de l'application dans `issuers` ne le soustrait pas à la révocation** : ses
228
+ jetons sont aiguillés vers la vérification locale avant toute liste, donc un jeton révoqué ou
229
+ celui d'un compte désactivé est refusé, déclaré ou non.
158
230
 
159
231
  ## 📖 Lexique
160
232