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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,16 +1,18 @@
1
1
  import { JwtKeystore } from "../src/token/JwtKeystore.js";
2
+ import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
2
3
  import { InsecureTransportError } from "../errors/InsecureTransportError.js";
3
4
  import { AuthenticationError } from "../errors/AuthenticationError.js";
4
5
  import { ThrottledError } from "../errors/ThrottledError.js";
5
6
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
7
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
6
8
  import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
7
9
  import { recordAudit } from "../src/audit/recordAudit.js";
8
10
  import { readAuditContext } from "../src/audit/readAuditContext.js";
9
11
  import { InvalidTargetError } from "../errors/InvalidTargetError.js";
10
12
  import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
11
- import { AUTO_STORE, BootConfigurationError, 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";
12
14
  import { createHash, randomBytes, randomUUID } from "node:crypto";
13
- //#region nodefony/service/tokenService.ts
15
+ // nodefony/service/tokenService.ts
14
16
  const serviceName = "tokenService";
15
17
  /**
16
18
  * Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
@@ -39,6 +41,8 @@ var TokenService = class extends Service {
39
41
  #throttler = null;
40
42
  #throttlerResolved = false;
41
43
  #transport = null;
44
+ /** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
45
+ #scopeGrants = null;
42
46
  #gc = null;
43
47
  constructor(module) {
44
48
  super(serviceName, module.container, module.notificationsCenter, module.options);
@@ -87,6 +91,7 @@ var TokenService = class extends Service {
87
91
  }
88
92
  const jwtEnabled = config.jwt.enabled;
89
93
  const apiKeysEnabled = config.apiKeys.enabled;
94
+ this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
90
95
  if (!jwtEnabled && !apiKeysEnabled) {
91
96
  this.log("token service idle — JWT et clés API désactivés", "DEBUG");
92
97
  return;
@@ -94,7 +99,7 @@ var TokenService = class extends Service {
94
99
  let storeName = config.tokenStore.store;
95
100
  let reason = `store explicitement configuré ("${storeName}")`;
96
101
  if (storeName === AUTO_STORE) {
97
- 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));
98
103
  storeName = auto.store;
99
104
  reason = auto.reason;
100
105
  this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
@@ -106,7 +111,7 @@ var TokenService = class extends Service {
106
111
  this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
107
112
  return;
108
113
  }
109
- 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");
110
115
  this.#store = factory({
111
116
  container: this.container,
112
117
  config
@@ -340,26 +345,30 @@ var TokenService = class extends Service {
340
345
  * d'informer le client — c'est ce que fait le champ `scope` de la réponse,
341
346
  * déjà rempli avec les scopes réellement accordés.
342
347
  *
343
- * Les scopes d'une APPLICATION ne sont pas jugés ici : c'est à elle de dire
344
- * 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.
345
352
  *
346
353
  * @param user - le porteur, déjà authentifié.
347
354
  * @param requested - ce qu'il demande.
348
355
  * @returns ce qui lui est accordé.
349
356
  */
350
357
  #grantableScopes(user, requested) {
358
+ if (requested.length === 0) return requested;
351
359
  const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
352
- const refuses = refusedAdminScopes(requested, roles);
353
- if (refuses.length === 0) return requested;
354
- 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");
355
- 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));
356
365
  }
357
366
  async issueTokens(user, requestedScopes, resource, accessTtlS) {
358
367
  const { store, runtime } = this.#ensureReady();
359
368
  const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
360
369
  const audience = this.#resolveAudience(runtime, resource);
361
- const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
362
- 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);
363
372
  await store.put(record);
364
373
  recordAudit(this.container, {
365
374
  category: "token",
@@ -418,10 +427,11 @@ var TokenService = class extends Service {
418
427
  const now = Date.now();
419
428
  if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
420
429
  const user = await this.#resolveUserForRefresh(record.subjectId);
421
- 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]);
422
432
  const audience = record.audience?.at(0) ?? defaultAudience(runtime);
423
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);
424
- const access = await this.#signAccess(user.identifier, scopes, audience);
434
+ const access = await this.#signAccess(user, scopes, audience);
425
435
  if (!runtime.rotateRefresh) return {
426
436
  access_token: access,
427
437
  refresh_token: rawRefresh,
@@ -430,7 +440,7 @@ var TokenService = class extends Service {
430
440
  scope: scopes.join(" ")
431
441
  };
432
442
  const family = record.family ?? this.#randomId();
433
- const next = this.#buildRefresh(user.identifier, scopes, family, audience);
443
+ const next = this.#buildRefresh(user, scopes, family, audience);
434
444
  await store.put(next.record);
435
445
  record.replacedBy = next.record.id;
436
446
  record.revokedAt = now;
@@ -444,15 +454,19 @@ var TokenService = class extends Service {
444
454
  scope: scopes.join(" ")
445
455
  };
446
456
  }
447
- async #signAccess(subject, scopes, audience, ttlS) {
457
+ async #signAccess(user, scopes, audience, ttlS) {
448
458
  const { keystore, runtime: rt } = this.#ensureReady();
449
459
  const jose = await this.#ensureJose();
450
460
  const { key, kid } = await keystore.getSigningKey();
451
- 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({
452
466
  alg: "EdDSA",
453
467
  kid,
454
468
  typ: "at+jwt"
455
- }).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);
456
470
  }
457
471
  /**
458
472
  * Construit (sans persister) un record refresh + son secret opaque en clair.
@@ -462,7 +476,7 @@ var TokenService = class extends Service {
462
476
  * silencieusement l'accès à l'audience par défaut — un downscoping
463
477
  * qui s'annule au bout de quelques minutes n'en est pas un.
464
478
  */
465
- #buildRefresh(subject, scopes, family, audience) {
479
+ #buildRefresh(user, scopes, family, audience) {
466
480
  const raw = `nfr_${randomBytes(32).toString("base64url")}`;
467
481
  const now = Date.now();
468
482
  return {
@@ -471,7 +485,7 @@ var TokenService = class extends Service {
471
485
  kind: "refresh",
472
486
  name: "refresh token",
473
487
  prefix: null,
474
- subjectId: subject,
488
+ subjectId: user.identifier,
475
489
  subjectType: "user",
476
490
  tenantId: null,
477
491
  scopes: [...scopes],
@@ -490,7 +504,10 @@ var TokenService = class extends Service {
490
504
  lastUsedUserAgent: null,
491
505
  revokedAt: null,
492
506
  revokedReason: null,
493
- metadata: {}
507
+ metadata: {
508
+ [ACCOUNT_ID_KEY]: user.id,
509
+ [CREDENTIALS_VERSION_KEY]: credentialsVersionOf(user)
510
+ }
494
511
  },
495
512
  raw
496
513
  };
@@ -543,5 +560,4 @@ var TokenService = class extends Service {
543
560
  return this.#throttler;
544
561
  }
545
562
  };
546
- //#endregion
547
563
  export { TokenService, TokenService as default };
@@ -5,8 +5,8 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
5
5
  import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
6
6
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
7
7
  import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
8
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
9
- //#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
10
10
  const serviceName = "totp";
11
11
  function isFlushable(s) {
12
12
  return s !== null && typeof s.flushNow === "function";
@@ -93,7 +93,7 @@ var TotpService = class extends Service {
93
93
  let driver = config.totp.store;
94
94
  let reason = `store explicitement configuré ("${driver}")`;
95
95
  if (driver === AUTO_STORE) {
96
- 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));
97
97
  driver = auto.store;
98
98
  reason = auto.reason;
99
99
  this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -105,7 +105,7 @@ var TotpService = class extends Service {
105
105
  this.log(`${msg} — 2FA indisponible`, "CRITIC");
106
106
  return null;
107
107
  }
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(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");
109
109
  const store = factory({
110
110
  container: this.container,
111
111
  config
@@ -252,5 +252,4 @@ var TotpService = class extends Service {
252
252
  return this.#deps;
253
253
  }
254
254
  };
255
- //#endregion
256
255
  export { TotpService, TotpService as default };
@@ -2,10 +2,10 @@ 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
6
  import { createHash } from "node:crypto";
7
7
  import { Buffer } from "node:buffer";
8
- //#region nodefony/service/webAuthn.ts
8
+ // nodefony/service/webAuthn.ts
9
9
  const serviceName = "webauthn";
10
10
  function isFlushable(s) {
11
11
  return s !== null && typeof s.flushNow === "function";
@@ -92,7 +92,7 @@ var WebAuthnService = class extends Service {
92
92
  let driver = config.passkeys.store;
93
93
  reason = `store explicitement configuré ("${driver}")`;
94
94
  if (driver === AUTO_STORE) {
95
- 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));
96
96
  driver = auto.store;
97
97
  reason = auto.reason;
98
98
  this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -104,7 +104,7 @@ var WebAuthnService = class extends Service {
104
104
  this.log(`${msg} — passkeys indisponibles`, "CRITIC");
105
105
  return;
106
106
  }
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(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");
108
108
  this.#store = factory({
109
109
  container: this.container,
110
110
  config
@@ -365,5 +365,4 @@ var WebAuthnService = class extends Service {
365
365
  };
366
366
  }
367
367
  };
368
- //#endregion
369
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,5 +1,5 @@
1
1
  import { stripTrailingSlashes } from "nodefony";
2
- //#region nodefony/src/SecuredArea.ts
2
+ // nodefony/src/SecuredArea.ts
3
3
  /**
4
4
  * Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
5
5
  *
@@ -65,5 +65,4 @@ var SecuredArea = class {
65
65
  return this.matchPath(pathname, context.domain);
66
66
  }
67
67
  };
68
- //#endregion
69
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 };
@@ -3,7 +3,7 @@ import { AUDIT_FILTERS } from "../audit/auditFilters.js";
3
3
  import { adminActor, auditAdmin } from "./adminAudit.js";
4
4
  import { webhookAdminEndpoints } from "./WebhookAdminApi.js";
5
5
  import { parseFilters, parsePageQuery } from "nodefony";
6
- //#region nodefony/src/admin/SecurityAdminApi.ts
6
+ // nodefony/src/admin/SecurityAdminApi.ts
7
7
  /**
8
8
  * Déduit le **driver** logique d'un store à partir de son nom de classe — le
9
9
  * `tokenService` connaît le driver config (`tokenStore.store`) mais ne l'expose
@@ -522,5 +522,4 @@ function registerSecurityAdminApi(registry, container) {
522
522
  if (registry.has("security")) return;
523
523
  registry.register(createSecurityAdminApi(container));
524
524
  }
525
- //#endregion
526
525
  export { createSecurityAdminApi, parseAuditQuery, parseTokenListQuery, registerSecurityAdminApi };
@@ -1,7 +1,7 @@
1
1
  import { WEBHOOK_FACETS, WEBHOOK_FILTERS, WEBHOOK_STATS_FILTERS } from "../webhook/webhookFilters.js";
2
2
  import { adminActor, auditAdmin } from "./adminAudit.js";
3
- import { parseFilters, parsePageQuery } from "nodefony";
4
- //#region nodefony/src/admin/WebhookAdminApi.ts
3
+ import { isPlainObject, parseFilters, parsePageQuery } from "nodefony";
4
+ // nodefony/src/admin/WebhookAdminApi.ts
5
5
  /**
6
6
  * Déduit le driver logique du store webhook depuis son nom de classe (miroir de
7
7
  * `tokenStoreDriver`). Fermé sur les implémentations connues ; `null` pour un
@@ -30,6 +30,21 @@ function bodyStringArray(v) {
30
30
  if (!Array.isArray(v) || v.length === 0) return void 0;
31
31
  return v.every((x) => typeof x === "string" && x.length > 0) ? v : void 0;
32
32
  }
33
+ /**
34
+ * Le corps d'une requête d'écriture : un objet JSON, ou rien (lu comme `{}`).
35
+ * Un tableau ou un scalaire rend `null` — l'appelant répond 400 au lieu de
36
+ * laisser un `in` lever sur une chaîne.
37
+ */
38
+ function bodyObject(request) {
39
+ const body = request.body;
40
+ if (body === void 0 || body === null) return {};
41
+ return isPlainObject(body) ? body : null;
42
+ }
43
+ /** Réponse au corps qui n'est pas un objet JSON. */
44
+ const BODY_NOT_OBJECT = {
45
+ status: 400,
46
+ body: { error: "JSON object body required" }
47
+ };
33
48
  /** Page par défaut du listing d'endpoints (registre de config, volume modeste). */
34
49
  const ENDPOINTS_DEFAULT_LIMIT = 50;
35
50
  /** Plafond dur : un client ne peut pas demander « tout » via `?limit=`. */
@@ -75,8 +90,8 @@ function parseWebhookListQuery(query, sortable) {
75
90
  * fail-closed, et logge).
76
91
  */
77
92
  function mapWebhookError(e) {
78
- if (e.code === 422) {
79
- const message = e.message;
93
+ if (typeof e === "object" && e !== null && "code" in e && e.code === 422) {
94
+ const message = "message" in e ? e.message : void 0;
80
95
  return {
81
96
  status: 422,
82
97
  body: { error: typeof message === "string" ? message : "invalid url" }
@@ -170,7 +185,8 @@ function webhookAdminEndpoints(container) {
170
185
  handler: async (request) => {
171
186
  const s = svc();
172
187
  if (!ready(s)) return UNAVAILABLE;
173
- const body = request.body ?? {};
188
+ const body = bodyObject(request);
189
+ if (body === null) return BODY_NOT_OBJECT;
174
190
  const url = bodyString(body.url);
175
191
  if (!url) return {
176
192
  status: 400,
@@ -252,7 +268,8 @@ function webhookAdminEndpoints(container) {
252
268
  if (!ready(s)) return UNAVAILABLE;
253
269
  const id = pathId(request);
254
270
  if (!id) return NOT_FOUND;
255
- const body = request.body ?? {};
271
+ const body = bodyObject(request);
272
+ if (body === null) return BODY_NOT_OBJECT;
256
273
  const patch = {};
257
274
  const fields = [];
258
275
  if ("url" in body) {
@@ -375,5 +392,4 @@ function webhookAdminEndpoints(container) {
375
392
  }
376
393
  ];
377
394
  }
378
- //#endregion
379
395
  export { parseWebhookListQuery, webhookAdminEndpoints };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/admin/adminAudit.ts
1
+ // nodefony/src/admin/adminAudit.ts
2
2
  /**
3
3
  * Helpers d'audit PARTAGÉS par les producteurs admin du module sécurité
4
4
  * (`SecurityAdminApi`, `WebhookAdminApi`…). Extraits dans leur propre fichier
@@ -32,5 +32,4 @@ function adminActor(user) {
32
32
  function auditAdmin(container, draft) {
33
33
  container.get("auditService")?.record?.(draft);
34
34
  }
35
- //#endregion
36
35
  export { adminActor, auditAdmin };
@@ -1,5 +1,27 @@
1
1
  import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
2
- //#region nodefony/src/admin/userRevocationCascade.ts
2
+ // nodefony/src/admin/userRevocationCascade.ts
3
+ /** Garde : la valeur sait-elle journaliser (`log` est une fonction) ? */
4
+ function isKernelLogger(value) {
5
+ return typeof value === "object" && value !== null && "log" in value && typeof value.log === "function";
6
+ }
7
+ /**
8
+ * Journalise l'échec d'une brique de la cascade. Le geste reste best-effort —
9
+ * une brique en panne n'empêche pas les autres — mais il ne peut plus échouer
10
+ * EN SILENCE : un accès qui survit à sa révocation (session encore ouverte
11
+ * après un changement de mot de passe) doit laisser une trace qui dit laquelle.
12
+ *
13
+ * @param container - container du kernel (service `kernel`).
14
+ * @param brick - brique en échec (`sessions`, `tokenStore`, `totp`, `webauthn`).
15
+ * @param identifier - compte visé par la cascade.
16
+ * @param error - erreur levée par la brique.
17
+ */
18
+ function reportCascadeFailure(container, brick, identifier, error) {
19
+ try {
20
+ const kernel = container.get("kernel");
21
+ if (!isKernelLogger(kernel)) return;
22
+ kernel.log(`révocation de "${identifier}" incomplète : la brique ${brick} a échoué — ` + (error instanceof Error ? error.message : String(error)), "ERROR", "SECURITY");
23
+ } catch {}
24
+ }
3
25
  /**
4
26
  * Cascade de révocation déclenchée par {@link USER_REVOKED_EVENT} : éjecte
5
27
  * **immédiatement** les artefacts d'accès du porteur — ses **sessions** (http,
@@ -22,13 +44,51 @@ import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
22
44
  */
23
45
  async function cascadeUserRevocation(container, event, now = Date.now()) {
24
46
  const identifier = event.identifier;
47
+ await revokeAccessArtifacts(container, identifier, now);
48
+ if (event.reason === "deleted") await removeAccountFactors(container, identifier);
49
+ }
50
+ /**
51
+ * Éjecte les artefacts d'accès rangés sous un identifiant — ses sessions (http,
52
+ * `destroyByUser`) et ses jetons/PAT/clés d'API (`tokenStore.revokeAllForSubject`,
53
+ * seuil `invalidBefore`). SEULE implémentation du geste, appelée à la révocation
54
+ * d'un compte ET à sa création. Best-effort par brique : l'indisponibilité de
55
+ * l'une n'empêche pas l'autre.
56
+ *
57
+ * @param container - container du kernel (services `sessions`, `tokenStore`).
58
+ * @param identifier - `IUser.identifier` du compte.
59
+ * @param invalidBefore - seuil (epoch ms) : tout jeton émis avant est rejeté.
60
+ */
61
+ async function revokeAccessArtifacts(container, identifier, invalidBefore) {
25
62
  try {
26
63
  await container.get("sessions")?.destroyByUser?.(identifier);
27
- } catch {}
64
+ } catch (error) {
65
+ reportCascadeFailure(container, "sessions", identifier, error);
66
+ }
28
67
  try {
29
- await container.get("tokenStore")?.revokeAllForSubject(identifier, now);
30
- } catch {}
31
- if (event.reason === "deleted") await removeAccountFactors(container, identifier);
68
+ await container.get("tokenStore")?.revokeAllForSubject(identifier, invalidBefore);
69
+ } catch (error) {
70
+ reportCascadeFailure(container, "tokenStore", identifier, error);
71
+ }
72
+ }
73
+ /**
74
+ * Purge, à la NAISSANCE d'un compte, tout ce qu'un ancien titulaire du même
75
+ * identifiant a laissé derrière lui : sessions, jetons et facteurs. Un compte
76
+ * qui vient de naître n'a encore rien émis — tout artefact déjà rangé sous son
77
+ * identifiant appartient donc à quelqu'un d'autre. C'est ce qui ferme le cas
78
+ * d'un compte supprimé HORS du framework (`DELETE` SQL, aucun événement), dont
79
+ * l'accès redeviendrait valide dès qu'un compte renaît sous son nom.
80
+ *
81
+ * Le seuil est tronqué à la SECONDE : l'`iat` d'un JWT l'est aussi (RFC 7519
82
+ * §2, NumericDate), et un seuil à la milliseconde rejetterait le jeton du
83
+ * nouveau titulaire émis dans la même seconde que sa création.
84
+ *
85
+ * @param container - container du kernel.
86
+ * @param identifier - `IUser.identifier` du compte créé.
87
+ * @param now - horloge (epoch ms) injectable pour les tests.
88
+ */
89
+ async function purgeInheritedAccess(container, identifier, now = Date.now()) {
90
+ await revokeAccessArtifacts(container, identifier, Math.floor(now / 1e3) * 1e3);
91
+ await removeAccountFactors(container, identifier);
32
92
  }
33
93
  /**
34
94
  * Retire les facteurs rangés sous un identifiant — secret TOTP (codes de
@@ -44,14 +104,18 @@ async function removeAccountFactors(container, identifier) {
44
104
  try {
45
105
  const totp = container.get("totp");
46
106
  if (totp?.isEnabled?.() !== false) await totp?.disable?.(identifier);
47
- } catch {}
107
+ } catch (error) {
108
+ reportCascadeFailure(container, "totp", identifier, error);
109
+ }
48
110
  try {
49
111
  const webauthn = container.get("webauthn");
50
112
  if (webauthn?.isEnabled?.() !== false) {
51
113
  const creds = await webauthn?.listUserCredentials?.(identifier) ?? [];
52
114
  for (const cred of creds) await webauthn?.removeUserCredential?.(identifier, cred.id);
53
115
  }
54
- } catch {}
116
+ } catch (error) {
117
+ reportCascadeFailure(container, "webauthn", identifier, error);
118
+ }
55
119
  }
56
120
  /** Garde : charge utile de {@link USER_CREATED_EVENT} (identifiant non vide). */
57
121
  function isUserCreatedEvent(value) {
@@ -62,11 +126,12 @@ function isUserCreatedEvent(value) {
62
126
  * `@nodefony/security` — avant qu'aucun compte ne puisse être créé ou révoqué
63
127
  * (les comptes semés au démarrage compris). Deux abonnements :
64
128
  * - {@link USER_REVOKED_EVENT} → {@link cascadeUserRevocation} ;
65
- * - {@link USER_CREATED_EVENT} → purge des facteurs HÉRITÉS : un compte qui
66
- * vient de naître n'a encore rien enrôlé, donc tout facteur déjà rangé sous
67
- * son identifiant appartient à un ancien titulaire. Règle exacte, aucune
68
- * date à comparer. ⚠️ Un import qui chargerait les facteurs AVANT les
69
- * comptes les verrait retirés : importer les comptes d'abord.
129
+ * - {@link USER_CREATED_EVENT} → {@link purgeInheritedAccess} : sessions,
130
+ * jetons et facteurs HÉRITÉS. Un compte qui vient de naître n'a encore rien
131
+ * émis ni enrôlé, donc tout artefact déjà rangé sous son identifiant
132
+ * appartient à un ancien titulaire. Règle exacte, aucune date à comparer.
133
+ * ⚠️ Un import qui chargerait les facteurs AVANT les comptes les verrait
134
+ * retirés : importer les comptes d'abord.
70
135
  *
71
136
  * Les deux sont émis par `UserService` seul, et attendus : chaque handler rend
72
137
  * sa promesse. **Extensible** : tout autre module (webhooks…) s'abonne aux
@@ -77,7 +142,7 @@ function isUserCreatedEvent(value) {
77
142
  */
78
143
  function registerUserRevocationCascade(kernel, container) {
79
144
  kernel.on(USER_REVOKED_EVENT, (event) => cascadeUserRevocation(container, event));
80
- kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ? removeAccountFactors(container, event.identifier) : void 0);
145
+ kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ? purgeInheritedAccess(container, event.identifier) : void 0);
81
146
  }
82
147
  /**
83
148
  * Avis de démarrage quand le service `users` n'émet pas sur le bus du kernel —
@@ -100,5 +165,4 @@ function usersEventBusNotice(container, kernel) {
100
165
  fix: "new UserService(repository, encoder, module.container)"
101
166
  };
102
167
  }
103
- //#endregion
104
- export { cascadeUserRevocation, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };
168
+ export { cascadeUserRevocation, purgeInheritedAccess, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };