@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
@@ -0,0 +1,699 @@
1
+ import { SecuredArea } from "../src/SecuredArea.js";
2
+ import { LoginThrottler } from "../src/throttle/LoginThrottler.js";
3
+ import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
4
+ import { mergeCspFragments } from "../src/csp.js";
5
+ import { CsrfTokenManager } from "../src/csrfToken.js";
6
+ import { CsrfError } from "../errors/CsrfError.js";
7
+ import { Csrf } from "./csrf.js";
8
+ import { Cors } from "./cors.js";
9
+ import { SecurityHeaders } from "./securityHeaders.js";
10
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
11
+ import { ThrottledError } from "../errors/ThrottledError.js";
12
+ import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
13
+ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
14
+ import { getAuthenticatorFactory, listAuthenticatorFactories } from "../src/authenticator/authenticatorRegistry.js";
15
+ import { FirewallRealtimeAuthenticator } from "../src/authenticator/FirewallRealtimeAuthenticator.js";
16
+ import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer, buildSystemRules } from "../src/realtime/frameAuthorizer.js";
17
+ import { recordAudit } from "../src/audit/recordAudit.js";
18
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
19
+ import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
20
+ import { RequestContext, Service, logColor } from "nodefony";
21
+ import { encoderFromConfig } from "@nodefony/user";
22
+ import { randomBytes } from "node:crypto";
23
+ //#region nodefony/service/firewall.ts
24
+ const serviceName = "firewall";
25
+ /**
26
+ * Réponse partagée de {@link Firewall.publishedProtectedResources} quand il n'y
27
+ * a rien à publier — le cas de l'immense majorité des applications. Gelée et
28
+ * réutilisée : allouer un tableau vide pour dire « rien » est exactement le
29
+ * genre de dépense que la règle de lazy-allocation proscrit.
30
+ */
31
+ const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
32
+ function headerValue(headers, name) {
33
+ const v = headers?.[name];
34
+ return Array.isArray(v) ? v[0] : v;
35
+ }
36
+ /**
37
+ * Extrait la valeur d'un cookie de l'en-tête `Cookie` brut (lecture directe, sans
38
+ * dépendre du parse du Context — robuste quel que soit l'ordre du pipeline).
39
+ * Retient la 1ʳᵉ occurrence du nom. Utilisé par la défense synchronizer CSRF.
40
+ */
41
+ function cookieValue(cookieHeader, name) {
42
+ if (!cookieHeader) return void 0;
43
+ for (const part of cookieHeader.split(";")) {
44
+ const eq = part.indexOf("=");
45
+ if (eq === -1) continue;
46
+ if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
47
+ }
48
+ }
49
+ /**
50
+ * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
51
+ *
52
+ * `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
53
+ * zone et poser `context.security`. `handleSecurity()` (lazy, seulement sur une
54
+ * zone protégée) exécute la chaîne d'authentication selon le `mode` de la zone
55
+ * (`first` : le premier qui reconnaît la requête authentifie ; `all` : tous
56
+ * doivent passer, le dernier porte l'identité) → propage l'utilisateur dans
57
+ * l'ALS → applique le **Zero Trust** (zone protégée sans preuve acceptée → 401,
58
+ * sauf anonymat explicite via l'authenticator `anonymous`).
59
+ *
60
+ * **Fail-closed** : config invalide au boot (Zod, nom d'authenticator inconnu)
61
+ * → le firewall capture TOUT le trafic et répond 401 (jamais une app servie
62
+ * sans sa sécurité). Erreur interne pendant l'authentification (source
63
+ * d'identité down, câblage manquant) → log ERROR serveur + 401 générique
64
+ * (aucun détail ne fuite au client).
65
+ *
66
+ * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 7235) fourni
67
+ * par le premier authenticator de la zone qui en déclare un.
68
+ *
69
+ * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
70
+ * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
71
+ */
72
+ var Firewall = class extends Service {
73
+ module;
74
+ #areas = null;
75
+ #authenticators = null;
76
+ #roleHierarchy = null;
77
+ #csrf = null;
78
+ #csrfTokens = null;
79
+ #cors = null;
80
+ #securityHeaders = null;
81
+ #cspFragments = null;
82
+ #config = null;
83
+ #configError = null;
84
+ constructor(module) {
85
+ super(serviceName, module.container, module.notificationsCenter, module.options);
86
+ this.module = module;
87
+ this.kernel?.once("onBoot", () => this.#build());
88
+ }
89
+ #build() {
90
+ try {
91
+ this.#config = defineSecurityConfig(this.options);
92
+ } catch (e) {
93
+ this.#configError = e;
94
+ this.log(`Security configuration INVALID — firewall fail-closed, ALL requests rejected: ${e.message}`, "CRITIC");
95
+ return;
96
+ }
97
+ this.#roleHierarchy = new RoleHierarchyWalker(this.#config.roleHierarchy);
98
+ this.container?.set("roleHierarchy", this.#roleHierarchy);
99
+ this.#provisionSharedServices(this.#config);
100
+ if (this.#config.csrf.enabled) {
101
+ this.#csrf = new Csrf(this.#config.csrf, [...this.#config.csrf.trustedOrigins, ...this.#config.cors.origins]);
102
+ let secret = this.#config.csrf.secret;
103
+ if (!secret) {
104
+ secret = randomBytes(32).toString("base64url");
105
+ this.log("csrf.secret absent → secret synchronizer ÉPHÉMÈRE généré (dev). En PROD/cluster, fixer csrf.secret (≥16 car., partagé entre process) — générer la clé et le câblage : `npx nodefony security:secrets`.", "WARNING");
106
+ }
107
+ this.#csrfTokens = new CsrfTokenManager(secret);
108
+ }
109
+ if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
110
+ if (this.#config.headers.enabled) this.#securityHeaders = new SecurityHeaders(this.#config.headers);
111
+ const areas = this.#config.areas;
112
+ const names = Object.keys(areas);
113
+ if (names.length) {
114
+ const list = [];
115
+ for (const name of names) list.push(new SecuredArea(name, areas[name]));
116
+ list.sort((a, b) => b.pattern.source.length - a.pattern.source.length);
117
+ this.#areas = list;
118
+ this.#instantiateAuthenticators(list);
119
+ }
120
+ if (!this.#configError) {
121
+ this.#wireRealtime();
122
+ this.log(`Firewall ready — ${this.#areas?.length ?? 0} area(s), ${this.#authenticators?.size ?? 0} authenticator(s)`, "DEBUG");
123
+ }
124
+ }
125
+ #wireRealtime() {
126
+ const realtime = this.container?.get("realtimeService");
127
+ if (!realtime) return;
128
+ let wired = false;
129
+ for (const area of this.#areas ?? []) {
130
+ if (!area.security || !area.realtime) continue;
131
+ const matcher = area.host ? {
132
+ pattern: area.pattern,
133
+ host: area.host
134
+ } : { pattern: area.pattern };
135
+ realtime.useAuthenticator(matcher, new FirewallRealtimeAuthenticator(() => this.container?.get("tokenStore") ?? null));
136
+ wired = true;
137
+ }
138
+ const configRules = (this.#config?.realtimeChannels ?? []).map((r) => ({
139
+ prefix: r.pattern,
140
+ policy: {
141
+ authenticated: r.authenticated,
142
+ roles: r.roles,
143
+ scopes: r.scopes
144
+ }
145
+ }));
146
+ const reserved = realtime.reservedSystemPrefixes?.();
147
+ const defaultRules = reserved && reserved.length > 0 ? buildSystemRules(reserved) : DEFAULT_SYSTEM_RULES;
148
+ const systemRules = configRules.length > 0 ? [...configRules, ...defaultRules] : defaultRules;
149
+ const container = this.container;
150
+ realtime.setFrameAuthorizer(buildFrameAuthorizer(this, {
151
+ channelResolver: realtime,
152
+ systemRules,
153
+ onDeny: (_surface, target, reason, token) => recordAudit(container, {
154
+ category: "ws",
155
+ action: "frame.denied",
156
+ outcome: "denied",
157
+ actor: token.getUserIdentifier(),
158
+ resource: target,
159
+ reason
160
+ })
161
+ }), { silentProbe: buildFrameAuthorizer(this, {
162
+ channelResolver: realtime,
163
+ systemRules
164
+ }) });
165
+ const auditSource = this.container?.get("auditService");
166
+ if (auditSource && realtime.registerSystemChannel) realtime.registerSystemChannel(SECURITY_AUDIT_CHANNEL, (ch, publish) => createAuditBridge(auditSource, publish, ch));
167
+ this.log(wired ? "Realtime data plane locked — WS handshake authenticators (RBAC) + frame authorizer + audit channel wired" : "Realtime data plane locked — frame authorizer (system floor) wired without qualifying zone; system channels closed to anonymous", "DEBUG");
168
+ }
169
+ #provisionSharedServices(config) {
170
+ const specs = Object.values(config.encoders);
171
+ if (specs.length > 0) this.container?.set("passwordEncoder", encoderFromConfig(specs));
172
+ const rl = config.rateLimit;
173
+ if (rl.enabled) this.container?.set("loginThrottler", new LoginThrottler({
174
+ freeAttempts: rl.freeAttempts,
175
+ baseDelayS: rl.baseDelayS,
176
+ capDelayS: rl.capDelayS,
177
+ maxTracked: rl.maxTracked
178
+ }));
179
+ }
180
+ #instantiateAuthenticators(areas) {
181
+ for (const area of areas) for (const name of area.authenticators) {
182
+ if (!this.#authenticators?.has(name)) {
183
+ const factory = getAuthenticatorFactory(name);
184
+ if (!factory) {
185
+ this.#configError = /* @__PURE__ */ new Error(`area "${area.name}": authenticator "${name}" unknown — registered: [${listAuthenticatorFactories().join(", ")}]`);
186
+ this.log(`Security configuration INVALID — ${this.#configError.message}`, "CRITIC");
187
+ return;
188
+ }
189
+ this.registerAuthenticator(factory({
190
+ container: this.container,
191
+ config: this.#config
192
+ }));
193
+ }
194
+ const authenticator = this.#authenticators?.get(name);
195
+ try {
196
+ authenticator?.validateArea?.(area);
197
+ } catch (error) {
198
+ this.#configError = error;
199
+ this.log(`Security configuration INVALID — ${error.message}`, "CRITIC");
200
+ return;
201
+ }
202
+ }
203
+ }
204
+ /** Hiérarchie de rôles résolue (niveau A de l'autorisation, P6.8). */
205
+ get roleHierarchy() {
206
+ return this.#roleHierarchy ??= new RoleHierarchyWalker();
207
+ }
208
+ /**
209
+ * `true` si l'un des rôles de l'utilisateur couvre `required` (hiérarchie
210
+ * comprise). Surface lue par le verrou de frame WS ({@link buildFrameAuthorizer})
211
+ * pour le RBAC par canal — délègue au {@link RoleHierarchyWalker}.
212
+ */
213
+ hasRole(userRoles, required) {
214
+ return this.roleHierarchy.hasRole(userRoles, required);
215
+ }
216
+ registerAuthenticator(authenticator) {
217
+ (this.#authenticators ??= /* @__PURE__ */ new Map()).set(authenticator.name, authenticator);
218
+ }
219
+ getArea(name) {
220
+ return this.#areas?.find((a) => a.name === name);
221
+ }
222
+ /**
223
+ * Projection LECTURE SEULE de l'état RUNTIME (data plane Studio P6.15) — décrit
224
+ * ce qui TOURNE (zones montées, authenticators instanciés, défenses résolues),
225
+ * pas la config brute. **Secrets exclus par construction** (le synchronizer CSRF
226
+ * et la clé JWT ne sont JAMAIS exposés — on remonte leur PRÉSENCE, pas leur
227
+ * valeur, comme le journal d'audit). Cold-path admin (lecture rare) → 0
228
+ * contrainte hot-path.
229
+ */
230
+ /**
231
+ * État RÉEL des trois en-têtes de transport (HSTS, X-Frame-Options,
232
+ * X-Content-Type-Options), lu chez celui qui les émet : `@nodefony/http`.
233
+ *
234
+ * Pourquoi ne pas lire la config `security` : ses clés `hsts`/`frameguard`/
235
+ * `noSniff` sont conservées pour la compatibilité mais **inertes** — leur
236
+ * `.meta({ reserved: true })` le dit. Une console d'administration qui affiche
237
+ * une valeur inerte comme si elle était appliquée ne se contente pas d'être
238
+ * inexacte : elle donne à l'exploitant une fausse assurance sur sa défense.
239
+ *
240
+ * @param config - config security, utilisée en repli si le HttpKernel est absent.
241
+ */
242
+ #transportHeaderState(config) {
243
+ const emitted = (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
244
+ if (!emitted) return {
245
+ hsts: config.headers.hsts,
246
+ hstsMaxAgeS: config.headers.hstsMaxAgeS,
247
+ frameguard: config.headers.frameguard,
248
+ noSniff: config.headers.noSniff
249
+ };
250
+ const hstsHeader = emitted.strictTransportSecurity;
251
+ return {
252
+ hsts: hstsHeader !== null,
253
+ hstsMaxAgeS: hstsHeader ? Number.parseInt(/max-age=(\d+)/.exec(hstsHeader)?.[1] ?? "0", 10) : 0,
254
+ frameguard: (emitted.frameOptions ?? "").toUpperCase() === "SAMEORIGIN" ? "sameorigin" : "deny",
255
+ noSniff: emitted.contentTypeOptions !== null
256
+ };
257
+ }
258
+ describe() {
259
+ const config = this.#config;
260
+ const mounted = this.#authenticators;
261
+ const zones = (this.#areas ?? []).map((a) => ({
262
+ name: a.name,
263
+ pattern: a.pattern.source,
264
+ security: a.security,
265
+ stateless: a.stateless,
266
+ mode: a.mode,
267
+ authenticators: [...a.authenticators],
268
+ allowsAnonymous: a.authenticators.includes("anonymous"),
269
+ host: a.host ?? null,
270
+ realtime: a.realtime
271
+ }));
272
+ const available = new Set(listAuthenticatorFactories());
273
+ const names = new Set(available);
274
+ if (mounted) for (const n of mounted.keys()) names.add(n);
275
+ const authenticators = [...names].sort().map((name) => ({
276
+ name,
277
+ mounted: mounted?.has(name) ?? false,
278
+ available: available.has(name),
279
+ challenge: typeof mounted?.get(name)?.challenge === "function"
280
+ }));
281
+ return {
282
+ configValid: !this.#configError,
283
+ configError: this.#configError ? this.#configError.message : null,
284
+ zones,
285
+ authenticators,
286
+ defenses: config ? this.#describeDefenses(config) : null
287
+ };
288
+ }
289
+ #describeDefenses(config) {
290
+ return {
291
+ csrf: {
292
+ enabled: config.csrf.enabled,
293
+ fetchMetadata: config.csrf.fetchMetadata,
294
+ checkOrigin: config.csrf.checkOrigin,
295
+ strictSameSite: config.csrf.strictSameSite,
296
+ sameSite: config.csrf.sameSite,
297
+ trustedOrigins: [...config.csrf.trustedOrigins],
298
+ synchronizerToken: this.#csrfTokens !== null
299
+ },
300
+ cors: {
301
+ enabled: config.cors.enabled,
302
+ origins: [...config.cors.origins],
303
+ credentials: config.cors.credentials,
304
+ methods: [...config.cors.methods],
305
+ allowedHeaders: [...config.cors.allowedHeaders],
306
+ exposedHeaders: [...config.cors.exposedHeaders],
307
+ maxAgeS: config.cors.maxAgeS
308
+ },
309
+ headers: {
310
+ enabled: config.headers.enabled,
311
+ ...this.#transportHeaderState(config),
312
+ csp: config.headers.csp,
313
+ cspNonces: config.headers.cspNonces,
314
+ referrerPolicy: config.headers.referrerPolicy,
315
+ coop: config.headers.coop,
316
+ coep: config.headers.coep,
317
+ corp: config.headers.corp,
318
+ originAgentCluster: config.headers.originAgentCluster,
319
+ permissionsPolicy: config.headers.permissionsPolicy
320
+ },
321
+ rateLimit: {
322
+ enabled: config.rateLimit.enabled,
323
+ freeAttempts: config.rateLimit.freeAttempts,
324
+ baseDelayS: config.rateLimit.baseDelayS,
325
+ capDelayS: config.rateLimit.capDelayS
326
+ }
327
+ };
328
+ }
329
+ /**
330
+ * Hiérarchie de rôles déclarée + résolution transitive (data plane Studio).
331
+ * Brut = ce que l'app a écrit ; `inherits` = aplati précalculé par le walker.
332
+ */
333
+ describeRoleHierarchy() {
334
+ const raw = this.#config?.roleHierarchy ?? {};
335
+ const hierarchy = {};
336
+ for (const role of Object.keys(raw)) hierarchy[role] = [...raw[role]];
337
+ const walker = this.roleHierarchy;
338
+ return {
339
+ hierarchy,
340
+ roles: Object.keys(hierarchy).map((role) => ({
341
+ role,
342
+ inherits: [...walker.reachableRoles([role])].filter((r) => r !== role).sort()
343
+ }))
344
+ };
345
+ }
346
+ /**
347
+ * Ce que l'application PROTÈGE, à publier en RFC 9728 — une entrée par
348
+ * ressource déclarée par une zone.
349
+ *
350
+ * ⭐ **Même donnée que le défi, donc impossible qu'ils divergent.** Le `401`
351
+ * d'une zone porte `resource_metadata="…/.well-known/oauth-protected-resource
352
+ * /<chemin de `area.resource`>"` ; ce que rend cette méthode est la source de
353
+ * ce que `@nodefony/framework` monte à cette URL. Une seconde déclaration —
354
+ * une clé « ressources publiées » à côté des zones — se serait périmée au
355
+ * premier renommage, et le symptôme aurait été un `404` que rien n'explique.
356
+ *
357
+ * 🔴 **Les serveurs d'autorisation sont les émetteurs de confiance, pas une
358
+ * liste à part.** `authorization_servers` répond à « qui peut délivrer un
359
+ * jeton pour cette ressource ? » — c'est exactement l'allowlist
360
+ * `resourceServer.issuers`, la seule que le vérificateur consulte. Publier
361
+ * autre chose reviendrait à envoyer le client demander un jeton à un émetteur
362
+ * dont on refuse ensuite la signature.
363
+ *
364
+ * Aucun émetteur de confiance ⇒ **rien à publier** : un document sans serveur
365
+ * d'autorisation apprendrait au client qu'un jeton est nécessaire sans jamais
366
+ * lui dire où l'obtenir (et la RFC 9728 comme la spécification MCP l'excluent).
367
+ *
368
+ * Appelée UNE fois, au montage des routes (`onKernelReady`) — hors hot path,
369
+ * d'où l'allocation directe plutôt qu'un cache à invalider.
370
+ *
371
+ * @returns les ressources protégées déclarées, éventuellement vide
372
+ */
373
+ publishedProtectedResources() {
374
+ const config = this.#config;
375
+ if (!config) return EMPTY_PROTECTED_RESOURCES;
376
+ const authorizationServers = config.resourceServer.issuers.map((i) => i.issuer).filter((issuer) => typeof issuer === "string" && issuer.length > 0);
377
+ if (authorizationServers.length === 0) return EMPTY_PROTECTED_RESOURCES;
378
+ const published = [];
379
+ const seen = /* @__PURE__ */ new Set();
380
+ for (const area of this.#areas ?? []) {
381
+ if (!area.resource || seen.has(area.resource)) continue;
382
+ seen.add(area.resource);
383
+ published.push({
384
+ resource: area.resource,
385
+ authorizationServers
386
+ });
387
+ }
388
+ return published.length > 0 ? published : EMPTY_PROTECTED_RESOURCES;
389
+ }
390
+ /**
391
+ * Match de zone par pathname (+ host) SANS contexte — source UNIQUE consultée
392
+ * par `isSecure` (HTTP) ET le verrou WebSocket (la frame `api.request` n'a
393
+ * qu'un path). Hot-path : patterns pré-compilés + pathname fourni → 0 alloc.
394
+ */
395
+ matchPath(pathname, host) {
396
+ if (!this.#areas) return null;
397
+ for (const area of this.#areas) if (area.matchPath(pathname, host)) return area;
398
+ return null;
399
+ }
400
+ /** Match rapide de zone — pose `context.security`. `true` si zone capturée. */
401
+ isSecure(context) {
402
+ if (this.#configError) return true;
403
+ if (!this.#areas) return false;
404
+ const req = context.request;
405
+ if (!req) return false;
406
+ const rp = req.pathname;
407
+ let pathname;
408
+ if (typeof rp === "string") pathname = rp;
409
+ else {
410
+ if (!req.url) return false;
411
+ pathname = req.url instanceof URL ? req.url.pathname : String(req.url);
412
+ }
413
+ const area = this.matchPath(pathname, context.domain);
414
+ if (area) {
415
+ context.security = area;
416
+ return true;
417
+ }
418
+ return false;
419
+ }
420
+ /**
421
+ * Pipeline complet de la zone : chaîne d'authenticators (selon `mode`) → ALS
422
+ * → Zero Trust. Rejette (401, challenge RFC 7235 posé) ou résout.
423
+ */
424
+ async handleSecurity(context) {
425
+ if (this.#configError) throw new AuthenticationError("Security configuration invalid");
426
+ const area = context.security;
427
+ if (!area || !area.security) return context;
428
+ const bypass = context.resolver?.bypassFirewall;
429
+ const trace = this.#startTrace(context);
430
+ if (bypass) {
431
+ if (trace) trace.outcome = "bypass";
432
+ return context;
433
+ }
434
+ let token;
435
+ try {
436
+ token = await this.#authenticate(context, area, trace);
437
+ } catch (error) {
438
+ if (error instanceof ThrottledError) {
439
+ context.response?.setHeader("Retry-After", String(error.retryAfterS));
440
+ this.#recordAuth(context, area, "auth.throttled", "failure", "throttled", null);
441
+ throw error;
442
+ }
443
+ if (error instanceof UnverifiableTokenError) {
444
+ this.#recordAuth(context, area, "auth.unverifiable", "failure", "verifier_unavailable", null);
445
+ throw error;
446
+ }
447
+ this.#setChallenge(context, area);
448
+ this.#recordAuth(context, area, "auth.failure", "failure", "invalid_credentials", null);
449
+ throw error;
450
+ }
451
+ if (token === null) {
452
+ this.#setChallenge(context, area);
453
+ this.#recordAuth(context, area, "auth.denied", "denied", "no_credentials", null);
454
+ throw new AuthenticationError(`Authentication required for area "${area.name}"`);
455
+ }
456
+ RequestContext.set("user", token.getUser());
457
+ RequestContext.set("token", token);
458
+ if (!token.isAuthenticated() && token.type !== "anonymous") {
459
+ this.#setChallenge(context, area);
460
+ this.#recordAuth(context, area, "auth.denied", "denied", "unauthenticated", token.getUserIdentifier());
461
+ throw new AuthenticationError(`Authentication required for area "${area.name}"`);
462
+ }
463
+ if (trace) {
464
+ trace.outcome = token.isAuthenticated() ? "granted" : "anonymous";
465
+ trace.user = token.getUserIdentifier();
466
+ trace.roles = token.getRoles();
467
+ }
468
+ return context;
469
+ }
470
+ /**
471
+ * Ouvre la trace de décision de la zone — **dev-only**, gratuit en production.
472
+ *
473
+ * L'allocation est conditionnée au témoin `context.profiling` (posé par le
474
+ * HttpKernel quand le Profiler est actif, donc jamais en prod) : sur le
475
+ * hot-path nominal, ceci coûte une lecture de booléen.
476
+ *
477
+ * @returns la trace fraîche (posée sur le context), ou `null` hors profiling.
478
+ */
479
+ #startTrace(context) {
480
+ const ctx = context;
481
+ if (!ctx.profiling) return null;
482
+ const trace = {
483
+ authenticator: null,
484
+ outcome: "denied",
485
+ reason: null,
486
+ user: null,
487
+ roles: null
488
+ };
489
+ ctx.securityTrace = trace;
490
+ return trace;
491
+ }
492
+ #recordAuth(context, area, action, outcome, reason, actor) {
493
+ const trace = context.securityTrace;
494
+ if (trace) {
495
+ trace.outcome = reason === "throttled" ? "throttled" : outcome;
496
+ trace.reason = reason;
497
+ trace.user = actor;
498
+ }
499
+ recordAudit(this.container, {
500
+ category: "auth",
501
+ action,
502
+ outcome,
503
+ actor,
504
+ resource: area.name,
505
+ reason,
506
+ ...readAuditContext(context)
507
+ });
508
+ }
509
+ /**
510
+ * Défense CSRF (P6 J5/étape 2) — branchée dans le pipeline HTTP de `@nodefony/http`
511
+ * pour TOUTE requête (zone ou non), APRÈS le resolve (les marqueurs `@CsrfProtect`/
512
+ * `@CsrfExempt` de la route sont disponibles). Trois rôles :
513
+ *
514
+ * - **Émission** : sur une requête SÛRE vers une route `@CsrfProtect`, minte le
515
+ * synchronizer token (`context.csrfToken`) — HttpContext pose ensuite le cookie
516
+ * lisible `csrf-token`. Sinon, hot-path GET = retour immédiat (aucun en-tête lu).
517
+ * - **Étape 1 (globale)** : sur une mutation, défense Fetch Metadata / Origin
518
+ * (rejet cross-site même sur route publique). Skippée si `@CsrfExempt` (webhook,
519
+ * auth par signature/clé) ou `bypassFirewall` (callbacks OAuth).
520
+ * - **Étape 2 (opt-in)** : sur une mutation `@CsrfProtect`, exige EN PLUS le
521
+ * synchronizer token (en-tête `x-csrf-token` ≡ cookie + HMAC valide).
522
+ *
523
+ * @throws CsrfError (403) — provenance tierce, ou synchronizer token absent/invalide.
524
+ */
525
+ enforceCsrf(context) {
526
+ if (!this.#csrf) return;
527
+ if (context.resolver?.bypassFirewall) return;
528
+ const ctx = context;
529
+ if (!Csrf.isStateChanging(context.method)) {
530
+ if (ctx.csrfProtect && this.#csrfTokens && ctx.csrfToken == null) ctx.csrfToken = this.#csrfTokens.issue();
531
+ return;
532
+ }
533
+ if (ctx.csrfExempt) return;
534
+ const headers = context.request?.headers;
535
+ this.#csrf.enforce({
536
+ method: context.method,
537
+ secFetchSite: headerValue(headers, "sec-fetch-site"),
538
+ origin: headerValue(headers, "origin"),
539
+ referer: headerValue(headers, "referer"),
540
+ host: headerValue(headers, "host") ?? headerValue(headers, ":authority") ?? context.domain
541
+ });
542
+ if (ctx.csrfProtect && this.#csrfTokens) {
543
+ if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), cookieValue(headerValue(headers, "cookie"), "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
544
+ }
545
+ }
546
+ /**
547
+ * Politique CORS (P6 J5) — appelée par `HttpKernel.handleHttp()`
548
+ * (`http-kernel.ts:1169`), en TÊTE du pipeline : avant le routing, avant le parse
549
+ * du corps, donc bien avant `handleFrontController` et le firewall. Un preflight
550
+ * n'a pas de route déclarée — router d'abord lèverait un 405.
551
+ * Pose les en-têtes `Access-Control-*` et **court-circuite le
552
+ * preflight** `OPTIONS` en `204` (le preflight ne porte jamais de credentials,
553
+ * Fetch Standard → il ne doit ni router ni s'authentifier). No-op hors requête
554
+ * cross-origin (pas d'`Origin`), CORS désactivé, ou réponse non-HTTP (WS).
555
+ *
556
+ * @returns `204` si la requête est un preflight (l'appelant court-circuite la
557
+ * réponse), sinon `undefined` (la requête réelle suit le pipeline normal).
558
+ */
559
+ handleCors(context) {
560
+ if (!this.#cors) return void 0;
561
+ const headers = context.request?.headers;
562
+ const origin = headerValue(headers, "origin");
563
+ if (!origin) return void 0;
564
+ const response = context.response;
565
+ if (typeof response?.setHeader !== "function") return void 0;
566
+ const isPreflight = context.method?.toUpperCase() === "OPTIONS" && headerValue(headers, "access-control-request-method") !== void 0;
567
+ const corsHeaders = isPreflight ? this.#cors.preflightHeaders(origin) : this.#cors.actualHeaders(origin);
568
+ if (corsHeaders) for (const name in corsHeaders) response.setHeader(name, corsHeaders[name]);
569
+ return isPreflight ? 204 : void 0;
570
+ }
571
+ /**
572
+ * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
573
+ * cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
574
+ * Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
575
+ * socle transport de `@nodefony/http` (nosniff/frame/HSTS, posé à l'entrée brute)
576
+ * SANS le ré-émettre. No-op si désactivé ou réponse non-HTTP (WS).
577
+ *
578
+ * En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
579
+ * nonce/req (étape B) ajoute 1 `join` + 1 `setHeader` UNIQUEMENT si activé.
580
+ */
581
+ applySecurityHeaders(context) {
582
+ const sh = this.#securityHeaders;
583
+ if (!sh) return;
584
+ const response = context.response;
585
+ if (typeof response?.setHeader !== "function") return;
586
+ const headers = sh.headers;
587
+ for (const name in headers) response.setHeader(name, headers[name]);
588
+ const extra = context.cspDirectives;
589
+ if (sh.hasNonce) {
590
+ const nonce = context.cspNonce;
591
+ response.setHeader("Content-Security-Policy", extra ? sh.cspForExtra(nonce, extra) : sh.cspFor(nonce));
592
+ } else if (extra) response.setHeader("Content-Security-Policy", sh.cspForExtra("", extra));
593
+ }
594
+ /**
595
+ * Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
596
+ * No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
597
+ * Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
598
+ */
599
+ registerCspOrigins(moduleName, fragment) {
600
+ if (!this.#securityHeaders || !this.#config) return;
601
+ (this.#cspFragments ??= /* @__PURE__ */ new Map()).set(moduleName, fragment);
602
+ this.#rebuildSecurityHeaders();
603
+ }
604
+ /** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
605
+ unregisterCspOrigins(moduleName) {
606
+ if (!this.#cspFragments?.delete(moduleName)) return;
607
+ this.#rebuildSecurityHeaders();
608
+ }
609
+ /**
610
+ * Reconstruit `SecurityHeaders` depuis le CSP de base mergé avec les fragments
611
+ * enregistrés. Appelé UNIQUEMENT au (dé)enregistrement d'un module (jamais par
612
+ * requête) → parse/merge/serialize amortis hors hot-path. Repart toujours de
613
+ * `headers.csp` (base) → idempotent (pas de merge sur du déjà-mergé).
614
+ */
615
+ #rebuildSecurityHeaders() {
616
+ const headers = this.#config?.headers;
617
+ if (!headers) return;
618
+ const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(headers.csp, this.#cspFragments.values()) : headers.csp;
619
+ this.#securityHeaders = new SecurityHeaders({
620
+ ...headers,
621
+ csp
622
+ });
623
+ }
624
+ /**
625
+ * Exécute la chaîne d'authenticators de la zone selon son `mode`.
626
+ *
627
+ * `first` : le premier dont `supports()` est vrai authentifie — un credential
628
+ * PRÉSENTÉ mais invalide échoue immédiatement (jamais de fallback silencieux
629
+ * vers le maillon suivant). Aucune preuve présentée → `null`.
630
+ *
631
+ * `all` : tous les maillons doivent supporter ET authentifier (MFA) ; le
632
+ * DERNIER token porte l'identité. Une preuve manquante = 401.
633
+ *
634
+ * @param trace - radiographie dev-only ; reçoit le NOM du maillon qui a
635
+ * résolu l'identité (`null` en prod → aucune écriture).
636
+ * @returns le token accepté, ou `null` si aucune preuve n'a été présentée.
637
+ * @throws AuthenticationError (401) — credential invalide ou preuve manquante
638
+ * (mode `all`). Toute erreur interne est logguée ERROR puis wrappée 401
639
+ * fail-closed (rien ne fuite au client).
640
+ */
641
+ async #authenticate(context, area, trace = null) {
642
+ let token = null;
643
+ for (const name of area.authenticators) {
644
+ const authenticator = this.#authenticators?.get(name);
645
+ if (!authenticator) {
646
+ this.log(`authenticator "${name}" not registered`, "ERROR");
647
+ throw new AuthenticationError(`Authentication required for area "${area.name}"`);
648
+ }
649
+ if (!authenticator.supports(context)) {
650
+ if (area.mode === "first") continue;
651
+ throw new AuthenticationError(`Authentication required for area "${area.name}"`);
652
+ }
653
+ const created = await authenticator.createToken(context);
654
+ let authenticated;
655
+ try {
656
+ authenticated = await authenticator.authenticate(created);
657
+ } catch (error) {
658
+ await authenticator.onFailure(context, error);
659
+ if (error instanceof AuthenticationError) {
660
+ this.log(`authentication failed (area "${area.name}", authenticator "${name}")`, "WARNING");
661
+ throw error;
662
+ }
663
+ if (error instanceof ThrottledError) {
664
+ this.log(`login throttled (area "${area.name}", authenticator "${name}", retry in ${error.retryAfterS}s)`, "WARNING");
665
+ throw error;
666
+ }
667
+ if (error instanceof UnverifiableTokenError) {
668
+ this.log(`token unverifiable (area "${area.name}", authenticator "${name}") — ${error.detail ?? "cause non renseignée"}`, "ERROR");
669
+ this.log(error, "ERROR");
670
+ throw error;
671
+ }
672
+ this.log(error, "ERROR");
673
+ throw new AuthenticationError("Authentication failed");
674
+ }
675
+ await authenticator.onSuccess(context, authenticated);
676
+ if (trace) trace.authenticator = name;
677
+ if (area.mode === "first") return authenticated;
678
+ token = authenticated;
679
+ }
680
+ return token;
681
+ }
682
+ #setChallenge(context, area) {
683
+ const response = context.response;
684
+ if (typeof response?.setHeader !== "function") return;
685
+ for (const name of area.authenticators) {
686
+ const challenge = this.#authenticators?.get(name)?.challenge?.(area);
687
+ if (challenge) {
688
+ response.setHeader("WWW-Authenticate", challenge);
689
+ return;
690
+ }
691
+ }
692
+ }
693
+ log(pci, severity, msgid, msg) {
694
+ if (!msgid) msgid = logColor.cyan("FIREWALL");
695
+ return super.log(pci, severity, msgid, msg);
696
+ }
697
+ };
698
+ //#endregion
699
+ export { Firewall, Firewall as default };