@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,279 @@
1
+ import { NODEFONY_CHANNEL_NAMESPACE, PLATFORM_CHANNELS, PLATFORM_INBOUND, startsWithCI } from "nodefony";
2
+ //#region nodefony/src/realtime/frameAuthorizer.ts
3
+ /**
4
+ * Politique système par défaut des canaux d'**introspection serveur** : réservés
5
+ * aux administrateurs. DURCISSEMENT Zero Trust (P6) : avant, « authentifié
6
+ * suffisait » (tout `ROLE_USER` lisait `nodefony:syslog`) ; désormais `ROLE_ADMIN`.
7
+ * Surchargeable finement par `realtimeChannels` (ex. `ROLE_SECURITY_AUDITOR`).
8
+ */
9
+ const SYSTEM_CHANNEL_POLICY = {
10
+ authenticated: true,
11
+ roles: ["ROLE_ADMIN"]
12
+ };
13
+ /**
14
+ * Namespace d'introspection serveur (observabilité) — s'y abonner expose l'état
15
+ * interne du pod : journaux (`nodefony:syslog`), base (`nodefony:orm:*`), métriques
16
+ * et supervision (`nodefony:dashboard`, `nodefony:supervision@<pid>`), sonde de la
17
+ * socket (`nodefony:socket`), contrôle du pod (`nodefony:kernel:gc` force un GC
18
+ * bloquant). Liste extensible via la config. Convention transverse Nodefony :
19
+ * `<module>:health` / `<module>:stats` (gérée à part dans {@link matchSystemPolicy}).
20
+ *
21
+ * ⚠️ Couplage ASSUMÉ : security connaît le namespace système de la plateforme
22
+ * (c'est son rôle de définir la politique) — mais il ne le REDÉCLARE pas : la
23
+ * constante vient du cœur, comme côté hub.
24
+ */
25
+ const DEFAULT_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
26
+ /**
27
+ * Plancher des canaux de **sécurité** (`nodefony:audit`, P6.14 lot 4) : réservé au
28
+ * super-admin Nodefony (`ROLE_NODEFONY_ADMIN`) — un cran AU-DESSUS du plancher
29
+ * d'observabilité générique (`ROLE_ADMIN`). Le journal d'audit du pod ne se lit
30
+ * pas avec un simple rôle admin applicatif. Cohérent avec le data plane HTTP de
31
+ * l'audit (`SecurityAdminApi`, lot 3, même rôle).
32
+ *
33
+ * Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
34
+ * jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
35
+ * pour permettre un filtrage par tenant quand le chantier multi-tenant arrivera.
36
+ */
37
+ const SECURITY_CHANNEL_POLICY = {
38
+ authenticated: true,
39
+ roles: ["ROLE_NODEFONY_ADMIN"]
40
+ };
41
+ /**
42
+ * Politique du canal **MONTANT** des journaux du navigateur
43
+ * ({@link PLATFORM_INBOUND.syslogUplink}) : authentifié, sans rôle particulier.
44
+ *
45
+ * Pourquoi il échappe à {@link SYSTEM_CHANNEL_POLICY} alors qu'il porte la même marque :
46
+ * ce plancher-là protège la **lecture** de l'état interne du pod — s'abonner à
47
+ * `nodefony:syslog` fait sortir les journaux du serveur. Le canal montant ne rend rien ;
48
+ * il ACCEPTE. Lui demander `ROLE_ADMIN` ne le rendrait pas plus sûr, cela le rendrait
49
+ * inutile : on ne recueillerait que les erreurs survenues chez les administrateurs, quand
50
+ * tout l'intérêt est de voir celles que subissent les utilisateurs.
51
+ *
52
+ * Les dangers propres à une surface d'écriture — noyer le journal, y fabriquer des
53
+ * pistes — se traitent là où ils se posent : origine forcée par le serveur, débit et
54
+ * taille bornés par connexion, sévérité plafonnée (`createSyslogUplinkHandler`).
55
+ *
56
+ * Le plancher irréductible reste respecté, et non contourné : `authenticated` est exigé,
57
+ * donc une connexion ANONYME ne pousse rien. Limite assumée, à connaître avant de
58
+ * chercher un journal qui n'existe pas : **les erreurs d'un visiteur non connecté ne
59
+ * remontent pas** — celles de la page de connexion, notamment.
60
+ */
61
+ const UPLINK_CHANNEL_POLICY = { authenticated: true };
62
+ /**
63
+ * F2 (revue 0.6) — PLANCHER IRRÉDUCTIBLE du namespace réservé plateforme. Il
64
+ * couvre tout ce qui expose l'état interne du pod (logs, audit, métriques,
65
+ * requêtes, supervision) : une règle de config `realtimeChannels` (placée AVANT
66
+ * les défauts, 1ᵉʳ match gagne) pourrait sinon l'OUVRIR à l'anonyme
67
+ * (`{ authenticated:false }` ou policy vide). Le plancher garantit qu'un canal de
68
+ * ce namespace exige TOUJOURS au moins `authenticated` — la config peut RESSERRER
69
+ * (rôle/scope) ou re-cibler le rôle, jamais DESCENDRE sous authenticated. Le canal
70
+ * d'audit en fait partie (son défaut ROLE_NODEFONY_ADMIN est déjà au-dessus, mais
71
+ * le plancher le blinde contre une surcharge de config). Défense structurelle,
72
+ * fail-closed (cf F1 fail-loud).
73
+ */
74
+ const RESERVED_FLOOR_PREFIXES = DEFAULT_SYSTEM_PREFIXES;
75
+ /**
76
+ * Règles système par défaut. Le canal d'audit est placé EN TÊTE (1ᵉʳ match gagne)
77
+ * avec son plancher super-admin propre ; le reste du namespace plateforme hérite de
78
+ * {@link SYSTEM_CHANNEL_POLICY}. Le firewall y préfixe les règles issues de la
79
+ * config (qui gagnent par ordre).
80
+ */
81
+ const DEFAULT_SYSTEM_RULES = buildSystemRules(RESERVED_FLOOR_PREFIXES);
82
+ /**
83
+ * Construit les règles système à partir d'une liste de namespaces réservés.
84
+ *
85
+ * Sépare la LISTE (quels namespaces sont réservés — propriété du hub realtime,
86
+ * qui sert ces canaux) de la POLITIQUE (quels droits — propriété de la sécurité).
87
+ * Le firewall appelle donc cette fabrique avec la liste que le hub lui donne, et
88
+ * ne redéclare rien : un namespace ajouté côté realtime hérite automatiquement
89
+ * d'une politique, au lieu de rester ouvert sans que personne ne le remarque.
90
+ *
91
+ * Le canal d'audit est placé EN TÊTE (premier match gagnant) : son plancher est
92
+ * plus haut que celui du reste de l'observabilité. Il n'est pas un namespace mais
93
+ * un canal précis — sa règle n'est donc posée que si la liste reçue le COUVRE : si
94
+ * le hub cessait un jour de réserver ce territoire, la sécurité cesserait avec lui
95
+ * de prétendre l'arbitrer, au lieu de garder une règle orpheline.
96
+ *
97
+ * @param prefixes - namespaces réservés (ordre indifférent).
98
+ * @returns les règles, canal d'audit d'abord.
99
+ */
100
+ function buildSystemRules(prefixes) {
101
+ const rules = [];
102
+ const audit = PLATFORM_CHANNELS.audit;
103
+ if (prefixes.some((prefix) => startsWithCI(audit, prefix))) rules.push({
104
+ prefix: audit,
105
+ policy: SECURITY_CHANNEL_POLICY
106
+ });
107
+ const uplink = PLATFORM_INBOUND.syslogUplink;
108
+ if (prefixes.some((prefix) => startsWithCI(uplink, prefix))) rules.push({
109
+ prefix: uplink,
110
+ policy: UPLINK_CHANNEL_POLICY
111
+ });
112
+ for (const prefix of prefixes) rules.push({
113
+ prefix,
114
+ policy: SYSTEM_CHANNEL_POLICY
115
+ });
116
+ return rules;
117
+ }
118
+ /**
119
+ * `s` contient-il `needle`, insensible à la casse et sans allocation ? Pour les
120
+ * conventions transverses `:health`/`:stats` (après le namespace de module —
121
+ * `mymod:health` — éventuellement suffixées d'une cadence `nodefony:orm:health:5000`).
122
+ */
123
+ function containsCI(s, needle) {
124
+ const last = s.length - needle.length;
125
+ for (let i = 0; i <= last; i++) {
126
+ let ok = true;
127
+ for (let j = 0; j < needle.length; j++) {
128
+ let a = s.charCodeAt(i + j);
129
+ if (a >= 65 && a <= 90) a += 32;
130
+ let b = needle.charCodeAt(j);
131
+ if (b >= 65 && b <= 90) b += 32;
132
+ if (a !== b) {
133
+ ok = false;
134
+ break;
135
+ }
136
+ }
137
+ if (ok) return true;
138
+ }
139
+ return false;
140
+ }
141
+ /**
142
+ * Politique système applicable à un canal, ou `null` si le canal n'est pas
143
+ * réservé. Premier préfixe qui matche gagne (la config est placée AVANT les
144
+ * défauts par le firewall → elle peut surcharger). Les conventions transverses
145
+ * `:health`/`:stats` retombent sur la politique système par défaut. Le match est
146
+ * INSENSIBLE À LA CASSE (cf {@link startsWithCI}) : le plancher ne se contourne
147
+ * pas en altérant la casse du namespace.
148
+ */
149
+ function matchSystemPolicy(channel, rules) {
150
+ for (let i = 0; i < rules.length; i++) if (startsWithCI(channel, rules[i].prefix)) return floorReserved(channel, rules[i].policy);
151
+ if (containsCI(channel, ":health") || containsCI(channel, ":stats")) return SYSTEM_CHANNEL_POLICY;
152
+ return null;
153
+ }
154
+ /**
155
+ * F2 (revue 0.6) — applique le PLANCHER irréductible : si `channel` est dans un
156
+ * namespace réservé plateforme ({@link RESERVED_FLOOR_PREFIXES}), l'autorisation
157
+ * effective exige AU MOINS `authenticated`, même si la `policy` (issue d'une règle
158
+ * de config) tente de l'ouvrir. Basé sur le namespace du CANAL, PAS sur le prefixe
159
+ * de la règle qui a matché → pas de contournement via un prefixe de config plus
160
+ * court/altéré (`{ prefix:"sec", authenticated:false }` sur `nodefony:audit`).
161
+ * `policy.authenticated` déjà vrai (cas nominal, défauts + `:health`) → retour tel
162
+ * quel, 0 allocation. N'alloue que sur une config qui tente de DESSERRER un
163
+ * namespace réservé (cold path de misconfiguration).
164
+ */
165
+ function floorReserved(channel, policy) {
166
+ if (policy.authenticated) return policy;
167
+ for (let i = 0; i < RESERVED_FLOOR_PREFIXES.length; i++) if (startsWithCI(channel, RESERVED_FLOOR_PREFIXES[i])) return {
168
+ ...policy,
169
+ authenticated: true
170
+ };
171
+ return policy;
172
+ }
173
+ /**
174
+ * Le token satisfait-il la politique ? Contraintes cumulatives (ET) ; un champ
175
+ * absent = pas de contrainte sur cet axe. SYNC, 0 lecture base (lit le token déjà
176
+ * résolu au handshake).
177
+ * - `authenticated` : token non anonyme.
178
+ * - `roles` : l'un des rôles requis, hiérarchie comprise (un anonyme `[]` échoue).
179
+ * - `scopes` : l'un des scopes requis (session BFF n'en porte pas → refus).
180
+ */
181
+ function satisfies(policy, token, firewall) {
182
+ if (policy.authenticated && !token.isAuthenticated()) return false;
183
+ if (policy.roles && policy.roles.length > 0) {
184
+ const userRoles = token.getRoles();
185
+ let granted = false;
186
+ for (let i = 0; i < policy.roles.length; i++) if (firewall.hasRole(userRoles, policy.roles[i])) {
187
+ granted = true;
188
+ break;
189
+ }
190
+ if (!granted) return false;
191
+ }
192
+ if (policy.scopes && policy.scopes.length > 0) {
193
+ const userScopes = token.getScopes();
194
+ let granted = false;
195
+ for (let i = 0; i < policy.scopes.length; i++) if (userScopes.includes(policy.scopes[i])) {
196
+ granted = true;
197
+ break;
198
+ }
199
+ if (!granted) return false;
200
+ }
201
+ return true;
202
+ }
203
+ /**
204
+ * Verrou `api.request {path}` — vérifie l'autorisation de ZONE du pathname (le
205
+ * MÊME re-match de zone qu'un `GET {path}` ; ne regarde PAS la méthode logique).
206
+ * Une zone protégée + un token anonyme = refus. Les mutations (POST/PUT/PATCH/
207
+ * DELETE) restent possibles via le pont MAIS seulement si la route déclare le
208
+ * transport WEBSOCKET + `methodOverride` (une route REST HTTP-only = 405,
209
+ * inatteignable), et sont gardées EN PLUS par `@IsGranted` + clé d'idempotence au
210
+ * data plane. Le verrou n'accorde donc que le plancher de ZONE, pas l'action.
211
+ */
212
+ function authorizeApiRequest(firewall, params, token, onDeny) {
213
+ const path = params?.path;
214
+ if (typeof path !== "string") return true;
215
+ const qi = path.indexOf("?");
216
+ const pathname = qi === -1 ? path : path.slice(0, qi);
217
+ const area = firewall.matchPath(pathname);
218
+ if (area && area.security && !token.isAuthenticated()) {
219
+ onDeny?.("api.request", pathname, "zone_protected", token);
220
+ return false;
221
+ }
222
+ return true;
223
+ }
224
+ /**
225
+ * Verrou `subscribe {channel}` (et canaux inbound full-duplex) — applique la
226
+ * politique du canal. PLANCHER système prioritaire (namespace réservé) ; sinon
227
+ * politique métier déclarée (`@RealtimeChannel`) ; sinon canal libre.
228
+ */
229
+ function authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny) {
230
+ const policy = matchSystemPolicy(channel, systemRules) ?? resolver?.resolveChannelPolicy?.(channel) ?? null;
231
+ if (policy === null) return true;
232
+ if (satisfies(policy, token, firewall)) return true;
233
+ onDeny?.("channel", channel, "channel_policy", token);
234
+ return false;
235
+ }
236
+ /**
237
+ * Construit le verrou de frame WS branché sur le hub realtime par le firewall au
238
+ * boot (`RealtimeService.setFrameAuthorizer`). SYNC, 0 lecture base : lit le
239
+ * token déjà résolu au handshake et matche la cible de la frame contre la zone
240
+ * (api.request) ou la politique du canal (subscribe/inbound).
241
+ *
242
+ * Trois surfaces gardées :
243
+ * - `api.request {path}` (pont API souverain) : re-match de zone HTTP → zone
244
+ * protégée + anonyme = refus (autorisation de ZONE, identique à `GET {path}` ;
245
+ * la méthode/action est gardée en aval par le router + `@IsGranted`).
246
+ * - `subscribe {channel}` : politique de canal (système plancher + déclaration
247
+ * métier `@RealtimeChannel` → rôles/scopes).
248
+ * - inbound (`method` = canal full-duplex déclaré avec policy) : même politique
249
+ * que `subscribe` — un client ne pousse pas sur un canal protégé sans droit.
250
+ *
251
+ * Toute autre frame (`ping`, `unsubscribe`, action explicitement ouverte) passe — le verrou
252
+ * cible les surfaces qui atteignent le data plane / l'observabilité / un canal
253
+ * protégé.
254
+ *
255
+ * @param firewall - matcher de zone + checker de rôle (le `Firewall`).
256
+ * @param options - `channelResolver` (politiques métier déclarées, via le
257
+ * service realtime) + `systemRules` (défauts + config) +
258
+ * `onDeny` (rapporteur d'audit, invoqué sur refus seulement).
259
+ * @returns un {@link FrameAuthorizer} sync (`true` = frame autorisée).
260
+ */
261
+ function buildFrameAuthorizer(firewall, options) {
262
+ const resolver = options?.channelResolver ?? null;
263
+ const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
264
+ const onDeny = options?.onDeny;
265
+ return (frame, token) => {
266
+ const f = frame;
267
+ const method = f?.method;
268
+ if (method === "api.request") return authorizeApiRequest(firewall, f.params, token, onDeny);
269
+ if (method === "subscribe") {
270
+ const channel = f.params?.channel;
271
+ if (typeof channel !== "string") return true;
272
+ return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
273
+ }
274
+ if (typeof method === "string") return authorizeChannel(method, token, firewall, resolver, systemRules, onDeny);
275
+ return true;
276
+ };
277
+ }
278
+ //#endregion
279
+ export { DEFAULT_SYSTEM_PREFIXES, DEFAULT_SYSTEM_RULES, RESERVED_FLOOR_PREFIXES, SECURITY_CHANNEL_POLICY, SYSTEM_CHANNEL_POLICY, UPLINK_CHANNEL_POLICY, buildFrameAuthorizer, buildFrameAuthorizer as default, buildSystemRules };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
2
+ import { UserNotFoundError } from "@nodefony/user";
3
+ //#region nodefony/src/sessionIdentity.ts
4
+ const INVALID_SESSION = "Invalid session";
5
+ /**
6
+ * Résout l'identité portée par une session (l'identifiant stocké dans le blob)
7
+ * en utilisateur VIVANT — re-fetch systématique auprès du provider.
8
+ *
9
+ * C'est LE choix structurant de la session BFF : la session ne stocke que
10
+ * l'identifiant, jamais l'utilisateur sérialisé. Rôles toujours frais, et un
11
+ * compte verrouillé/désactivé entre deux requêtes est rejeté immédiatement
12
+ * (révocation effective — l'argument décisif face au JWT côté web).
13
+ *
14
+ * Partagé par le `SessionAuthenticator` (requêtes en zone) et `AuthFlow.me()`
15
+ * (hors zone) : une seule source de vérité des contrôles d'état du compte.
16
+ *
17
+ * @param provider - source d'identité (`UserService` via le container).
18
+ * @param identifier - identifiant fonctionnel stocké en session.
19
+ * @returns l'utilisateur actif.
20
+ * @throws AuthenticationError (401, message uniforme) — identifiant inconnu,
21
+ * compte verrouillé ou désactivé.
22
+ */
23
+ async function resolveSessionIdentity(provider, identifier) {
24
+ let user;
25
+ try {
26
+ user = await provider.loadUserByIdentifier(identifier);
27
+ } catch (error) {
28
+ if (error instanceof UserNotFoundError) throw new AuthenticationError(INVALID_SESSION);
29
+ throw error;
30
+ }
31
+ if (user.isLocked() || !user.isActive()) throw new AuthenticationError(INVALID_SESSION);
32
+ return user;
33
+ }
34
+ //#endregion
35
+ export { resolveSessionIdentity };
@@ -0,0 +1,97 @@
1
+ //#region nodefony/src/throttle/LoginThrottler.ts
2
+ const DEFAULT_FREE_ATTEMPTS = 3;
3
+ const DEFAULT_BASE_DELAY_S = 1;
4
+ const DEFAULT_CAP_DELAY_S = 900;
5
+ const DEFAULT_MAX_TRACKED = 1e4;
6
+ /**
7
+ * Limiteur de tentatives de login **en mémoire, par identifiant saisi**.
8
+ *
9
+ * La clé est l'identifiant TEL QUE SAISI (existant ou non en base) : un
10
+ * identifiant martelé est ralenti qu'il corresponde à un compte ou pas — aucun
11
+ * oracle d'énumération. Par-processus (V1) : en cluster, chaque worker porte son
12
+ * compteur (l'attaquant gagne ×N workers — acceptable car le backoff est
13
+ * exponentiel ; un backend partagé type Redis se branchera derrière la même
14
+ * interface sans toucher l'authenticator).
15
+ *
16
+ * Coût borné (règle perf Nodefony) : `Map` allouée LAZY au premier échec (une
17
+ * app sans échec de login ne paie rien), AUCUN timer — l'expiration est évaluée
18
+ * à la lecture et les entrées mortes sont purgées par balayage opportuniste
19
+ * quand la borne `maxTracked` est atteinte (puis éviction FIFO en dernier
20
+ * recours : ~10 000 × ~100 B ≈ 1 MB au pire).
21
+ */
22
+ var LoginThrottler = class {
23
+ freeAttempts;
24
+ baseDelayS;
25
+ capDelayS;
26
+ maxTracked;
27
+ #entries = null;
28
+ #now;
29
+ constructor(options = {}, now = Date.now) {
30
+ this.freeAttempts = options.freeAttempts ?? DEFAULT_FREE_ATTEMPTS;
31
+ this.baseDelayS = options.baseDelayS ?? DEFAULT_BASE_DELAY_S;
32
+ this.capDelayS = options.capDelayS ?? DEFAULT_CAP_DELAY_S;
33
+ this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
34
+ this.#now = now;
35
+ }
36
+ /**
37
+ * L'identifiant peut-il tenter un login maintenant ?
38
+ *
39
+ * @param identifier - identifiant tel que saisi.
40
+ * @returns `0` si autorisé, sinon les secondes restantes (→ `Retry-After`,
41
+ * arrondies à l'entier supérieur).
42
+ */
43
+ check(identifier) {
44
+ const entry = this.#entries?.get(identifier);
45
+ if (entry === void 0) return 0;
46
+ const remainingMs = entry.blockedUntil - this.#now();
47
+ return remainingMs > 0 ? Math.ceil(remainingMs / 1e3) : 0;
48
+ }
49
+ /**
50
+ * Enregistre un échec : au-delà de `freeAttempts`, arme un délai exponentiel
51
+ * `baseDelayS × 2^(échecs - freeAttempts - 1)`, plafonné à `capDelayS`.
52
+ *
53
+ * @param identifier - identifiant tel que saisi.
54
+ */
55
+ recordFailure(identifier) {
56
+ const entries = this.#entries ??= /* @__PURE__ */ new Map();
57
+ let entry = entries.get(identifier);
58
+ if (entry === void 0) {
59
+ if (entries.size >= this.maxTracked) this.#evict(entries);
60
+ entry = {
61
+ failures: 0,
62
+ blockedUntil: 0
63
+ };
64
+ entries.set(identifier, entry);
65
+ }
66
+ entry.failures += 1;
67
+ const over = entry.failures - this.freeAttempts;
68
+ if (over > 0) {
69
+ const delayS = Math.min(this.baseDelayS * 2 ** (over - 1), this.capDelayS);
70
+ entry.blockedUntil = this.#now() + delayS * 1e3;
71
+ }
72
+ }
73
+ /**
74
+ * Login réussi : oublie l'identifiant (le délai repart de zéro — NIST :
75
+ * l'utilisateur légitime ne traîne pas la dette d'un attaquant passé).
76
+ *
77
+ * @param identifier - identifiant tel que saisi.
78
+ */
79
+ recordSuccess(identifier) {
80
+ this.#entries?.delete(identifier);
81
+ }
82
+ /** Nombre d'identifiants actuellement suivis (introspection / tests). */
83
+ get trackedCount() {
84
+ return this.#entries?.size ?? 0;
85
+ }
86
+ #evict(entries) {
87
+ const now = this.#now();
88
+ for (const [key, entry] of entries) if (entry.blockedUntil <= now) entries.delete(key);
89
+ let excess = entries.size - this.maxTracked + 1;
90
+ if (excess > 0) for (const key of entries.keys()) {
91
+ entries.delete(key);
92
+ if ((excess -= 1) <= 0) break;
93
+ }
94
+ }
95
+ };
96
+ //#endregion
97
+ export { LoginThrottler, LoginThrottler as default };
@@ -0,0 +1,40 @@
1
+ import { anonymousUser } from "@nodefony/user";
2
+ //#region nodefony/src/token/AnonymousToken.ts
3
+ /**
4
+ * Token du visiteur non authentifié — Zero Trust : un visiteur EST un utilisateur
5
+ * anonyme (jamais `null`).
6
+ *
7
+ * Porte le singleton gelé `anonymousUser` → **zéro allocation d'utilisateur** par
8
+ * requête non authentifiée (hot path). Les attributs sont lazy (`null` tant qu'on
9
+ * n'en pose pas).
10
+ */
11
+ var AnonymousToken = class {
12
+ type = "anonymous";
13
+ #attributes = null;
14
+ getUser() {
15
+ return anonymousUser;
16
+ }
17
+ getUserIdentifier() {
18
+ return anonymousUser.identifier;
19
+ }
20
+ isAuthenticated() {
21
+ return false;
22
+ }
23
+ getRoles() {
24
+ return [...anonymousUser.roles];
25
+ }
26
+ getCredentials() {
27
+ return null;
28
+ }
29
+ getScopes() {
30
+ return [];
31
+ }
32
+ getAttribute(key) {
33
+ return this.#attributes?.get(key);
34
+ }
35
+ setAttribute(key, value) {
36
+ (this.#attributes ??= /* @__PURE__ */ new Map()).set(key, value);
37
+ }
38
+ };
39
+ //#endregion
40
+ export { AnonymousToken, AnonymousToken as default };
@@ -0,0 +1,160 @@
1
+ import { messageNonRestreint, modeNonRestreintAsync, readIfPresent, writeSecret } from "./secretFile.js";
2
+ import { join } from "node:path";
3
+ //#region nodefony/src/token/JwtKeystore.ts
4
+ /**
5
+ * Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
6
+ *
7
+ * Résout la clé de signature selon une **priorité** (jamais d'auto-génération en
8
+ * clair « par défaut » en prod) :
9
+ * 1. **env** — `config.jwt.keystore.keySetJson` (JWK Set injecté par l'app depuis
10
+ * son catalogue d'env) : prod cloud, secret géré hors-app, même clé sur tous
11
+ * les pods.
12
+ * 2. **fichier** — `config.jwt.keystore.dir/keyset.json` (écrit en mode 600,
13
+ * généré si absent) : opt-in dev/VPS mono-machine. Le mode effectif est
14
+ * **constaté** après coup : un système de fichiers qui n'applique pas les
15
+ * permissions POSIX (NTFS, FAT, NFS sans mapping) déclenche un **warning**
16
+ * plutôt qu'une garantie silencieusement fausse.
17
+ * 3. **mémoire** — aucune source → clé éphémère générée au 1ᵉʳ usage + **warning**
18
+ * (perdue au redémarrage = refresh invalidés, incohérente en cluster).
19
+ *
20
+ * jose est importé **paresseusement** (dep lourde — règle perf P6) au premier
21
+ * usage ; le boot ne paie rien si le JWT n'est jamais sollicité. Le chargement
22
+ * est mémoïsé (une seule résolution concurrente).
23
+ *
24
+ * @remarks Race au 1ᵉʳ boot d'un **cluster** sans clé pré-provisionnée : deux
25
+ * workers peuvent générer puis écrire des clés différentes (le dernier `rename`
26
+ * gagne). En prod, provisionner la clé hors-bande (`keySetJson`/SecretProvider
27
+ * P16) élimine ce cas — c'est précisément la source recommandée.
28
+ */
29
+ var JwtKeystore = class {
30
+ #source;
31
+ #log;
32
+ #keys = [];
33
+ #activeKid = "";
34
+ #ready = null;
35
+ constructor(source, log) {
36
+ this.#source = source;
37
+ this.#log = log;
38
+ }
39
+ async getSigningKey() {
40
+ await this.#ensureLoaded();
41
+ const active = this.#keys.find((k) => k.kid === this.#activeKid);
42
+ if (!active) throw new Error("JwtKeystore: aucune clé de signature active");
43
+ return {
44
+ key: active.privateKey,
45
+ kid: active.kid,
46
+ alg: "EdDSA"
47
+ };
48
+ }
49
+ async getPublicJWKS() {
50
+ await this.#ensureLoaded();
51
+ return { keys: this.#keys.map((k) => k.publicJwk) };
52
+ }
53
+ /** Charge le keyset une seule fois (mémoïsation de la promesse). */
54
+ #ensureLoaded() {
55
+ return this.#ready ??= this.#load();
56
+ }
57
+ async #load() {
58
+ const jose = await import("jose");
59
+ if (this.#source.keySetJson) {
60
+ await this.#importKeyset(jose, this.#parseKeyset(this.#source.keySetJson));
61
+ return;
62
+ }
63
+ if (this.#source.dir) {
64
+ const file = join(this.#source.dir, "keyset.json");
65
+ const existing = await this.#readFile(file);
66
+ if (existing) {
67
+ await this.#checkRestricted(file);
68
+ await this.#importKeyset(jose, this.#parseKeyset(existing));
69
+ return;
70
+ }
71
+ const keyset = await this.#generate(jose);
72
+ await this.#writeAtomic(file, JSON.stringify(keyset));
73
+ await this.#importKeyset(jose, keyset);
74
+ return;
75
+ }
76
+ this.#log("JWT keystore: clé de signature ÉPHÉMÈRE en mémoire (perdue au redémarrage → refresh tokens invalidés, incohérente en cluster). Configurez jwt.keystore.dir (dev/VPS) ou jwt.keystore.keySetJson depuis l'env (prod).", "WARNING");
77
+ await this.#importKeyset(jose, await this.#generate(jose));
78
+ }
79
+ /** Génère une nouvelle paire Ed25519 (extractable pour persistance JWK). */
80
+ async #generate(jose) {
81
+ const { publicKey, privateKey } = await jose.generateKeyPair("Ed25519", { extractable: true });
82
+ const publicJwk = await jose.exportJWK(publicKey);
83
+ const kid = await jose.calculateJwkThumbprint(publicJwk, "sha256");
84
+ return {
85
+ active: kid,
86
+ keys: [{
87
+ ...await jose.exportJWK(privateKey),
88
+ kid,
89
+ alg: "EdDSA",
90
+ use: "sig",
91
+ createdAt: Date.now()
92
+ }]
93
+ };
94
+ }
95
+ /** Importe un keyset persisté : privée → `CryptoKey`, public → JWK sans `d`. */
96
+ async #importKeyset(jose, keyset) {
97
+ const loaded = [];
98
+ for (const stored of keyset.keys) {
99
+ const imported = await jose.importJWK(stored, "Ed25519");
100
+ if (!(imported instanceof CryptoKey)) throw new Error(`JwtKeystore: clé asymétrique attendue pour le kid "${stored.kid}"`);
101
+ loaded.push({
102
+ kid: stored.kid,
103
+ privateKey: imported,
104
+ publicJwk: {
105
+ kty: stored.kty,
106
+ crv: stored.crv,
107
+ x: stored.x,
108
+ kid: stored.kid,
109
+ use: "sig",
110
+ alg: "EdDSA"
111
+ },
112
+ createdAt: stored.createdAt ?? Date.now()
113
+ });
114
+ }
115
+ if (loaded.length === 0) throw new Error("JwtKeystore: keyset vide");
116
+ this.#keys = loaded;
117
+ this.#activeKid = keyset.active && loaded.some((k) => k.kid === keyset.active) ? keyset.active : loaded[0].kid;
118
+ }
119
+ #parseKeyset(json) {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(json);
123
+ } catch {
124
+ throw new Error("JwtKeystore: keyset JSON invalide");
125
+ }
126
+ const keyset = parsed;
127
+ if (!keyset || !Array.isArray(keyset.keys) || keyset.keys.length === 0 || keyset.keys.some((k) => typeof k.kid !== "string")) throw new Error("JwtKeystore: keyset malformé (champ `keys` non vide avec `kid` requis)");
128
+ return keyset;
129
+ }
130
+ /** Lit un fichier ; `null` si absent (ENOENT), relance toute autre erreur. */
131
+ async #readFile(file) {
132
+ return readIfPresent(file);
133
+ }
134
+ /** Écriture atomique (tmp + rename) en mode 600 — pas de fichier partiel lu. */
135
+ async #writeAtomic(file, data) {
136
+ await writeSecret(file, data);
137
+ this.#log(`JWT keystore: clé Ed25519 générée et persistée (${file}).`, "INFO");
138
+ await this.#checkRestricted(file);
139
+ }
140
+ /**
141
+ * Constate le mode EFFECTIF du fichier de clés et avertit s'il n'est pas 0600.
142
+ *
143
+ * Le mode demandé à l'écriture est une intention, pas une garantie : NTFS
144
+ * (Windows) l'ignore, tout comme un montage FAT/exFAT ou NFS sans mapping
145
+ * d'identité — sous Linux comme ailleurs. Le fichier porte la clé PRIVÉE :
146
+ * si la restriction n'a pas pris, la confidentialité repose sur les droits du
147
+ * dossier, ce qui doit être DIT plutôt que supposé. La capacité se constate,
148
+ * elle ne se déduit pas de `process.platform`.
149
+ *
150
+ * Un `stat` par résolution de keystore (une fois par process, source fichier
151
+ * uniquement) — hors hot-path.
152
+ */
153
+ async #checkRestricted(file) {
154
+ const mode = await modeNonRestreintAsync(file);
155
+ if (mode === null || mode === void 0) return;
156
+ this.#log(`JWT keystore: ${messageNonRestreint(file, mode)} La clé PRIVÉE y réside : provisionnez-la plutôt hors-bande via jwt.keystore.keySetJson (recommandé en production).`, "WARNING");
157
+ }
158
+ };
159
+ //#endregion
160
+ export { JwtKeystore, JwtKeystore as default };