@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/nodefony/command/security-token.js +4 -4
  2. package/dist/nodefony/command/security-user-delete.js +2 -2
  3. package/dist/nodefony/command/security-user-list.js +1 -1
  4. package/dist/nodefony/service/apiKeys.js +2 -2
  5. package/dist/nodefony/service/auditService.js +3 -3
  6. package/dist/nodefony/service/authFlow.js +2 -2
  7. package/dist/nodefony/service/authorization.js +3 -4
  8. package/dist/nodefony/service/firewall.js +21 -9
  9. package/dist/nodefony/service/oauth2.js +5 -3
  10. package/dist/nodefony/service/securityHeaders.js +1 -1
  11. package/dist/nodefony/service/tokenService.js +27 -18
  12. package/dist/nodefony/service/webAuthn.js +38 -31
  13. package/dist/nodefony/service/webhooks.js +50 -41
  14. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  15. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  16. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  17. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  18. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  19. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  20. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  21. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  22. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  23. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  24. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  25. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  26. package/dist/nodefony/src/csp.js +4 -5
  27. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  28. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  29. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  30. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
  31. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  32. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  33. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  34. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  35. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
  36. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  37. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  38. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  39. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  40. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  41. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  42. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  43. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  44. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  45. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  46. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  47. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  48. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  49. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  50. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  51. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  52. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  53. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  54. package/docs/api-keys.md +15 -15
  55. package/docs/audit.md +18 -13
  56. package/docs/authenticators.md +6 -6
  57. package/docs/authorization.md +15 -15
  58. package/docs/cors.md +6 -6
  59. package/docs/csrf.md +7 -7
  60. package/docs/external-jwt.md +17 -9
  61. package/docs/firewall.md +7 -7
  62. package/docs/headers.md +13 -13
  63. package/docs/index.md +3 -1
  64. package/docs/oauth2.md +2 -2
  65. package/docs/tokens.md +6 -6
  66. package/docs/webauthn.md +13 -13
  67. package/docs/webhooks.md +9 -12
  68. package/package.json +13 -13
@@ -223,7 +223,7 @@ var SecurityToken = class extends Command {
223
223
  identifier = await this.prompts.select({
224
224
  message: "Compte porteur du jeton :",
225
225
  choices: page.items.map((u) => ({
226
- name: `${u.identifier}${(u.roles ?? []).length ? ` ${DIM}(${(u.roles ?? []).join(", ")})${RESET}` : ""}`,
226
+ name: `${u.identifier}${u.roles.length ? ` ${DIM}(${u.roles.join(", ")})${RESET}` : ""}`,
227
227
  value: u.identifier
228
228
  }))
229
229
  });
@@ -269,7 +269,7 @@ var SecurityToken = class extends Command {
269
269
  throw e;
270
270
  }
271
271
  const token = issued.access_token;
272
- const accordes = (issued.scope ?? "").split(/\s+/u).filter(Boolean);
272
+ const accordes = issued.scope.split(/\s+/u).filter(Boolean);
273
273
  const nonAccordes = scopes.filter((s) => !accordes.includes(s));
274
274
  if (opts.json) {
275
275
  process.stdout.write(`${JSON.stringify({
@@ -285,7 +285,7 @@ var SecurityToken = class extends Command {
285
285
  process.stdout.write(s);
286
286
  };
287
287
  let write = opts.write === true;
288
- if (!write && process.stdin.isTTY && !opts.json) {
288
+ if (!write && process.stdin.isTTY) {
289
289
  await this.loadPrompts();
290
290
  write = await this.prompts.confirm({
291
291
  message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
@@ -293,7 +293,7 @@ var SecurityToken = class extends Command {
293
293
  });
294
294
  }
295
295
  if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement.${RESET}\n`);
296
- const minutes = Math.round((issued.expires_in ?? 0) / 60);
296
+ const minutes = Math.round(issued.expires_in / 60);
297
297
  w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${minutes} min${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
298
298
  if (nonAccordes.length > 0) w(`${DIM} ⚠️ non accordé(s) : ${nonAccordes.join(" ")} — réservé(s) au rôle d'administration, que le compte « ${identifier} » ne porte pas.${RESET}\n\n`);
299
299
  if (write) {
@@ -64,7 +64,7 @@ var SecurityUserDelete = class extends Command {
64
64
  process.exitCode = 1;
65
65
  return this;
66
66
  }
67
- if ((user.roles ?? []).includes(ADMIN_ROLE)) {
67
+ if (user.roles.includes(ADMIN_ROLE)) {
68
68
  if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) {
69
69
  this.log(`« ${identifier} » est le DERNIER administrateur actif — refus.\n Sans lui, plus personne n'administre cette application.\n Crée d'abord un autre admin : nodefony security:user:add <id> --admin`, "ERROR");
70
70
  process.exitCode = 1;
@@ -74,7 +74,7 @@ var SecurityUserDelete = class extends Command {
74
74
  const w = (t) => {
75
75
  process.stdout.write(t);
76
76
  };
77
- w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${(user.roles ?? []).join(", ") || "—"}\n id interne : ${DIM}${user.id}${RESET}\n\n`);
77
+ w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${user.roles.join(", ") || "—"}\n id interne : ${DIM}${user.id}${RESET}\n\n`);
78
78
  if (!opts.yes) {
79
79
  if (!process.stdin.isTTY) {
80
80
  this.log("confirmation impossible sans terminal — relance avec --yes si c'est voulu.", "ERROR");
@@ -52,7 +52,7 @@ var SecurityUserList = class extends Command {
52
52
  });
53
53
  const rows = page.items.map((u) => ({
54
54
  identifiant: u.identifier,
55
- rôles: (u.roles ?? []).join(", ") || "—",
55
+ rôles: u.roles.join(", ") || "—",
56
56
  actif: u.isActive() ? "oui" : "non",
57
57
  verrouillé: u.isLocked() ? "OUI" : "—",
58
58
  id: u.id
@@ -199,7 +199,7 @@ var ApiKeyService = class extends Service {
199
199
  async revokeAnyPat(id, actorId) {
200
200
  const store = this.#resolveStore();
201
201
  const record = await store.findById(id);
202
- if (!record || record.kind !== "pat") return null;
202
+ if (record?.kind !== "pat") return null;
203
203
  await store.revoke(id, "manual");
204
204
  this.log(`api key revoked by admin — id=${id} actor=${actorId} subject=${record.subjectId}`, "INFO");
205
205
  recordAudit(this.container, {
@@ -240,7 +240,7 @@ var ApiKeyService = class extends Service {
240
240
  async revokeForSubject(subjectId, id) {
241
241
  const store = this.#resolveStore();
242
242
  const record = await store.findById(id);
243
- if (!record || record.kind !== "pat" || record.subjectId !== subjectId) return false;
243
+ if (record?.kind !== "pat" || record.subjectId !== subjectId) return false;
244
244
  await store.revoke(id, "manual");
245
245
  this.log(`api key revoked — id=${id} subject=${subjectId}`, "INFO");
246
246
  recordAudit(this.container, {
@@ -108,14 +108,14 @@ var AuditService = class extends Service {
108
108
  ts: Date.now()
109
109
  };
110
110
  this.#store.append(event).catch((error) => this.log(error, "ERROR"));
111
- if (this.#listeners !== null) for (let i = 0; i < this.#listeners.length; i++) try {
112
- this.#listeners[i](event);
111
+ if (this.#listeners !== null) for (const listener of this.#listeners) try {
112
+ listener(event);
113
113
  } catch (error) {
114
114
  this.log(error, "ERROR");
115
115
  }
116
116
  }
117
117
  subscribe(listener) {
118
- if (this.#listeners === null) this.#listeners = [];
118
+ this.#listeners ??= [];
119
119
  this.#listeners.push(listener);
120
120
  let active = true;
121
121
  return () => {
@@ -238,7 +238,7 @@ var AuthFlow = class extends Service {
238
238
  */
239
239
  async logout(context) {
240
240
  const session = context.session;
241
- if (!session || session.status !== "active") return false;
241
+ if (session?.status !== "active") return false;
242
242
  const actor = typeof session.user === "string" ? session.user : null;
243
243
  await session.destroy(true);
244
244
  context.session = null;
@@ -315,7 +315,7 @@ var AuthFlow = class extends Service {
315
315
  #resolveTotp() {
316
316
  if (!this.#totpResolved) {
317
317
  const svc = this.get("totp") ?? null;
318
- this.#totp = svc && svc.isEnabled() ? svc : null;
318
+ this.#totp = svc?.isEnabled() ? svc : null;
319
319
  this.#totpResolved = true;
320
320
  }
321
321
  return this.#totp;
@@ -45,8 +45,7 @@ var Authorization = class extends Service {
45
45
  const voters = this.#voters ?? this.#build();
46
46
  let granted = false;
47
47
  let considered = 0;
48
- for (let i = 0; i < voters.length; i++) {
49
- const voter = voters[i];
48
+ for (const voter of voters) {
50
49
  if (!voter.supports(attribute, subject)) continue;
51
50
  considered++;
52
51
  let vote;
@@ -88,8 +87,8 @@ var Authorization = class extends Service {
88
87
  /** Descripteur léger d'un sujet pour l'audit (jamais de JSON.stringify aveugle). */
89
88
  function describeSubject(subject) {
90
89
  if (typeof subject === "string") return subject;
91
- if (subject === null) return "null";
92
- return subject?.constructor?.name ?? typeof subject;
90
+ if (subject === null || subject === void 0) return String(subject);
91
+ return subject.constructor?.name ?? typeof subject;
93
92
  }
94
93
  //#endregion
95
94
  export { Authorization, Authorization as default };
@@ -47,6 +47,11 @@ function cookieValue(cookieHeader, name) {
47
47
  }
48
48
  }
49
49
  /**
50
+ * Défi posé sur un 401 quand aucun authenticator de la zone n'en déclare
51
+ * (RFC 9110 §15.5.2). Schéma inconnu des navigateurs : aucune fenêtre de login.
52
+ */
53
+ const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
54
+ /**
50
55
  * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
51
56
  *
52
57
  * `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
@@ -63,8 +68,9 @@ function cookieValue(cookieHeader, name) {
63
68
  * d'identité down, câblage manquant) → log ERROR serveur + 401 générique
64
69
  * (aucun détail ne fuite au client).
65
70
  *
66
- * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 7235) fourni
67
- * par le premier authenticator de la zone qui en déclare un.
71
+ * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
72
+ * §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
73
+ * le défi de repli {@link FALLBACK_CHALLENGE}.
68
74
  *
69
75
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
70
76
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -109,10 +115,9 @@ var Firewall = class extends Service {
109
115
  if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
110
116
  if (this.#config.headers.enabled) this.#securityHeaders = new SecurityHeaders(this.#config.headers);
111
117
  const areas = this.#config.areas;
112
- const names = Object.keys(areas);
113
- if (names.length) {
118
+ if (Object.keys(areas).length) {
114
119
  const list = [];
115
- for (const name of names) list.push(new SecuredArea(name, areas[name]));
120
+ for (const [name, area] of Object.entries(areas)) list.push(new SecuredArea(name, area));
116
121
  list.sort((a, b) => b.pattern.source.length - a.pattern.source.length);
117
122
  this.#areas = list;
118
123
  this.#instantiateAuthenticators(list);
@@ -333,7 +338,7 @@ var Firewall = class extends Service {
333
338
  describeRoleHierarchy() {
334
339
  const raw = this.#config?.roleHierarchy ?? {};
335
340
  const hierarchy = {};
336
- for (const role of Object.keys(raw)) hierarchy[role] = [...raw[role]];
341
+ for (const [role, inherited] of Object.entries(raw)) hierarchy[role] = [...inherited];
337
342
  const walker = this.roleHierarchy;
338
343
  return {
339
344
  hierarchy,
@@ -565,7 +570,10 @@ var Firewall = class extends Service {
565
570
  if (typeof response?.setHeader !== "function") return void 0;
566
571
  const isPreflight = context.method?.toUpperCase() === "OPTIONS" && headerValue(headers, "access-control-request-method") !== void 0;
567
572
  const corsHeaders = isPreflight ? this.#cors.preflightHeaders(origin) : this.#cors.actualHeaders(origin);
568
- if (corsHeaders) for (const name in corsHeaders) response.setHeader(name, corsHeaders[name]);
573
+ if (corsHeaders) for (const name in corsHeaders) {
574
+ const value = corsHeaders[name];
575
+ if (value !== void 0) response.setHeader(name, value);
576
+ }
569
577
  return isPreflight ? 204 : void 0;
570
578
  }
571
579
  /**
@@ -584,7 +592,10 @@ var Firewall = class extends Service {
584
592
  const response = context.response;
585
593
  if (typeof response?.setHeader !== "function") return;
586
594
  const headers = sh.headers;
587
- for (const name in headers) response.setHeader(name, headers[name]);
595
+ for (const name in headers) {
596
+ const value = headers[name];
597
+ if (value !== void 0) response.setHeader(name, value);
598
+ }
588
599
  const extra = context.cspDirectives;
589
600
  if (sh.hasNonce) {
590
601
  const nonce = context.cspNonce;
@@ -665,7 +676,7 @@ var Firewall = class extends Service {
665
676
  throw error;
666
677
  }
667
678
  if (error instanceof UnverifiableTokenError) {
668
- this.log(`token unverifiable (area "${area.name}", authenticator "${name}") — ${error.detail ?? "cause non renseignée"}`, "ERROR");
679
+ this.log(`token unverifiable (area "${area.name}", authenticator "${name}") — ` + (error.detail ?? "cause non renseignée"), "ERROR");
669
680
  this.log(error, "ERROR");
670
681
  throw error;
671
682
  }
@@ -689,6 +700,7 @@ var Firewall = class extends Service {
689
700
  return;
690
701
  }
691
702
  }
703
+ response.setHeader("WWW-Authenticate", FALLBACK_CHALLENGE);
692
704
  }
693
705
  log(pci, severity, msgid, msg) {
694
706
  if (!msgid) msgid = logColor.cyan("FIREWALL");
@@ -135,7 +135,7 @@ var OAuth2Service = class extends Service {
135
135
  listDisplayProviders() {
136
136
  if (this.#config === null) return [];
137
137
  const configured = this.#config.oauth2.providers;
138
- return this.listProviders().filter((name) => configured[name]?.hidden !== true).map((name) => ({
138
+ return this.listProviders().filter((name) => !configured[name]?.hidden).map((name) => ({
139
139
  name,
140
140
  label: configured[name]?.label ?? oauthDisplayLabel(name)
141
141
  }));
@@ -192,7 +192,7 @@ var OAuth2Service = class extends Service {
192
192
  codeVerifier
193
193
  });
194
194
  const profile = await p.fetchProfile(tokens);
195
- const cfg = this.#config.oauth2;
195
+ const cfg = this.#ensureReady().oauth2;
196
196
  const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
197
197
  return { identifier: (await this.#resolveProvisioner().provisionOAuthUser(profile, {
198
198
  defaultRoles: [...defaultRoles],
@@ -210,7 +210,8 @@ var OAuth2Service = class extends Service {
210
210
  return pending;
211
211
  }
212
212
  async #buildProvider(name) {
213
- const cfg = this.#config.oauth2.providers[name];
213
+ const providers = this.#ensureReady().oauth2.providers;
214
+ const cfg = Object.hasOwn(providers, name) ? providers[name] : void 0;
214
215
  if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
215
216
  const factory = getOAuthProviderFactory(name);
216
217
  if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
@@ -233,6 +234,7 @@ var OAuth2Service = class extends Service {
233
234
  }
234
235
  #ensureReady() {
235
236
  if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
237
+ return this.#config;
236
238
  }
237
239
  };
238
240
  //#endregion
@@ -39,7 +39,7 @@ var SecurityHeaders = class {
39
39
  if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
40
40
  this.#headers = Object.freeze(h);
41
41
  this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
42
- this.#cspRaw = o.csp ?? "";
42
+ this.#cspRaw = o.csp;
43
43
  }
44
44
  /** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
45
45
  get headers() {
@@ -1,7 +1,7 @@
1
1
  import { AuthenticationError } from "../errors/AuthenticationError.js";
2
2
  import { ThrottledError } from "../errors/ThrottledError.js";
3
3
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
4
- import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
4
+ import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
5
5
  import { recordAudit } from "../src/audit/recordAudit.js";
6
6
  import { InvalidTargetError } from "../errors/InvalidTargetError.js";
7
7
  import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
@@ -250,8 +250,8 @@ var TokenService = class extends Service {
250
250
  * @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
251
251
  * non déclarée
252
252
  */
253
- #resolveAudience(requested) {
254
- const fallback = this.#runtime.audiences[0];
253
+ #resolveAudience(runtime, requested) {
254
+ const fallback = defaultAudience(runtime);
255
255
  if (requested === void 0 || requested === null) return fallback;
256
256
  if (Array.isArray(requested)) throw new InvalidTargetError("A single `resource` is accepted — a token valid at several resources lets each of them act at the others (RFC 8707 §3).");
257
257
  if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
@@ -262,7 +262,7 @@ var TokenService = class extends Service {
262
262
  throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
263
263
  }
264
264
  if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
265
- if (!this.#runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
265
+ if (!runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
266
266
  return requested;
267
267
  }
268
268
  /**
@@ -304,12 +304,12 @@ var TokenService = class extends Service {
304
304
  return requested.filter((scope) => !refuses.includes(scope));
305
305
  }
306
306
  async issueTokens(user, requestedScopes, resource, accessTtlS) {
307
- this.#ensureReady();
307
+ const { store, runtime } = this.#ensureReady();
308
308
  const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
309
- const audience = this.#resolveAudience(resource);
309
+ const audience = this.#resolveAudience(runtime, resource);
310
310
  const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
311
311
  const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
312
- await this.#store.put(record);
312
+ await store.put(record);
313
313
  recordAudit(this.container, {
314
314
  category: "token",
315
315
  action: "token.issued",
@@ -324,7 +324,7 @@ var TokenService = class extends Service {
324
324
  access_token: access,
325
325
  refresh_token: raw,
326
326
  token_type: "Bearer",
327
- expires_in: accessTtlS ?? this.#runtime.accessTtlS,
327
+ expires_in: accessTtlS ?? runtime.accessTtlS,
328
328
  scope: scopes.join(" ")
329
329
  };
330
330
  }
@@ -345,11 +345,10 @@ var TokenService = class extends Service {
345
345
  * @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
346
346
  */
347
347
  async refresh(rawRefresh, resource) {
348
- this.#ensureReady();
348
+ const { store, runtime } = this.#ensureReady();
349
349
  if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
350
- const store = this.#store;
351
350
  const record = await store.findByHash(this.#hash(rawRefresh));
352
- if (!record || record.kind !== "refresh") throw new AuthenticationError("Invalid token");
351
+ if (record?.kind !== "refresh") throw new AuthenticationError("Invalid token");
353
352
  if (record.revokedAt !== null) {
354
353
  if (record.family) await store.revokeFamily(record.family, "reuse_detected");
355
354
  recordAudit(this.container, {
@@ -369,14 +368,14 @@ var TokenService = class extends Service {
369
368
  if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
370
369
  const user = await this.#resolveUserForRefresh(record.subjectId);
371
370
  const scopes = [...record.scopes];
372
- const audience = record.audience?.[0] ?? this.#runtime.audiences[0];
371
+ const audience = record.audience?.at(0) ?? defaultAudience(runtime);
373
372
  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);
374
373
  const access = await this.#signAccess(user.identifier, scopes, audience);
375
- if (!this.#runtime.rotateRefresh) return {
374
+ if (!runtime.rotateRefresh) return {
376
375
  access_token: access,
377
376
  refresh_token: rawRefresh,
378
377
  token_type: "Bearer",
379
- expires_in: this.#runtime.accessTtlS,
378
+ expires_in: runtime.accessTtlS,
380
379
  scope: scopes.join(" ")
381
380
  };
382
381
  const family = record.family ?? this.#randomId();
@@ -390,14 +389,14 @@ var TokenService = class extends Service {
390
389
  access_token: access,
391
390
  refresh_token: next.raw,
392
391
  token_type: "Bearer",
393
- expires_in: this.#runtime.accessTtlS,
392
+ expires_in: runtime.accessTtlS,
394
393
  scope: scopes.join(" ")
395
394
  };
396
395
  }
397
396
  async #signAccess(subject, scopes, audience, ttlS) {
397
+ const { keystore, runtime: rt } = this.#ensureReady();
398
398
  const jose = await this.#ensureJose();
399
- const { key, kid } = await this.#keystore.getSigningKey();
400
- const rt = this.#runtime;
399
+ const { key, kid } = await keystore.getSigningKey();
401
400
  return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
402
401
  alg: "EdDSA",
403
402
  kid,
@@ -434,7 +433,7 @@ var TokenService = class extends Service {
434
433
  family,
435
434
  replacedBy: null,
436
435
  createdAt: now,
437
- expiresAt: now + this.#runtime.refreshTtlS * 1e3,
436
+ expiresAt: now + this.#ensureReady().runtime.refreshTtlS * 1e3,
438
437
  lastUsedAt: null,
439
438
  lastUsedIp: null,
440
439
  lastUsedUserAgent: null,
@@ -445,8 +444,18 @@ var TokenService = class extends Service {
445
444
  raw
446
445
  };
447
446
  }
447
+ /**
448
+ * Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
449
+ * store, le keystore et le runtime typés non nuls. Aucun des trois n'est
450
+ * réassigné après le boot — les capturer équivaut à les relire plus tard.
451
+ */
448
452
  #ensureReady() {
449
453
  if (!this.#store || !this.#keystore || !this.#runtime) throw new Error("TokenService: non initialisé (JWT désactivé ou store indisponible)");
454
+ return {
455
+ store: this.#store,
456
+ keystore: this.#keystore,
457
+ runtime: this.#runtime
458
+ };
450
459
  }
451
460
  async #ensureJose() {
452
461
  return this.#jose ??= await import("jose");
@@ -135,10 +135,10 @@ var WebAuthnService = class extends Service {
135
135
  * pour empêcher un double enregistrement sur le même authenticator (§7.1).
136
136
  */
137
137
  async generateRegistrationOptions(user) {
138
- this.#ensureReady();
138
+ const { store, config } = this.#ensureReady();
139
139
  const lib = await this.#ensureLib();
140
- const pk = this.#config.passkeys;
141
- const existing = await this.#store.findByUser(user.id);
140
+ const pk = config.passkeys;
141
+ const existing = await store.findByUser(user.id);
142
142
  return lib.generateRegistrationOptions({
143
143
  rpName: this.#rpName,
144
144
  rpID: this.#rpID,
@@ -169,23 +169,23 @@ var WebAuthnService = class extends Service {
169
169
  * @throws WebAuthnError (409) — plafond `passkeys.maxPerUser` atteint.
170
170
  */
171
171
  async verifyRegistration(response, expectedChallenge, userId, requestOrigin) {
172
- this.#ensureReady();
172
+ const { store, config } = this.#ensureReady();
173
173
  const lib = await this.#ensureLib();
174
- const pk = this.#config.passkeys;
174
+ const pk = config.passkeys;
175
175
  let verification;
176
176
  try {
177
177
  verification = await lib.verifyRegistrationResponse({
178
178
  response,
179
179
  expectedChallenge,
180
- expectedOrigin: this.#expectedOrigin(requestOrigin),
180
+ expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
181
181
  expectedRPID: this.#rpID,
182
182
  requireUserVerification: pk.userVerification === "required"
183
183
  });
184
184
  } catch {
185
185
  throw new AuthenticationError("WebAuthn registration failed");
186
186
  }
187
- if (!verification.verified || !verification.registrationInfo) throw new AuthenticationError("WebAuthn registration failed");
188
- if (await this.#store.countByUser(userId) >= pk.maxPerUser) throw new WebAuthnError(`passkey limit reached (${pk.maxPerUser})`, 409);
187
+ if (!verification.verified) throw new AuthenticationError("WebAuthn registration failed");
188
+ if (await store.countByUser(userId) >= pk.maxPerUser) throw new WebAuthnError(`passkey limit reached (${pk.maxPerUser})`, 409);
189
189
  const info = verification.registrationInfo;
190
190
  const credential = {
191
191
  id: info.credential.id,
@@ -199,7 +199,7 @@ var WebAuthnService = class extends Service {
199
199
  createdAt: Date.now(),
200
200
  lastUsedAt: null
201
201
  };
202
- await this.#store.save(credential);
202
+ await store.save(credential);
203
203
  return credential;
204
204
  }
205
205
  /**
@@ -216,17 +216,17 @@ var WebAuthnService = class extends Service {
216
216
  * `WebAuthnController.loginOptions()` ne cible que depuis la session.
217
217
  */
218
218
  async generateAuthenticationOptions(userId) {
219
- this.#ensureReady();
219
+ const { store, config } = this.#ensureReady();
220
220
  const lib = await this.#ensureLib();
221
- const pk = this.#config.passkeys;
221
+ const pk = config.passkeys;
222
222
  let allowCredentials;
223
- if (userId) allowCredentials = (await this.#store.findByUser(userId)).map((c) => ({
223
+ if (userId) allowCredentials = (await store.findByUser(userId)).map((c) => ({
224
224
  id: c.id,
225
225
  transports: [...c.transports]
226
226
  }));
227
227
  return lib.generateAuthenticationOptions({
228
228
  rpID: this.#rpID,
229
- allowCredentials,
229
+ ...allowCredentials !== void 0 && { allowCredentials },
230
230
  userVerification: pk.userVerification,
231
231
  timeout: pk.timeoutMs
232
232
  });
@@ -240,17 +240,17 @@ var WebAuthnService = class extends Service {
240
240
  * @throws AuthenticationError (401) — credential inconnu ou vérification échouée.
241
241
  */
242
242
  async verifyAuthentication(response, expectedChallenge, requestOrigin) {
243
- this.#ensureReady();
243
+ const { store, config } = this.#ensureReady();
244
244
  const lib = await this.#ensureLib();
245
- const pk = this.#config.passkeys;
246
- const stored = await this.#store.findById(response.id);
245
+ const pk = config.passkeys;
246
+ const stored = await store.findById(response.id);
247
247
  if (!stored) throw new AuthenticationError("WebAuthn authentication failed");
248
248
  let verification;
249
249
  try {
250
250
  verification = await lib.verifyAuthenticationResponse({
251
251
  response,
252
252
  expectedChallenge,
253
- expectedOrigin: this.#expectedOrigin(requestOrigin),
253
+ expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
254
254
  expectedRPID: this.#rpID,
255
255
  credential: {
256
256
  id: stored.id,
@@ -265,7 +265,7 @@ var WebAuthnService = class extends Service {
265
265
  }
266
266
  if (!verification.verified) throw new AuthenticationError("WebAuthn authentication failed");
267
267
  const info = verification.authenticationInfo;
268
- await this.#store.update(stored.id, {
268
+ await store.update(stored.id, {
269
269
  signCount: info.newCounter,
270
270
  backupState: info.credentialBackedUp,
271
271
  uvInitialized: stored.uvInitialized || info.userVerified,
@@ -278,8 +278,7 @@ var WebAuthnService = class extends Service {
278
278
  }
279
279
  /** Liste les credentials d'un utilisateur (UX « mes appareils »). */
280
280
  listUserCredentials(userId) {
281
- this.#ensureReady();
282
- return this.#store.findByUser(userId);
281
+ return this.#ensureReady().store.findByUser(userId);
283
282
  }
284
283
  /**
285
284
  * Page de passkeys pour le data plane admin (vue TRANSVERSE : « quels appareils
@@ -290,21 +289,18 @@ var WebAuthnService = class extends Service {
290
289
  * du store exclut la clé publique.
291
290
  */
292
291
  listCredentialsPage(query) {
293
- this.#ensureReady();
294
- return this.#store.listPage(query);
292
+ return this.#ensureReady().store.listPage(query);
295
293
  }
296
294
  /**
297
295
  * Nombre de passkeys correspondant aux filtres, ou `-1` si le backend ne sait
298
296
  * pas compter à coût raisonnable (Redis).
299
297
  */
300
298
  countCredentials(query) {
301
- this.#ensureReady();
302
- return this.#store.countCredentials(query);
299
+ return this.#ensureReady().store.countCredentials(query);
303
300
  }
304
301
  /** Révoque un credential (retrait d'un appareil). */
305
302
  removeCredential(credentialId) {
306
- this.#ensureReady();
307
- return this.#store.delete(credentialId);
303
+ return this.#ensureReady().store.delete(credentialId);
308
304
  }
309
305
  /**
310
306
  * Supprime un credential **du propriétaire** (self-service, anti-IDOR) : la
@@ -313,10 +309,10 @@ var WebAuthnService = class extends Service {
313
309
  * credential d'autrui).
314
310
  */
315
311
  async removeUserCredential(userId, credentialId) {
316
- this.#ensureReady();
317
- const cred = await this.#store.findById(credentialId);
312
+ const { store } = this.#ensureReady();
313
+ const cred = await store.findById(credentialId);
318
314
  if (!cred || cred.userId !== userId) return false;
319
- await this.#store.delete(credentialId);
315
+ await store.delete(credentialId);
320
316
  return true;
321
317
  }
322
318
  /**
@@ -324,9 +320,11 @@ var WebAuthnService = class extends Service {
324
320
  * priorité (prod). À défaut : l'origine de la requête est acceptée **seulement
325
321
  * si son hostname == rpID** (dev : `localhost:port` quel que soit le port, sans
326
322
  * jamais ouvrir à un domaine tiers). Dernier recours : `https://{rpID}`.
323
+ *
324
+ * @param origins - liste blanche `passkeys.origins` de la config prête.
325
+ * @param requestOrigin - origine HTTP de la requête, si connue.
327
326
  */
328
- #expectedOrigin(requestOrigin) {
329
- const origins = this.#config.passkeys.origins;
327
+ #expectedOrigin(origins, requestOrigin) {
330
328
  if (origins.length > 0) return [...origins];
331
329
  if (requestOrigin) try {
332
330
  if (new URL(requestOrigin).hostname === this.#rpID) return requestOrigin;
@@ -336,8 +334,17 @@ var WebAuthnService = class extends Service {
336
334
  async #ensureLib() {
337
335
  return this.#lib ??= await import("@simplewebauthn/server");
338
336
  }
337
+ /**
338
+ * Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
339
+ * store et la config typés non nuls (ni l'un ni l'autre ne sont réassignés
340
+ * après le boot — les capturer ici équivaut à les relire plus tard).
341
+ */
339
342
  #ensureReady() {
340
343
  if (!this.#ready || !this.#store || !this.#config) throw new Error("WebAuthnService: non initialisé (passkeys désactivés ou boot échoué)");
344
+ return {
345
+ store: this.#store,
346
+ config: this.#config
347
+ };
341
348
  }
342
349
  };
343
350
  //#endregion