@nodefony/security 10.0.0-alpha.1

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 (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
package/docs/csrf.md ADDED
@@ -0,0 +1,392 @@
1
+ ---
2
+ title: "CSRF — anti-forgery (Fetch Metadata + double-submit signé)"
3
+ navTitle: CSRF
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: csrf
7
+ coverageModule: security
8
+ coverageFiles: "csrf.ts,csrfToken.ts"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ security,
14
+ csrf,
15
+ fetch-metadata,
16
+ double-submit,
17
+ sec-fetch-site,
18
+ owasp,
19
+ rfc9110,
20
+ ]
21
+ version: "doc"
22
+ status: stable
23
+ updated: 2026-07-19
24
+ source: "src/packages/@nodefony/security/docs/csrf.md"
25
+ ---
26
+
27
+ # CSRF — empêcher les requêtes forgées cross-site
28
+
29
+ > Le CSRF fait exécuter au navigateur d'une victime **déjà authentifiée** une mutation qu'elle n'a
30
+ > pas voulue (son cookie de session part automatiquement). Nodefony défend en **deux couches** : une
31
+ > défense **globale** par _Fetch Metadata_ (le navigateur tamponne lui-même la provenance), et une
32
+ > défense **en profondeur opt-in** par _synchronizer token_ signé (`@CsrfProtect`, double-submit).
33
+ > Ancré sur `src/packages/@nodefony/security/nodefony/service/csrf.ts` et `src/csrfToken.ts`.
34
+
35
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **CSRF**
36
+
37
+ ## 🧠 Le modèle mental — deux couches, zéro friction la plupart du temps
38
+
39
+ ```mermaid
40
+ flowchart TD
41
+ REQ["requête entrante"] --> M{"méthode sûre ?<br/>GET/HEAD/OPTIONS/TRACE"}
42
+ M -->|"oui — 0 coût<br/>(+ @CsrfProtect : émettre le token)"| PASS["laisser passer"]
43
+ M -->|non = mutation| TO{"origine de confiance ?<br/>trustedOrigins ∪ CORS"}
44
+ TO -->|oui| PASS
45
+ TO -->|non| FM{"Sec-Fetch-Site ?"}
46
+ FM -->|same-origin / none| CP
47
+ FM -->|same-site| SS{"strictSameSite ?"}
48
+ SS -->|non| CP
49
+ SS -->|oui| B403["403"]
50
+ FM -->|cross-site| B403
51
+ FM -->|absent / inconnu| FB{"Origin/Referer<br/>same-host ?"}
52
+ FB -->|oui, ou non-navigateur| CP
53
+ FB -->|non| B403
54
+ CP{"route @CsrfProtect ?"} -->|oui| DS["exiger le token double-submit<br/>(en-tête ≡ cookie + HMAC)"]
55
+ CP -->|non| OK["→ contrôleur"]
56
+ DS -->|valide| OK
57
+ DS -->|absent / invalide| B403
58
+ ```
59
+
60
+ La couche 1 (provenance) est **globale et active par défaut** ; la couche 2 (token) n'est payée que
61
+ sur les routes décorées `@CsrfProtect`.
62
+
63
+ ## 📖 Lexique
64
+
65
+ | Terme | Sens |
66
+ | ------------------ | ------------------------------------------------------------------------------------------------------ |
67
+ | CSRF | _Cross-Site Request Forgery_ : un site tiers déclenche une action authentifiée à l'insu de la victime. |
68
+ | Méthode sûre | GET/HEAD/OPTIONS/TRACE — sans effet de bord (RFC 9110 §9.2.1), hors vecteur CSRF. |
69
+ | Fetch Metadata | En-têtes `Sec-Fetch-*` posés **par le navigateur** (infalsifiables par un script). |
70
+ | `Sec-Fetch-Site` | `same-origin` / `same-site` / `cross-site` / `none` : d'où vient la requête. |
71
+ | Synchronizer token | Jeton anti-CSRF rejoué par le client pour prouver l'intention. |
72
+ | Double-submit | Le token est à la fois dans un **cookie lisible** et dans un **en-tête** ; les deux doivent coïncider. |
73
+ | HMAC | _Hash-based MAC_ : signature symétrique — sans le secret, impossible de forger un token valide. |
74
+ | SOP | _Same-Origin Policy_ : un script tiers ne peut pas lire les cookies d'un autre site. |
75
+ | BFF | _Backend-For-Frontend_ : le serveur gère session/jetons pour le front web. |
76
+
77
+ ## Qu'est-ce que le CSRF ? — l'attaque, vue de la victime
78
+
79
+ 1. Tu es connecté·e à `app.example.org` — ton **cookie de session** est en poche.
80
+ 2. Un autre onglet affiche `evil.site` : la page embarque un `<form>` invisible pointant sur
81
+ `https://app.example.org/api/profile/email`, soumis automatiquement en JS.
82
+ 3. Ton navigateur envoie la requête **avec ton cookie** — c'est le comportement normal des cookies,
83
+ `evil.site` n'a rien volé.
84
+ 4. Sans défense, le serveur voit une mutation authentifiée : l'email du compte est remplacé, et le
85
+ « mot de passe oublié » part chez l'attaquant.
86
+
87
+ Ce que la défense bloque : à l'étape 3, le navigateur tamponne lui-même `Sec-Fetch-Site: cross-site`
88
+ — un script attaquant **ne peut pas** falsifier cet en-tête. Le serveur répond **403 avant tout
89
+ contrôleur** : l'attaque meurt sans avoir touché ton code.
90
+
91
+ ## La vision Nodefony
92
+
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:151-156`).
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:1427`) arrive **après** le
97
+ resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
98
+ attaquant ne coûte ni lecture de session ni authentification).
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`).
101
+ - La couche 2 (`@CsrfProtect`) est la ceinture-et-bretelles des mutations à haute valeur ; la
102
+ couche 3 côté cookies (attribut `SameSite`) reste portée par les émetteurs de cookies.
103
+
104
+ ## 🚀 Démarrage rapide
105
+
106
+ ### Dans une app `nodefony create app`, la couche 1 est DÉJÀ active
107
+
108
+ Rien à écrire ni à configurer : toute mutation dont la provenance est un site tiers reçoit **403**,
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`).
111
+
112
+ ### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
113
+
114
+ Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:86-87`) :
115
+
116
+ ```typescript
117
+ // nodefony/controllers/ProfileController.ts — complet, compile tel quel
118
+ import {
119
+ controller,
120
+ Controller,
121
+ Get,
122
+ Post,
123
+ Body,
124
+ CsrfProtect,
125
+ } from "@nodefony/framework";
126
+
127
+ @controller("/api/profile")
128
+ class ProfileController extends Controller {
129
+ // Requête SÛRE vers une route @CsrfProtect : le firewall MINT le token →
130
+ // la réponse pose le cookie lisible `csrf-token` (et on le rend au SPA).
131
+ @CsrfProtect()
132
+ @Get("/csrf")
133
+ csrf() {
134
+ return this.renderJson({ token: this.context?.csrfToken ?? null });
135
+ }
136
+
137
+ // Mutation @CsrfProtect : couche 1 (provenance) PUIS couche 2 —
138
+ // en-tête `x-csrf-token` ≡ cookie `csrf-token` + HMAC valide, sinon 403.
139
+ @CsrfProtect()
140
+ @Post("/email")
141
+ async changeEmail(@Body() body: { email: string }) {
142
+ return this.renderJson({ ok: true, email: body.email });
143
+ }
144
+ }
145
+
146
+ export default ProfileController;
147
+ ```
148
+
149
+ (Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
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:1605-1611`.)
152
+
153
+ ### Comment le front obtient — puis rejoue — le token
154
+
155
+ 1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
156
+ (`firewall.ts:753-757`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
157
+ exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
158
+ 2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
159
+ `x-csrf-token` sur chaque mutation.
160
+
161
+ ```typescript
162
+ // Côté SPA — lire le cookie lisible, le rejouer dans l'en-tête
163
+ export async function updateEmail(email: string): Promise<Response> {
164
+ const token =
165
+ document.cookie.match(/(?:^|;\s*)csrf-token=([^;]+)/)?.[1] ?? "";
166
+ return fetch("/api/profile/email", {
167
+ method: "POST",
168
+ headers: {
169
+ "content-type": "application/json",
170
+ "x-csrf-token": decodeURIComponent(token),
171
+ },
172
+ body: JSON.stringify({ email }),
173
+ });
174
+ }
175
+ ```
176
+
177
+ ### Ce qu'on observe
178
+
179
+ ```bash
180
+ # 1) Mutation forgée cross-site (ce que déclenche evil.site) → 403, couche 1
181
+ curl -si -X POST -H 'Sec-Fetch-Site: cross-site' \
182
+ http://localhost:5151/api/profile/email | head -1
183
+ # HTTP/1.1 403 Forbidden
184
+
185
+ # 2) Provenance saine MAIS pas de token (route @CsrfProtect) → 403, couche 2
186
+ curl -si -X POST -H 'Sec-Fetch-Site: same-origin' \
187
+ http://localhost:5151/api/profile/email | head -1
188
+ # HTTP/1.1 403 Forbidden
189
+
190
+ # 3) Semer le token (requête sûre), puis rejouer cookie + en-tête → 200
191
+ TOKEN=$(curl -s -c /tmp/jar http://localhost:5151/api/profile/csrf \
192
+ | sed -E 's/.*"token":"([^"]+)".*/\1/')
193
+ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
194
+ -H 'Content-Type: application/json' -d '{"email":"ada@example.org"}' \
195
+ -X POST http://localhost:5151/api/profile/email | head -1
196
+ # HTTP/1.1 200 OK
197
+ ```
198
+
199
+ > [!IMPORTANT]
200
+ > En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
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:199-209`). Générer et câbler :
203
+ > `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
204
+ > `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
205
+
206
+ ## ⚙️ Choisir sa défense — trois situations
207
+
208
+ ### Situation 1 — l'app web classique : la couche 1 suffit (défaut)
209
+
210
+ Ton SPA + BFF session sert des utilisateurs connectés ; tu ne veux **aucune friction**. Rien à
211
+ configurer : le navigateur tamponne la provenance, le serveur tranche.
212
+
213
+ | Le client envoie… | Couche 1 (provenance) | Résultat |
214
+ | -------------------------------------------------------- | ---------------------------- | :------: |
215
+ | SPA same-origin, cookie de session | `same-origin` → passe | **200** |
216
+ | `evil.site` (form auto-soumis, cookie embarqué de force) | `cross-site` | **403** |
217
+ | curl / script CI (aucun en-tête navigateur) | non-navigateur, hors vecteur | **200** |
218
+
219
+ ### Situation 2 — mutation à haute valeur : ajouter `@CsrfProtect`
220
+
221
+ Changement d'email/mot de passe, virement : tu veux que la mutation tienne **même si** un signal de
222
+ provenance manque (proxy qui strippe, navigateur ancien, valeur `Sec-Fetch-Site` future). Le token
223
+ prouve l'**intention** en plus de la provenance :
224
+
225
+ | Le client envoie… | Couche 1 | Couche 2 (token) | Résultat |
226
+ | ---------------------------------------------- | -------- | --------------------- | :------: |
227
+ | SPA : cookie + en-tête `x-csrf-token` ≡ cookie | passe | signature HMAC valide | **200** |
228
+ | SPA qui oublie l'en-tête | passe | token absent | **403** |
229
+ | curl sans rien (passait en situation 1) | passe | token **exigé** | **403** |
230
+ | curl après GET du token (cookie + en-tête) | passe | signature HMAC valide | **200** |
231
+
232
+ ### Situation 3 — webhook entrant : `@CsrfExempt`, jamais `@BypassFirewall`
233
+
234
+ Un provider (paiement, git) POST cross-origin **légitimement**, authentifié autrement (signature
235
+ HMAC du provider, clé API). La route sort de la défense CSRF **en conservant** authentification et
236
+ autorisation :
237
+
238
+ ```typescript
239
+ @CsrfExempt() // ✅ hors défense CSRF, l'auth de la zone RESTE appliquée
240
+ @BypassFirewall() // ❌ coupe AUSSI l'authentification — porte grande ouverte
241
+ ```
242
+
243
+ > [!WARNING]
244
+ > `@CsrfExempt` (`routerDecorators.ts:1099`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
245
+ > webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
246
+
247
+ Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
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:176-181`).
250
+
251
+ ## 🏗️ Architecture interne
252
+
253
+ ### Couche 1 — la chaîne de décision (`Csrf.enforce()`)
254
+
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 :
257
+
258
+ 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:190-194`).
261
+ 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`).
264
+ 3. **Repli `Origin`/`Referer`** (vieux navigateur, ou `Sec-Fetch-Site` absent) : **aucune** des
265
+ deux → client non-navigateur, hors vecteur → OK ; sinon **same-host** exigé, mismatch → **403**
266
+ (`csrf.ts:112-116`).
267
+
268
+ Lectures durcies côté firewall :
269
+
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:90-103`) ;
273
+ - hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
274
+ (`firewall.ts:772-775`) ;
275
+ - 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`).
277
+
278
+ ### Couche 2 — le token signé (`CsrfTokenManager`)
279
+
280
+ `CsrfTokenManager` (`csrfToken.ts:23`) implémente le **signed double-submit** (OWASP Cheat Sheet) :
281
+
282
+ - token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts:27`),
283
+ émis par `issue()` (`csrfToken.ts:34`) ;
284
+ - `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`).
287
+
288
+ Pourquoi ça tient : le secret HMAC empêche un script tiers de **forger** un token (il ne peut pas
289
+ calculer la signature) ; le double-submit l'empêche d'en **injecter** un (il ne peut ni écrire
290
+ l'en-tête custom — préflight CORS — ni lire le cookie de la victime — SameSite + SOP). Et c'est
291
+ **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`).
293
+
294
+ ### Le câblage dans le pipeline (du décorateur au 403)
295
+
296
+ 1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
297
+ `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
298
+ 2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
299
+ (`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
300
+ (`Context.ts:181-183`).
301
+ 3. `Firewall.enforceCsrf()` (`firewall.ts:932`) fait les trois rôles : **émission** du token sur
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:743-745`), les `@CsrfExempt` sortent après la barrière méthode sûre
305
+ (`firewall.ts:951`).
306
+ 4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
307
+ avec le cookie de session (`HttpContext.ts:419-432`).
308
+
309
+ ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:149-192`)
310
+
311
+ <!-- prettier-ignore -->
312
+ | Option | Type · défaut | Effet |
313
+ | --- | --- | --- |
314
+ | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:151-156`). |
315
+ | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:157-162`). |
316
+ | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:164-169`). |
317
+ | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:170-175`). |
318
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:469`). |
319
+ | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:176-181`). |
320
+ | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:182-188`). |
321
+
322
+ ## 📜 Normes appliquées
323
+
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:107` |
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`) |
333
+
334
+ ## ⚡ Performance & mémoire
335
+
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`).
338
+ - **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
339
+ - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
+ allouée « au cas où » (`firewall.ts:164`).
341
+ - 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:142`).
343
+
344
+ ## 📡 Observabilité — Studio
345
+
346
+ 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:575`) publie la config résolue, et `synchronizerToken`
349
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:557`).
350
+
351
+ ## ⚠️ Pièges (symptôme → cause → correction)
352
+
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:199-209`) | 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` |
362
+
363
+ > [!TIP]
364
+ > Un banc d'intégration **live** du repo exerce exactement ce flow (émission GET, double-submit,
365
+ > 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.
367
+
368
+ ## 🧪 Tests & couverture
369
+
370
+ Trois familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
371
+ (régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
372
+
373
+ - **unit** : `csrf.test.ts` (la chaîne de décision couche 1 : Fetch Metadata, `strictSameSite`,
374
+ repli, origines de confiance), `csrfToken.test.ts` (le double-submit signé : émission, formats,
375
+ vérification) ;
376
+ - **attaque** : `csrf.attack.test.ts` (red-team — spoofing d'`Origin` host-exact, provenance
377
+ illisible, token malformé, **splicing** nonce/signature de deux vrais tokens, et la limite
378
+ host-only du repli **documentée par test**) ;
379
+ - **intégration live** : `tests/http/csrf.test.ts` chez `@nodefony/http` (serveur réel — défense
380
+ globale, flow double-submit `@CsrfProtect`, opt-out `@CsrfExempt`).
381
+
382
+ Couverture : `npm run coverage` dans `@nodefony/security`.
383
+
384
+ ## 🔗 Pour aller plus loin
385
+
386
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
387
+ - 🧭 **Pages sœurs** : [CORS](cors.md) · [En-têtes de sécurité](headers.md)
388
+
389
+ - Le firewall qui câble les deux couches → [firewall](./firewall.md)
390
+ - CORS (ce qui est autorisé cross-origin, ∪ des origines de confiance CSRF) → [cors](./cors.md)
391
+ - En-têtes de sécurité (CSP, isolation) → [headers](./headers.md)
392
+ - Vue d'ensemble sécurité → [index](./index.md)
@@ -0,0 +1,181 @@
1
+ ---
2
+ title: "Jetons d'un émetteur TIERS — accepter Keycloak, Auth0 ou Entra sans leur céder l'application"
3
+ navTitle: Émetteur tiers
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: external-jwt
7
+ coverageModule: security
8
+ coverageFiles: "ExternalJwtAuthenticator,accessTokenVerifier,authenticatorRegistry"
9
+ section: "Sécurité"
10
+ audience: [developer, devops]
11
+ tags: [security, jwt, oauth2, resource-server, rfc9068, rfc8707, keycloak]
12
+ status: stable
13
+ version: "10.0.0"
14
+ updated: 2026-08-24
15
+ source: "src/packages/@nodefony/security/nodefony/src/authenticator/ExternalJwtAuthenticator.ts"
16
+ ---
17
+
18
+ # Accepter les jetons d'un émetteur tiers
19
+
20
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Jetons d'un émetteur tiers**
21
+
22
+ Une application Nodefony sait émettre ses propres jetons ([Jetons](tokens.md)). Cette page traite du
23
+ cas inverse : **un serveur d'autorisation extérieur** — l'annuaire de l'entreprise, Keycloak, Auth0,
24
+ Entra ID — émet les jetons, et l'application doit décider qui entre. Elle devient alors ce que les
25
+ normes appellent un **serveur de ressources** (RFC 6750, RFC 9068).
26
+
27
+ C'est le mode d'une API appelée par d'autres services, par des agents, ou par une application dont
28
+ l'authentification est centralisée ailleurs.
29
+
30
+ ## Ce que l'application NE délègue pas
31
+
32
+ Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions restent locales, et ce
33
+ sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
34
+ d'accès » :
35
+
36
+ 1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:660`). Un jeton dont l'`iss` n'y figure pas est
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:666`). Par défaut, il
39
+ doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
40
+ toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
41
+ du pare-feu.
42
+
43
+ ## Démarrage rapide
44
+
45
+ ```ts
46
+ // nodefony.config.ts
47
+ security: {
48
+ resourceServer: {
49
+ issuers: [
50
+ {
51
+ issuer: "https://auth.example.com/realms/mon-royaume",
52
+ algorithms: ["RS256"],
53
+ },
54
+ ],
55
+ },
56
+ }
57
+ ```
58
+
59
+ Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
60
+ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authorization: Bearer <jeton>`
61
+ émis par ce royaume — **à condition que le sujet du jeton corresponde à un compte local**.
62
+
63
+ > **L'audience n'est pas facultative.** Un jeton émis pour une autre application du même annuaire ne
64
+ > doit pas ouvrir celle-ci : c'est le rôle du claim `aud` (RFC 8707). La vérification l'exige.
65
+
66
+ ## Les réglages, et ce qu'ils engagent
67
+
68
+ | Réglage | Défaut | Ce qu'il décide |
69
+ | -------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | `issuers[].issuer` | — | L'allowlist. En `https`, sans requête ni fragment (RFC 8414 §2). |
71
+ | `issuers[].jwksUri` | découvert | Déclaré, aucune découverte n'a lieu — utile pour un émetteur sans métadonnées, ou un démarrage à froid sans requête sortante. |
72
+ | `issuers[].algorithms` | `RS256`, `ES256`, `EdDSA` | Allowlist **serveur** : l'algorithme n'est jamais déduit de l'en-tête du jeton (RFC 8725 §3.1). À restreindre à ce que l'émetteur utilise réellement. |
73
+ | `issuers[].typ` | non exigé | `at+jwt` pour un émetteur conforme RFC 9068. |
74
+ | `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss` et `aud`. |
75
+ | `issuers[].subjectMapping` | `prefixed` | Comment le `sub` devient un identifiant local — **voir l'encadré ci-dessous**. |
76
+ | `subjectPolicy` | `require` | `require` : un compte local est exigé. `ephemeral` : l'appelant vit le temps de la requête. |
77
+ | `ephemeralRoles` | `[]` | Rôles accordés en mode `ephemeral`. Vide à dessein. |
78
+
79
+ ### 🔴 `subjectMapping` — pourquoi le défaut est `prefixed`
80
+
81
+ Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
82
+ paire `(émetteur, sujet)`, jamais le sujet seul.
83
+
84
+ En mode `prefixed` (`config.ts:650`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
85
+ pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
86
+ identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
87
+ l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
88
+ rattaché au compte local du même nom.
89
+
90
+ `subject` ne se déclare donc que si l'on maîtrise l'espace de noms de cet émetteur : typiquement
91
+ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des identifiants locaux.
92
+
93
+ ### `subjectPolicy` — qui a le droit d'exister
94
+
95
+ - **`require`** (défaut) — le sujet doit correspondre à un compte local (`loadUserByIdentifier`).
96
+ Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
97
+ utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
98
+ - **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
99
+ avec les rôles d'`ephemeralRoles` (`config.ts:672`). C'est le mode de l'appelant **purement machine** : un agent, un
100
+ service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
101
+ — qui viennent du jeton, donc bornés par le serveur d'autorisation.
102
+
103
+ > Écrire un rôle dans `ephemeralRoles` accorde un pouvoir local à quiconque détient un jeton valide
104
+ > pour cette ressource. À faire sciemment, jamais par confort.
105
+
106
+ ## Comment ça s'articule
107
+
108
+ Le service `accessTokenVerifier` n'est posé au conteneur **que si `issuers` n'est pas vide** — il n'y
109
+ a pas de drapeau `enabled` qui permettrait « activé sans émetteur ». Une porte protégée par des
110
+ jetons tiers, dans une application qui n'en déclare aucun, **refuse de servir** plutôt que d'accepter
111
+ des porteurs qu'elle ne sait pas lire.
112
+
113
+ L'authentificateur `external-jwt` (`authenticatorRegistry.ts:118`) reconnaît les jetons qui le
114
+ 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.
115
+ La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
116
+
117
+ ```ts
118
+ firewall: {
119
+ api: {
120
+ pattern: "^/api",
121
+ stateless: true,
122
+ authenticators: ["external-jwt"],
123
+ },
124
+ }
125
+ ```
126
+
127
+ ## Ce que l'application publie d'elle-même
128
+
129
+ Une ressource protégée doit dire **où obtenir un jeton valable pour elle**. C'est l'objet des
130
+ métadonnées de ressource protégée (RFC 9728), servies par l'application, et de l'en-tête
131
+ `WWW-Authenticate` renvoyé sur un refus. Un client conforme y trouve seul l'émetteur à interroger et
132
+ l'audience à demander.
133
+
134
+ ## ⚠️ Pièges
135
+
136
+ - **Une panne de l'émetteur est un `503`, jamais un `401`.** Si les clés publiques sont
137
+ injoignables, l'application ne sait pas si le jeton est valide — répondre « refusé » ferait passer
138
+ une panne d'infrastructure pour un problème d'identifiants, et enverrait le porteur légitime
139
+ chercher au mauvais endroit. Le message de refus est **constant** ; la cause vit dans le journal.
140
+ - **L'audience vient de la ZONE, pas de l'authentificateur.** Elle est exigée au boot
141
+ (`validateArea`) : le pare-feu ne connaît aucun nom en dur, et une zone sans ressource déclarée ne
142
+ démarre pas.
143
+ - **L'ordre des authentificateurs dans la zone ne décide de rien** entre `jwt` et `external-jwt` :
144
+ l'aiguillage se fait sur l'`iss` du jeton. Inutile de les ranger « dans le bon ordre ».
145
+ - **`HS256` est impossible en configuration**, et ce n'est pas un oubli : un secret partagé ferait de
146
+ l'application un émetteur autant qu'un vérificateur. Seules des clés publiques sont acceptées.
147
+ - **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut, et il fait qu'une zone
148
+ protégée par jetons tiers refuse de servir plutôt que d'accepter des porteurs illisibles.
149
+
150
+ ## 📖 Lexique
151
+
152
+ | Terme | Ce que ça désigne ici |
153
+ | ------------------------- | --------------------------------------------------------------------------------------------------- |
154
+ | **Émetteur** (`iss`) | Le serveur d'autorisation qui a signé le jeton. Identifiant en `https`, sans requête ni fragment. |
155
+ | **Audience** (`aud`) | Pour QUI le jeton a été émis. Un jeton destiné à une autre application ne doit pas ouvrir celle-ci. |
156
+ | **Sujet** (`sub`) | Qui est le porteur, **dans l'espace de noms de son émetteur** — jamais unique en soi. |
157
+ | **JWKS** | Le jeu de clés publiques de l'émetteur, qui permet de vérifier la signature sans secret partagé. |
158
+ | **Serveur de ressources** | Le rôle que joue l'application : elle vérifie des jetons qu'elle n'a pas émis (RFC 6750). |
159
+ | **Scope** | Ce que le serveur d'autorisation a autorisé. Distinct d'un **rôle**, qui est une décision locale. |
160
+
161
+ ## 🧪 Tests & couverture
162
+
163
+ - **unit** : `externalJwtAuthenticator` (espace de noms du sujet, refus d'un `iss` hors allowlist),
164
+ `remoteJwtVerifier` (signature, audience, panne de l'émetteur), `protectedResourcePublication` et
165
+ `protectedResourceChain` (ce que la ressource publie d'elle-même, RFC 9728), et
166
+ `protectedResourceRoutes` côté framework ;
167
+ - **intégration, sur un serveur réel** : `external-jwt.test.ts` éprouve la PANNE (l'émetteur ne
168
+ répond pas), `external-jwt-e2e.test.ts` joue la boucle entière — l'application se déclarant
169
+ elle-même émetteur de confiance, ce qu'elle peut faire depuis qu'elle publie ses métadonnées.
170
+
171
+ Les chiffres exacts vivent dans la carte de l'aperçu, régénérée depuis vitest — jamais figés ici.
172
+
173
+ ## Pour aller plus loin
174
+
175
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
176
+ - [Jetons](tokens.md) — l'émission par l'application elle-même, le keystore, la révocation.
177
+ - [OAuth2](oauth2.md) — « se connecter avec GitHub » : un flux d'authentification, pas un serveur de
178
+ ressources.
179
+ - [Clés d'API](api-keys.md) — le porteur opaque, révocable, quand il n'y a pas de serveur
180
+ d'autorisation.
181
+ - [Pare-feu](firewall.md) — zones, `stateless`, ordre des authentificateurs.