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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,14 +1,18 @@
1
+ import { JwtKeystore } from "../src/token/JwtKeystore.js";
2
+ import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
3
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
1
4
  import { AuthenticationError } from "../errors/AuthenticationError.js";
2
5
  import { ThrottledError } from "../errors/ThrottledError.js";
3
6
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
7
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
4
8
  import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
5
9
  import { recordAudit } from "../src/audit/recordAudit.js";
10
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
6
11
  import { InvalidTargetError } from "../errors/InvalidTargetError.js";
7
12
  import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
8
- import { JwtKeystore } from "../src/token/JwtKeystore.js";
9
- import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
13
+ import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
10
14
  import { createHash, randomBytes, randomUUID } from "node:crypto";
11
- //#region nodefony/service/tokenService.ts
15
+ // nodefony/service/tokenService.ts
12
16
  const serviceName = "tokenService";
13
17
  /**
14
18
  * Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
@@ -36,13 +40,48 @@ var TokenService = class extends Service {
36
40
  #users = null;
37
41
  #throttler = null;
38
42
  #throttlerResolved = false;
43
+ #transport = null;
44
+ /** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
45
+ #scopeGrants = null;
39
46
  #gc = null;
40
47
  constructor(module) {
41
48
  super(serviceName, module.container, module.notificationsCenter, module.options);
42
49
  this.module = module;
43
- this.kernel?.once("onBoot", () => this.#build());
50
+ const tokenServiceBoot = () => this.#build();
51
+ this.kernel?.once("onBoot", tokenServiceBoot);
44
52
  this.kernel?.once("onTerminate", () => this.#shutdown());
45
53
  }
54
+ /**
55
+ * Refuse de SERVIR en production sans clé de signature partagée.
56
+ *
57
+ * Sans `keySetJson` ni `dir`, le keystore tire une clé ÉPHÉMÈRE propre au
58
+ * process : derrière plusieurs pods ou workers, un jeton signé par l'un est
59
+ * refusé par l'autre (401 au hasard de la répartition), et chaque
60
+ * redémarrage invalide tous les jetons. Un avertissement au premier jeton ne
61
+ * suffisait pas — il tombait en production, loin de celui qui pouvait agir.
62
+ *
63
+ * Seul un run qui SERT est concerné (`runProfile.servers`) : une commande de
64
+ * console (`orm:migrate`, `security:user:add`) ne signe aucun jeton, et la
65
+ * bloquer casserait le job de migration pour rien.
66
+ *
67
+ * @throws BootConfigurationError - production, serveur, et aucune source.
68
+ */
69
+ #requireSharedSigningKey(keystore) {
70
+ if (keystore.keySetJson || keystore.dir) return;
71
+ if (this.kernel?.environment !== "production") return;
72
+ if (!this.kernel.runProfile.servers) return;
73
+ throw new BootConfigurationError([
74
+ "[@nodefony/security] DÉMARRAGE REFUSÉ — aucune clé de signature des jetons partagée (jwt.keystore).",
75
+ " Pourquoi : chaque process signerait avec la sienne. Un jeton émis par un pod ou un worker",
76
+ " serait refusé par les autres (401 au hasard), et tous seraient perdus au redémarrage.",
77
+ " Que faire — une des trois :",
78
+ " 1. Poser NF_JWT_KEYSET (câblée sur jwt.keystore.keySetJson) : valeur générée UNE fois par",
79
+ " `npx nodefony security:secrets --jwt-keyset`, rangée dans le gestionnaire de secrets,",
80
+ " injectée dans chaque pod.",
81
+ " 2. Serveur unique à disque persistant : jwt.keystore.dir.",
82
+ " 3. L'application n'émet aucun jeton : jwt.enabled: false."
83
+ ].join("\n"));
84
+ }
46
85
  #build() {
47
86
  let config;
48
87
  try {
@@ -52,6 +91,7 @@ var TokenService = class extends Service {
52
91
  }
53
92
  const jwtEnabled = config.jwt.enabled;
54
93
  const apiKeysEnabled = config.apiKeys.enabled;
94
+ this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
55
95
  if (!jwtEnabled && !apiKeysEnabled) {
56
96
  this.log("token service idle — JWT et clés API désactivés", "DEBUG");
57
97
  return;
@@ -59,7 +99,7 @@ var TokenService = class extends Service {
59
99
  let storeName = config.tokenStore.store;
60
100
  let reason = `store explicitement configuré ("${storeName}")`;
61
101
  if (storeName === AUTO_STORE) {
62
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", runNeedsExternalServices(this.kernel));
102
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", storeConnectivity(this.kernel));
63
103
  storeName = auto.store;
64
104
  reason = auto.reason;
65
105
  this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
@@ -71,7 +111,7 @@ var TokenService = class extends Service {
71
111
  this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
72
112
  return;
73
113
  }
74
- if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
114
+ if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
75
115
  this.#store = factory({
76
116
  container: this.container,
77
117
  config
@@ -89,6 +129,7 @@ var TokenService = class extends Service {
89
129
  });
90
130
  this.container?.set("tokenStore", this.#store);
91
131
  if (jwtEnabled) {
132
+ this.#requireSharedSigningKey(config.jwt.keystore);
92
133
  this.#runtime = resolveJwtRuntime(config.jwt);
93
134
  this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
94
135
  this.container?.set("jwtKeystore", this.#keystore);
@@ -193,10 +234,24 @@ var TokenService = class extends Service {
193
234
  * Émet un couple access/refresh après vérification d'un credential
194
235
  * identifiant/mot de passe (grant M2M/CLI). Throttling NIST partagé si activé.
195
236
  *
237
+ * @param context - requête HTTP qui porte le credential. Présente, elle
238
+ * soumet l'appel au refus du clair en production ; absente (CLI, appel
239
+ * programmatique), il n'y a pas de transport à juger. Un endpoint HTTP DOIT
240
+ * la passer.
241
+ * @throws InsecureTransportError (403) — credential reçu en clair en production.
196
242
  * @throws ThrottledError (429) — backoff actif.
197
243
  * @throws AuthenticationError (401, message uniforme) — credential invalide.
198
244
  */
199
- async issueForCredentials(identifier, password, requestedScopes, resource) {
245
+ async issueForCredentials(identifier, password, requestedScopes, resource, context) {
246
+ if (context !== void 0) {
247
+ this.#transport ??= this.get("credentialTransport") ?? null;
248
+ try {
249
+ this.#transport?.assert(context, "token");
250
+ } catch (error) {
251
+ if (error instanceof InsecureTransportError) this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "insecure_transport", readAuditContext(context));
252
+ throw error;
253
+ }
254
+ }
200
255
  if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
201
256
  this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "invalid_credentials");
202
257
  throw new AuthenticationError("Invalid credentials");
@@ -218,13 +273,14 @@ var TokenService = class extends Service {
218
273
  throttler?.recordSuccess(identifier);
219
274
  return this.issueTokens(user, requestedScopes, resource);
220
275
  }
221
- #auditGrant(action, actor, reason) {
276
+ #auditGrant(action, actor, reason, info) {
222
277
  recordAudit(this.container, {
223
278
  category: "auth",
224
279
  action,
225
280
  outcome: "failure",
226
281
  actor,
227
- reason
282
+ reason,
283
+ ...info
228
284
  });
229
285
  }
230
286
  /**
@@ -289,26 +345,30 @@ var TokenService = class extends Service {
289
345
  * d'informer le client — c'est ce que fait le champ `scope` de la réponse,
290
346
  * déjà rempli avec les scopes réellement accordés.
291
347
  *
292
- * Les scopes d'une APPLICATION ne sont pas jugés ici : c'est à elle de dire
293
- * qui peut obtenir `shop:read`.
348
+ * Les scopes d'une APPLICATION se bornent par `grantableScopes` : c'est elle
349
+ * qui dit qui peut obtenir `shop:write`. Un scope qu'elle ne déclare pas
350
+ * reste accordé à tout porteur. Les deux familles passent par le MÊME juge
351
+ * ({@link refusedScopes}), que les clés API appellent aussi.
294
352
  *
295
353
  * @param user - le porteur, déjà authentifié.
296
354
  * @param requested - ce qu'il demande.
297
355
  * @returns ce qui lui est accordé.
298
356
  */
299
357
  #grantableScopes(user, requested) {
358
+ if (requested.length === 0) return requested;
300
359
  const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
301
- const refuses = refusedAdminScopes(requested, roles);
302
- if (refuses.length === 0) return requested;
303
- this.log(`grant : scope(s) « ${refuses.join(" ")} » NON accordé(s) à « ${user.identifier} » — réservé(s) au rôle d'administration. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
304
- return requested.filter((scope) => !refuses.includes(scope));
360
+ const refusals = refusedScopes(requested, roles, this.#scopeGrants ?? new ScopeGrantPolicy(), this.container?.get("roleHierarchy") ?? null);
361
+ if (refusals.length === 0) return requested;
362
+ const named = refusals.map((r) => `${r.scope} (${r.rule === "admin" ? "réservé au rôle d'administration" : "réservé par grantableScopes"})`).join(", ");
363
+ this.log(`grant : scope(s) NON accordé(s) à « ${user.identifier} » — ${named}. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
364
+ return requested.filter((scope) => !refusals.some((r) => r.scope === scope));
305
365
  }
306
366
  async issueTokens(user, requestedScopes, resource, accessTtlS) {
307
367
  const { store, runtime } = this.#ensureReady();
308
368
  const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
309
369
  const audience = this.#resolveAudience(runtime, resource);
310
- const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
311
- const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
370
+ const access = await this.#signAccess(user, scopes, audience, accessTtlS);
371
+ const { record, raw } = this.#buildRefresh(user, scopes, this.#randomId(), audience);
312
372
  await store.put(record);
313
373
  recordAudit(this.container, {
314
374
  category: "token",
@@ -367,10 +427,11 @@ var TokenService = class extends Service {
367
427
  const now = Date.now();
368
428
  if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
369
429
  const user = await this.#resolveUserForRefresh(record.subjectId);
370
- const scopes = [...record.scopes];
430
+ if (!isBoundToAccount(user, record.metadata["accountId"], record.metadata["credentialsVersion"])) throw new AuthenticationError("Invalid token");
431
+ const scopes = this.#grantableScopes(user, [...record.scopes]);
371
432
  const audience = record.audience?.at(0) ?? defaultAudience(runtime);
372
433
  if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
373
- const access = await this.#signAccess(user.identifier, scopes, audience);
434
+ const access = await this.#signAccess(user, scopes, audience);
374
435
  if (!runtime.rotateRefresh) return {
375
436
  access_token: access,
376
437
  refresh_token: rawRefresh,
@@ -379,7 +440,7 @@ var TokenService = class extends Service {
379
440
  scope: scopes.join(" ")
380
441
  };
381
442
  const family = record.family ?? this.#randomId();
382
- const next = this.#buildRefresh(user.identifier, scopes, family, audience);
443
+ const next = this.#buildRefresh(user, scopes, family, audience);
383
444
  await store.put(next.record);
384
445
  record.replacedBy = next.record.id;
385
446
  record.revokedAt = now;
@@ -393,15 +454,19 @@ var TokenService = class extends Service {
393
454
  scope: scopes.join(" ")
394
455
  };
395
456
  }
396
- async #signAccess(subject, scopes, audience, ttlS) {
457
+ async #signAccess(user, scopes, audience, ttlS) {
397
458
  const { keystore, runtime: rt } = this.#ensureReady();
398
459
  const jose = await this.#ensureJose();
399
460
  const { key, kid } = await keystore.getSigningKey();
400
- return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
461
+ return new jose.SignJWT({
462
+ scope: scopes.join(" "),
463
+ ["uid"]: user.id,
464
+ ["cv"]: credentialsVersionOf(user)
465
+ }).setProtectedHeader({
401
466
  alg: "EdDSA",
402
467
  kid,
403
468
  typ: "at+jwt"
404
- }).setIssuedAt().setIssuer(rt.issuer).setSubject(subject).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
469
+ }).setIssuedAt().setIssuer(rt.issuer).setSubject(user.identifier).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
405
470
  }
406
471
  /**
407
472
  * Construit (sans persister) un record refresh + son secret opaque en clair.
@@ -411,7 +476,7 @@ var TokenService = class extends Service {
411
476
  * silencieusement l'accès à l'audience par défaut — un downscoping
412
477
  * qui s'annule au bout de quelques minutes n'en est pas un.
413
478
  */
414
- #buildRefresh(subject, scopes, family, audience) {
479
+ #buildRefresh(user, scopes, family, audience) {
415
480
  const raw = `nfr_${randomBytes(32).toString("base64url")}`;
416
481
  const now = Date.now();
417
482
  return {
@@ -420,7 +485,7 @@ var TokenService = class extends Service {
420
485
  kind: "refresh",
421
486
  name: "refresh token",
422
487
  prefix: null,
423
- subjectId: subject,
488
+ subjectId: user.identifier,
424
489
  subjectType: "user",
425
490
  tenantId: null,
426
491
  scopes: [...scopes],
@@ -439,7 +504,10 @@ var TokenService = class extends Service {
439
504
  lastUsedUserAgent: null,
440
505
  revokedAt: null,
441
506
  revokedReason: null,
442
- metadata: {}
507
+ metadata: {
508
+ [ACCOUNT_ID_KEY]: user.id,
509
+ [CREDENTIALS_VERSION_KEY]: credentialsVersionOf(user)
510
+ }
443
511
  },
444
512
  raw
445
513
  };
@@ -492,5 +560,4 @@ var TokenService = class extends Service {
492
560
  return this.#throttler;
493
561
  }
494
562
  };
495
- //#endregion
496
563
  export { TokenService, TokenService as default };
@@ -1,10 +1,12 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
+ import { recordAudit } from "../src/audit/recordAudit.js";
3
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
2
4
  import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
3
5
  import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
4
6
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
5
7
  import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
6
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
7
- //#region nodefony/service/totp.ts
8
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
9
+ // nodefony/service/totp.ts
8
10
  const serviceName = "totp";
9
11
  function isFlushable(s) {
10
12
  return s !== null && typeof s.flushNow === "function";
@@ -91,7 +93,7 @@ var TotpService = class extends Service {
91
93
  let driver = config.totp.store;
92
94
  let reason = `store explicitement configuré ("${driver}")`;
93
95
  if (driver === AUTO_STORE) {
94
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", runNeedsExternalServices(this.kernel));
96
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", storeConnectivity(this.kernel));
95
97
  driver = auto.store;
96
98
  reason = auto.reason;
97
99
  this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -103,7 +105,7 @@ var TotpService = class extends Service {
103
105
  this.log(`${msg} — 2FA indisponible`, "CRITIC");
104
106
  return null;
105
107
  }
106
- if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
108
+ if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
107
109
  const store = factory({
108
110
  container: this.container,
109
111
  config
@@ -157,17 +159,60 @@ var TotpService = class extends Service {
157
159
  beginEnrollment(userId, account) {
158
160
  return beginTotpEnrollment(this.#ensureReady(), userId, account);
159
161
  }
160
- /** Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs. */
161
- confirmEnrollment(userId, code) {
162
- return confirmTotpEnrollment(this.#ensureReady(), userId, code);
162
+ /**
163
+ * Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs.
164
+ *
165
+ * Activer un second facteur est un geste de SÉCURITÉ : il est audité ici
166
+ * (`user.totp_enabled`), avec la requête qui l'a porté — sans trace, un facteur
167
+ * posé à l'insu du titulaire ne se retrouve pas.
168
+ *
169
+ * @param userId - compte dont on active le 2FA (le porteur de la session).
170
+ * @param code - premier code de l'application d'authentification.
171
+ * @param context - contexte de la requête (IP, navigateur, identifiant de
172
+ * requête) recopié dans l'événement d'audit ; absent, l'événement part sans.
173
+ * @returns les codes de récupération, affichés une seule fois.
174
+ * @throws quand le code est faux, l'enrôlement absent ou déjà confirmé.
175
+ */
176
+ async confirmEnrollment(userId, code, context) {
177
+ const activation = await confirmTotpEnrollment(this.#ensureReady(), userId, code);
178
+ recordAudit(this.container, {
179
+ category: "auth",
180
+ action: "user.totp_enabled",
181
+ outcome: "success",
182
+ actor: userId,
183
+ resource: userId,
184
+ ...readAuditContext(context)
185
+ });
186
+ return activation;
163
187
  }
164
188
  /** Vérifie un second facteur au login (code TOTP ou code de récupération). */
165
189
  verifyLogin(userId, code) {
166
190
  return verifyTotpLogin(this.#ensureReady(), userId, code);
167
191
  }
168
- /** Désactive le 2FA d'un utilisateur. */
169
- disable(userId) {
170
- return disableTotp(this.#ensureReady(), userId);
192
+ /**
193
+ * Désactive le 2FA d'un utilisateur.
194
+ *
195
+ * Avec un `context`, c'est le TITULAIRE qui retire son facteur (porte
196
+ * libre-service) : le geste est audité ici (`user.totp_disabled`). Sans
197
+ * contexte, l'appelant porte lui-même l'audit — le plan d'administration le
198
+ * fait avec l'identité de l'administrateur (`viaAdmin`), qu'ici on ignore.
199
+ * Un seul événement par geste, jamais deux.
200
+ *
201
+ * @param userId - compte dont on retire le 2FA.
202
+ * @param context - contexte de la requête du titulaire (audit libre-service).
203
+ */
204
+ async disable(userId, context) {
205
+ await disableTotp(this.#ensureReady(), userId);
206
+ if (context === void 0) return;
207
+ recordAudit(this.container, {
208
+ category: "auth",
209
+ action: "user.totp_disabled",
210
+ outcome: "success",
211
+ actor: userId,
212
+ resource: userId,
213
+ metadata: { viaAdmin: false },
214
+ ...readAuditContext(context)
215
+ });
171
216
  }
172
217
  /** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
173
218
  status(userId) {
@@ -207,5 +252,4 @@ var TotpService = class extends Service {
207
252
  return this.#deps;
208
253
  }
209
254
  };
210
- //#endregion
211
255
  export { TotpService, TotpService as default };
@@ -2,13 +2,31 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
2
2
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
3
3
  import { WebAuthnError } from "../errors/WebAuthnError.js";
4
4
  import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
5
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
5
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
6
+ import { createHash } from "node:crypto";
6
7
  import { Buffer } from "node:buffer";
7
- //#region nodefony/service/webAuthn.ts
8
+ // nodefony/service/webAuthn.ts
8
9
  const serviceName = "webauthn";
9
10
  function isFlushable(s) {
10
11
  return s !== null && typeof s.flushNow === "function";
11
12
  }
13
+ /** Taille maximale d'un `userHandle` (WebAuthn L3 §4, « User Handle »). */
14
+ const USER_HANDLE_MAX_BYTES = 64;
15
+ /**
16
+ * Octets du `userHandle` d'une cérémonie d'enregistrement — opaques, bornés, sans
17
+ * donnée personnelle (cf {@link IWebAuthnUser.handle}).
18
+ *
19
+ * @param user - sujet de la cérémonie.
20
+ * @returns `handle` encodé s'il est fourni et tient en 64 octets, sinon le
21
+ * condensat SHA-256 de l'`id`.
22
+ */
23
+ function userHandleBytes(user) {
24
+ if (user.handle !== void 0 && user.handle.length > 0) {
25
+ const bytes = Buffer.from(user.handle, "utf8");
26
+ if (bytes.length <= USER_HANDLE_MAX_BYTES) return new Uint8Array(bytes);
27
+ }
28
+ return new Uint8Array(createHash("sha256").update(user.id, "utf8").digest());
29
+ }
12
30
  /**
13
31
  * **WebAuthn / passkeys** (P6 J9) — orchestrateur des deux cérémonies FIDO2
14
32
  * (WebAuthn L3 §7.1 enregistrement, §7.2 authentification).
@@ -74,7 +92,7 @@ var WebAuthnService = class extends Service {
74
92
  let driver = config.passkeys.store;
75
93
  reason = `store explicitement configuré ("${driver}")`;
76
94
  if (driver === AUTO_STORE) {
77
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", runNeedsExternalServices(this.kernel));
95
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", storeConnectivity(this.kernel));
78
96
  driver = auto.store;
79
97
  reason = auto.reason;
80
98
  this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -86,7 +104,7 @@ var WebAuthnService = class extends Service {
86
104
  this.log(`${msg} — passkeys indisponibles`, "CRITIC");
87
105
  return;
88
106
  }
89
- if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
107
+ if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
90
108
  this.#store = factory({
91
109
  container: this.container,
92
110
  config
@@ -143,7 +161,7 @@ var WebAuthnService = class extends Service {
143
161
  rpName: this.#rpName,
144
162
  rpID: this.#rpID,
145
163
  userName: user.name,
146
- userID: new Uint8Array(Buffer.from(user.id, "utf8")),
164
+ userID: userHandleBytes(user),
147
165
  userDisplayName: user.displayName ?? user.name,
148
166
  attestationType: pk.attestation,
149
167
  timeout: pk.timeoutMs,
@@ -347,5 +365,4 @@ var WebAuthnService = class extends Service {
347
365
  };
348
366
  }
349
367
  };
350
- //#endregion
351
- export { WebAuthnService, WebAuthnService as default };
368
+ export { WebAuthnService, WebAuthnService as default, userHandleBytes };
@@ -6,11 +6,11 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
6
6
  import { assertPublicUrl } from "../src/net/ssrfGuard.js";
7
7
  import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
8
8
  import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
9
- import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { Buffer } from "node:buffer";
12
12
  import { schemaMismatchOf } from "@nodefony/http";
13
- //#region nodefony/service/webhooks.ts
13
+ // nodefony/service/webhooks.ts
14
14
  const serviceName = "webhooks";
15
15
  /** Historique de livraisons gardé PAR endpoint (ring borné, RAM, par pod). */
16
16
  const MAX_DELIVERIES_PER_ENDPOINT = 20;
@@ -155,7 +155,7 @@ var WebhookService = class extends Service {
155
155
  let driver = config.webhooks.store;
156
156
  let reason = `store explicitement configuré ("${driver}")`;
157
157
  if (driver === AUTO_STORE) {
158
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", runNeedsExternalServices(this.kernel));
158
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", storeConnectivity(this.kernel));
159
159
  driver = auto.store;
160
160
  reason = auto.reason;
161
161
  this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -167,7 +167,7 @@ var WebhookService = class extends Service {
167
167
  this.log(`${msg} — webhooks indisponibles`, "CRITIC");
168
168
  return null;
169
169
  }
170
- if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
170
+ if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
171
171
  const store = factory({
172
172
  container: this.container,
173
173
  config
@@ -546,5 +546,4 @@ var WebhookService = class extends Service {
546
546
  return addresses;
547
547
  }
548
548
  };
549
- //#endregion
550
549
  export { WebhookService, WebhookService as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/RoleHierarchyWalker.ts
1
+ // nodefony/src/RoleHierarchyWalker.ts
2
2
  /**
3
3
  * Résout la hiérarchie de rôles — `ROLE_ADMIN` hérite `ROLE_USER`, etc.
4
4
  *
@@ -73,5 +73,4 @@ var RoleHierarchyWalker = class {
73
73
  for (const node of Object.keys(hierarchy)) if ((color.get(node) ?? WHITE) === WHITE) visit(node);
74
74
  }
75
75
  };
76
- //#endregion
77
76
  export { RoleHierarchyWalker, RoleHierarchyWalker as default };
@@ -1,4 +1,5 @@
1
- //#region nodefony/src/SecuredArea.ts
1
+ import { stripTrailingSlashes } from "nodefony";
2
+ // nodefony/src/SecuredArea.ts
2
3
  /**
3
4
  * Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
4
5
  *
@@ -16,22 +17,30 @@ var SecuredArea = class {
16
17
  host;
17
18
  realtime;
18
19
  resource;
20
+ entryPoint;
21
+ subjectPolicy;
22
+ ephemeralRoles;
19
23
  /**
20
24
  * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
21
25
  * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
22
26
  * et aucune zone ne porte de tableau qu'elle n'utilise pas.
23
27
  */
24
28
  roles;
29
+ #hostKey;
25
30
  constructor(name, config) {
26
31
  this.name = name;
27
- this.pattern = new RegExp(config.pattern, "u");
32
+ this.pattern = new RegExp(config.pattern, "iu");
28
33
  this.security = config.security;
29
34
  this.stateless = config.stateless;
30
35
  this.mode = config.mode;
31
36
  this.authenticators = config.authenticators;
32
37
  this.host = config.host;
38
+ this.#hostKey = config.host ? config.host.toLowerCase() : null;
33
39
  this.realtime = config.realtime;
34
40
  this.resource = config.resource;
41
+ this.entryPoint = config.entryPoint;
42
+ this.subjectPolicy = config.subjectPolicy;
43
+ this.ephemeralRoles = config.subjectPolicy === "ephemeral" ? config.ephemeralRoles ?? [] : void 0;
35
44
  this.roles = config.roles.length > 0 ? config.roles : null;
36
45
  }
37
46
  /**
@@ -40,8 +49,10 @@ var SecuredArea = class {
40
49
  * UNIQUE de la décision de zone (invariant `api.request {path}` ≤ `GET {path}`).
41
50
  */
42
51
  matchPath(pathname, host) {
43
- if (this.host && this.host !== host) return false;
44
- return this.pattern.test(pathname);
52
+ if (this.#hostKey !== null && this.#hostKey !== host?.toLowerCase()) return false;
53
+ if (this.pattern.test(pathname)) return true;
54
+ const bare = stripTrailingSlashes(pathname);
55
+ return bare !== pathname && this.pattern.test(bare);
45
56
  }
46
57
  /** La requête tombe-t-elle dans cette zone ? (host éventuel + pathname). */
47
58
  match(context) {
@@ -54,5 +65,4 @@ var SecuredArea = class {
54
65
  return this.matchPath(pathname, context.domain);
55
66
  }
56
67
  };
57
- //#endregion
58
68
  export { SecuredArea, SecuredArea as default };
@@ -0,0 +1,96 @@
1
+ // nodefony/src/accountBinding.ts
2
+ /**
3
+ * Claim d'un jeton d'accès émis par l'application qui porte l'identifiant
4
+ * IMMUABLE du compte (`IUser.id`) — `sub` porte l'identifiant fonctionnel.
5
+ */
6
+ const ACCOUNT_ID_CLAIM = "uid";
7
+ /**
8
+ * Clé, dans `metadata` d'un enregistrement de jeton (refresh, clé d'API) comme
9
+ * dans le `metaBag` d'une session, qui porte l'identifiant immuable du compte.
10
+ */
11
+ const ACCOUNT_ID_KEY = "accountId";
12
+ /**
13
+ * Claim d'un jeton d'accès émis par l'application qui porte la version des
14
+ * identifiants du compte à l'émission (`IUser.credentialsVersion`).
15
+ */
16
+ const CREDENTIALS_VERSION_CLAIM = "cv";
17
+ /**
18
+ * Clé, dans `metadata` d'un enregistrement de jeton comme dans le `metaBag`
19
+ * d'une session, qui porte la version des identifiants lue à l'émission.
20
+ */
21
+ const CREDENTIALS_VERSION_KEY = "credentialsVersion";
22
+ /**
23
+ * La version des identifiants à inscrire dans un artefact qu'on émet pour ce
24
+ * compte — 0 quand le compte n'en porte pas encore.
25
+ *
26
+ * @param user - compte pour lequel l'artefact est émis.
27
+ * @returns la version courante, jamais `null`.
28
+ */
29
+ function credentialsVersionOf(user) {
30
+ return user.credentialsVersion ?? 0;
31
+ }
32
+ /**
33
+ * Dit si un artefact d'accès (jeton, session, clé) appartient bien au compte
34
+ * qu'on vient de recharger par son identifiant fonctionnel, ET s'il a été émis
35
+ * depuis le dernier changement de mot de passe.
36
+ *
37
+ * Les artefacts sont rangés sous l'`identifier`, qu'un compte RECRÉÉ peut
38
+ * reprendre : supprimé hors du framework (`DELETE` SQL, restauration, import)
39
+ * puis revenu sous le même nom, l'identité se rechargerait et l'artefact de
40
+ * l'ancien titulaire ouvrirait le compte du nouveau. L'identifiant immuable
41
+ * lève l'ambiguïté sans dépendre d'aucun événement ni d'aucun stockage : deux
42
+ * comptes successifs portent deux `id` différents. SEULE règle de
43
+ * comparaison — chaque authentificateur l'appelle, aucun ne la recopie.
44
+ *
45
+ * Un artefact SANS liaison est refusé : il a été émis avant que la règle
46
+ * existe, et rien ne dit à qui il appartient.
47
+ *
48
+ * La version des identifiants fait de la coupure au changement de mot de passe
49
+ * un ÉTAT du compte, et non une suppression : si la cascade de révocation
50
+ * échoue, ou si une requête en vol sur un autre processus réécrit la session
51
+ * qu'on vient d'effacer, l'artefact porte toujours l'ancienne version, et il
52
+ * tombe ici. Un artefact émis avant cette règle n'en porte pas : il vaut 0,
53
+ * comme un compte qui n'a jamais changé de mot de passe — le déploiement ne
54
+ * déconnecte personne. Une valeur d'un autre type est refusée.
55
+ *
56
+ * ⚠️ Chemin chaud — appelée sur CHAQUE requête authentifiée : deux comparaisons
57
+ * de chaîne et d'entier, aucune allocation.
58
+ *
59
+ * @param user - compte rechargé par l'identifiant que porte l'artefact.
60
+ * @param boundAccountId - identifiant immuable inscrit dans l'artefact.
61
+ * @param boundCredentialsVersion - version des identifiants inscrite dans
62
+ * l'artefact ({@link CREDENTIALS_VERSION_KEY}), `undefined` si absente.
63
+ * Obligatoire dans la signature pour qu'aucune porte ne l'oublie.
64
+ * @returns `true` si l'artefact a été émis pour CE compte, à sa version courante.
65
+ */
66
+ function isBoundToAccount(user, boundAccountId, boundCredentialsVersion) {
67
+ return typeof boundAccountId === "string" && boundAccountId.length > 0 && boundAccountId === user.id && (boundCredentialsVersion ?? 0) === (user.credentialsVersion ?? 0);
68
+ }
69
+ /**
70
+ * Recharge le compte qu'un jeton d'accès de CETTE application désigne, ou
71
+ * `null` : sujet inconnu, compte inactif ou verrouillé, jeton émis pour un
72
+ * autre compte du même nom ou avant un changement de mot de passe.
73
+ *
74
+ * SEULE implémentation de la règle « ce jeton ouvre-t-il encore ce compte ? »
75
+ * pour un jeton maison : la zone `jwt` (`JwtAuthenticator`) et le résolveur de
76
+ * compte d'une ressource protégée l'appellent, aucun ne la recopie. Un refus
77
+ * est `null`, jamais une exception : chaque appelant le traduit dans son
78
+ * propre contrat (401 uniforme, outil en `isError`).
79
+ *
80
+ * @param provider - annuaire des comptes de l'application.
81
+ * @param identifier - identifiant fonctionnel porté par le jeton (`sub`).
82
+ * @param boundAccountId - identifiant immuable inscrit dans le jeton ({@link ACCOUNT_ID_CLAIM}).
83
+ * @param boundCredentialsVersion - version des identifiants inscrite ({@link CREDENTIALS_VERSION_CLAIM}).
84
+ * @returns le compte, ou `null` s'il n'est pas (ou plus) celui du jeton.
85
+ */
86
+ async function loadBoundAccount(provider, identifier, boundAccountId, boundCredentialsVersion) {
87
+ let user;
88
+ try {
89
+ user = await provider.loadUserByIdentifier(identifier);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!user.isActive() || user.isLocked()) return null;
94
+ return isBoundToAccount(user, boundAccountId, boundCredentialsVersion) ? user : null;
95
+ }
96
+ export { ACCOUNT_ID_CLAIM, ACCOUNT_ID_KEY, CREDENTIALS_VERSION_CLAIM, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount, loadBoundAccount };