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

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 (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.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:169-174`).
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:1854`) 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:1750-1755`.)
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: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`).
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:226-235`). 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,131 +244,131 @@ autorisation :
241
244
  ```
242
245
 
243
246
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1165`) 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:194-199`).
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:218-221`).
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:114`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
272
- parse du contexte (`cookieValue()`, `firewall.ts:117-130`) ;
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:983-988`) ;
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:160-190`) — champs portés par le `Context` de base, HTTP comme WS
300
- (`Context.ts:241-243`).
301
- 3. `Firewall.enforceCsrf()` (`firewall.ts:1042`) 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
306
  `@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
307
+ (`firewall.ts#Firewall.enforceCsrf`). Une route `bypassFirewall` n'est **pas** exemptée : le bypass retire
305
308
  l'authentification, pas la provenance — les routes de connexion sont en bypass, et c'est là
306
309
  qu'un formulaire tiers ouvrirait une session sur le compte de l'attaquant (login CSRF,
307
- CWE-352, `firewall.ts:1035`).
310
+ CWE-352, `firewall.ts#Firewall.enforceCsrf`).
308
311
  4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
309
- avec le cookie de session (`HttpContext.ts:636-655`).
312
+ avec le cookie de session (`HttpContext.ts#HttpContext`).
310
313
 
311
- ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:167-210`)
314
+ ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts#csrfSchema`)
312
315
 
313
316
  <!-- prettier-ignore -->
314
317
  | Option | Type · défaut | Effet |
315
318
  | --- | --- | --- |
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`). |
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`). |
323
326
 
324
327
  ## 📜 Normes appliquées
325
328
 
326
- | Domaine | Norme | Ancrage |
327
- | ------------------------------ | --------------------------------- | -------------------------------------- |
328
- | Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
329
- | Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
330
- | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
331
- | Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
332
- | Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
333
- | Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
334
- | 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`) |
335
338
 
336
339
  ## ⚡ Performance & mémoire
337
340
 
338
- - **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts:88`) ; seule exception, une
339
- route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts:1025`).
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`).
340
343
  - **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
341
344
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
342
- allouée « au cas où » (`firewall.ts:165`).
345
+ allouée « au cas où » (`firewall.ts#Firewall`).
343
346
  - Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
344
- marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:209`).
347
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts#Resolver.match`).
345
348
 
346
349
  ## 📡 Observabilité — Studio
347
350
 
348
351
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
349
- `Firewall.tsx:313-314`). La projection est **sans secret par construction** :
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`).
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`).
352
355
 
353
356
  ## ⚠️ Pièges (symptôme → cause → correction)
354
357
 
355
- | Symptôme | Cause (dans le code) | Correction |
356
- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
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) |
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 |
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` |
361
- | `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
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 |
363
- | 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` |
364
367
 
365
368
  > [!TIP]
366
369
  > Un banc d'intégration **live** du repo exerce exactement ce flow (émission GET, double-submit,
367
370
  > exemption) sur les routes `/csrf/token`, `/csrf/submit` et `/csrf/webhook` du module de test
368
- > (`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.
369
372
 
370
373
  ## 🧪 Tests & couverture
371
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 (`trustedIssuerSchema`, `config.ts:710`). 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:678-683`). 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:723-727`), 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,7 +104,7 @@ 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:745`). 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
 
@@ -145,12 +145,16 @@ Trois règles, toutes vérifiées au boot ou au rattachement :
145
145
 
146
146
  ## Comment ça s'articule
147
147
 
148
- Le service `accessTokenVerifier` n'est posé au conteneur **que si `issuers` n'est pas vide** — il n'y
149
- a pas de drapeau `enabled` qui permettrait « activé sans émetteur ». Une porte protégée par des
150
- jetons tiers, dans une application qui n'en déclare aucun, **refuse de servir** plutôt que d'accepter
151
- 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.
152
156
 
153
- 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
154
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.
155
159
  La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
156
160
 
@@ -173,11 +177,13 @@ et son secret. Le framework le porte, pour qu'aucun appelant ne recopie la plomb
173
177
 
174
178
  ```ts
175
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";
176
182
 
177
183
  const tokens = await requestClientCredentialsToken({
178
184
  issuer: "https://sso.example.com/realms/acme", // le point de jeton en est DÉCOUVERT (RFC 8414)
179
185
  clientId: "acme-machine",
180
- clientSecret: process.env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
186
+ clientSecret: env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
181
187
  resource: "https://app.example/api/partner", // audience visée (RFC 8707), facultative
182
188
  });
183
189
  await fetch("https://app.example/api/partner/whoami", {
@@ -215,8 +221,12 @@ l'audience à demander.
215
221
  l'aiguillage se fait sur l'`iss` du jeton. Inutile de les ranger « dans le bon ordre ».
216
222
  - **`HS256` est impossible en configuration**, et ce n'est pas un oubli : un secret partagé ferait de
217
223
  l'application un émetteur autant qu'un vérificateur. Seules des clés publiques sont acceptées.
218
- - **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut, et il fait qu'une zone
219
- 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.
220
230
 
221
231
  ## 📖 Lexique
222
232