@nodefony/security 10.0.0-alpha.8 → 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 (83) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
77
77
  import SecurityToken from "./nodefony/command/security-token.js";
78
78
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
79
79
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
80
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
81
- import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
80
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
81
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
82
82
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
83
83
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
84
84
  import "./nodefony/errors/index.js";
@@ -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) {
@@ -1,4 +1,4 @@
1
- import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
1
+ import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
2
  import { WeakPasswordError } from "@nodefony/user";
3
3
  //#region nodefony/command/security-user-add.ts
4
4
  const options = {
@@ -17,22 +17,6 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
17
17
  /** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
18
18
  const ROLE_BASE = "ROLE_USER";
19
19
  /**
20
- * La route qui échange un compte contre une SESSION — le geste qui manquait.
21
- *
22
- * Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
23
- * 33 minutes d'un essai réel se sont passées entre un premier refus et
24
- * l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
25
- * suivante au moment où elle a un sens.
26
- *
27
- * ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
28
- * framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
29
- * conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
30
- * exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
31
- * controller : deux copies qui divergent en silence, c'est précisément ce
32
- * qu'on refuse.
33
- */
34
- const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
35
- /**
36
20
  * `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
37
21
  * service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
38
22
  * jamais de mot de passe stocké en clair).
@@ -163,4 +147,4 @@ var SecurityUserAdd = class extends Command {
163
147
  }
164
148
  };
165
149
  //#endregion
166
- export { AUTH_LOGIN_PATH, SecurityUserAdd as default };
150
+ export { SecurityUserAdd as default };
@@ -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
@@ -1,4 +1,4 @@
1
- import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
1
+ import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
2
  import { USER_REVOKED_EVENT } from "@nodefony/user";
3
3
  //#region nodefony/command/security-user-password.ts
4
4
  const options = {
@@ -106,7 +106,7 @@ var SecurityUserPassword = class extends Command {
106
106
  tenantId: null,
107
107
  reason: "password_changed"
108
108
  });
109
- process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST /nodefony/security/api/auth/login${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
109
+ process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST ${AUTH_LOGIN_PATH}${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
110
110
  return this;
111
111
  }
112
112
  };
@@ -208,7 +208,7 @@ const totpSchema = z.strictObject({
208
208
  window: z.number().int().nonnegative().default(1).describe("Tolérance de dérive d'horloge (± N pas). RFC 6238 §5.2 : « at most one time step » → 1 [défaut]. Plus = surface d'attaque accrue."),
209
209
  recoveryCodes: z.number().int().positive().default(10).describe("Nombre de codes de récupération générés à l'activation (NIST SP 800-63B « look-up secrets », usage unique, affichés 1×)."),
210
210
  encryptionKey: z.string().optional().describe("Clé de chiffrement AES-256-GCM du secret TOTP au repos (fournie par l'app depuis env, ≥ 32 octets après décodage). Omise = clé éphémère dérivée + WARNING (dev mono-process) ; en prod multi-pod, REQUISE (un secret 2FA déchiffrable doit l'être par tous les pods, et jamais en clair)."),
211
- store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). ⚠️ Contrairement aux sessions, aux jetons et aux passkeys, **seul @nodefony/drizzle fournit un store TOTP** : @nodefony/mongoose et @nodefony/redis n'en enregistrent pas. Sur une infra Mongo, `auto` ne trouve donc pas de backend durable et se replie sur `memory` (repli tracé au boot, et WARNING en production) : les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur. Charger @nodefony/drizzle — même en sqlite local, à côté de Mongo — donne la persistance.")
211
+ store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle | mongoose — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). Les deux backends DURABLES le portent : sur une infra SQL comme sur une infra Mongo, `auto` trouve un store persistant. ⚠️ @nodefony/redis n'en enregistre pas : avec Redis pour seule infra, `auto` se replie sur `memory` (repli tracé au boot, et WARNING en production) — les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur.")
212
212
  }).describe("2FA TOTP (RFC 6238) — second facteur step-up au login. Secret chiffré au repos, codes de récupération hachés.");
213
213
  const remoteJwtAlgSchema = z.enum([
214
214
  "RS256",
@@ -276,7 +276,7 @@ const webhooksSchema = z.strictObject({
276
276
  }).describe("Webhooks sortants signés (Standard Webhooks).");
277
277
  const auditSchema = z.strictObject({
278
278
  enabled: z.boolean().default(true),
279
- store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` = persistant + partagé multi-pod (auto-register par l'adapter). Vocabulaire unifié : données = `store`."),
279
+ store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` et `mongoose` = persistants + partagés multi-pod (auto-register par l'adapter chargé). @nodefony/redis n'en fournit pas : un journal croît sans borne et se consulte, ce n'est pas le motif d'accès d'un cache. Vocabulaire unifié : données = `store`."),
280
280
  immutable: z.boolean().default(true).meta({
281
281
  reserved: true,
282
282
  description: "INERTE — l'immuabilité du journal vient du contrat `IAuditStore` (aucune méthode `update`/`delete`), pas de ce drapeau. Le passer à `false` n'ouvre aucune mutation."
@@ -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, {
@@ -1,6 +1,6 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
2
  import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
3
- import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
3
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
4
4
  import { randomBytes } from "node:crypto";
5
5
  //#region nodefony/service/auditService.ts
6
6
  const serviceName = "auditService";
@@ -78,7 +78,8 @@ var AuditService = class extends Service {
78
78
  available: listAuditStores(),
79
79
  reason,
80
80
  configPath: "security.audit.store",
81
- location: readStoreLocation(this.#store)
81
+ location: readStoreLocation(this.#store),
82
+ connector: readStoreConnector(this.#store)
82
83
  });
83
84
  this.#gc = new GcScheduler({
84
85
  intervalS: GC_INTERVAL_MS / 1e3,
@@ -107,14 +108,14 @@ var AuditService = class extends Service {
107
108
  ts: Date.now()
108
109
  };
109
110
  this.#store.append(event).catch((error) => this.log(error, "ERROR"));
110
- if (this.#listeners !== null) for (let i = 0; i < this.#listeners.length; i++) try {
111
- this.#listeners[i](event);
111
+ if (this.#listeners !== null) for (const listener of this.#listeners) try {
112
+ listener(event);
112
113
  } catch (error) {
113
114
  this.log(error, "ERROR");
114
115
  }
115
116
  }
116
117
  subscribe(listener) {
117
- if (this.#listeners === null) this.#listeners = [];
118
+ this.#listeners ??= [];
118
119
  this.#listeners.push(listener);
119
120
  let active = true;
120
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,12 +1,12 @@
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";
8
8
  import { JwtKeystore } from "../src/token/JwtKeystore.js";
9
- import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
10
10
  import { createHash, randomBytes, randomUUID } from "node:crypto";
11
11
  //#region nodefony/service/tokenService.ts
12
12
  const serviceName = "tokenService";
@@ -84,7 +84,8 @@ var TokenService = class extends Service {
84
84
  available: listTokenStores(),
85
85
  reason,
86
86
  configPath: "security.tokenStore.store",
87
- location: readStoreLocation(this.#store)
87
+ location: readStoreLocation(this.#store),
88
+ connector: readStoreConnector(this.#store)
88
89
  });
89
90
  this.container?.set("tokenStore", this.#store);
90
91
  if (jwtEnabled) {
@@ -249,8 +250,8 @@ var TokenService = class extends Service {
249
250
  * @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
250
251
  * non déclarée
251
252
  */
252
- #resolveAudience(requested) {
253
- const fallback = this.#runtime.audiences[0];
253
+ #resolveAudience(runtime, requested) {
254
+ const fallback = defaultAudience(runtime);
254
255
  if (requested === void 0 || requested === null) return fallback;
255
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).");
256
257
  if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
@@ -261,7 +262,7 @@ var TokenService = class extends Service {
261
262
  throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
262
263
  }
263
264
  if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
264
- 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);
265
266
  return requested;
266
267
  }
267
268
  /**
@@ -303,12 +304,12 @@ var TokenService = class extends Service {
303
304
  return requested.filter((scope) => !refuses.includes(scope));
304
305
  }
305
306
  async issueTokens(user, requestedScopes, resource, accessTtlS) {
306
- this.#ensureReady();
307
+ const { store, runtime } = this.#ensureReady();
307
308
  const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
308
- const audience = this.#resolveAudience(resource);
309
+ const audience = this.#resolveAudience(runtime, resource);
309
310
  const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
310
311
  const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
311
- await this.#store.put(record);
312
+ await store.put(record);
312
313
  recordAudit(this.container, {
313
314
  category: "token",
314
315
  action: "token.issued",
@@ -323,7 +324,7 @@ var TokenService = class extends Service {
323
324
  access_token: access,
324
325
  refresh_token: raw,
325
326
  token_type: "Bearer",
326
- expires_in: accessTtlS ?? this.#runtime.accessTtlS,
327
+ expires_in: accessTtlS ?? runtime.accessTtlS,
327
328
  scope: scopes.join(" ")
328
329
  };
329
330
  }
@@ -344,11 +345,10 @@ var TokenService = class extends Service {
344
345
  * @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
345
346
  */
346
347
  async refresh(rawRefresh, resource) {
347
- this.#ensureReady();
348
+ const { store, runtime } = this.#ensureReady();
348
349
  if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
349
- const store = this.#store;
350
350
  const record = await store.findByHash(this.#hash(rawRefresh));
351
- if (!record || record.kind !== "refresh") throw new AuthenticationError("Invalid token");
351
+ if (record?.kind !== "refresh") throw new AuthenticationError("Invalid token");
352
352
  if (record.revokedAt !== null) {
353
353
  if (record.family) await store.revokeFamily(record.family, "reuse_detected");
354
354
  recordAudit(this.container, {
@@ -368,14 +368,14 @@ var TokenService = class extends Service {
368
368
  if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
369
369
  const user = await this.#resolveUserForRefresh(record.subjectId);
370
370
  const scopes = [...record.scopes];
371
- const audience = record.audience?.[0] ?? this.#runtime.audiences[0];
371
+ const audience = record.audience?.at(0) ?? defaultAudience(runtime);
372
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);
373
373
  const access = await this.#signAccess(user.identifier, scopes, audience);
374
- if (!this.#runtime.rotateRefresh) return {
374
+ if (!runtime.rotateRefresh) return {
375
375
  access_token: access,
376
376
  refresh_token: rawRefresh,
377
377
  token_type: "Bearer",
378
- expires_in: this.#runtime.accessTtlS,
378
+ expires_in: runtime.accessTtlS,
379
379
  scope: scopes.join(" ")
380
380
  };
381
381
  const family = record.family ?? this.#randomId();
@@ -389,14 +389,14 @@ var TokenService = class extends Service {
389
389
  access_token: access,
390
390
  refresh_token: next.raw,
391
391
  token_type: "Bearer",
392
- expires_in: this.#runtime.accessTtlS,
392
+ expires_in: runtime.accessTtlS,
393
393
  scope: scopes.join(" ")
394
394
  };
395
395
  }
396
396
  async #signAccess(subject, scopes, audience, ttlS) {
397
+ const { keystore, runtime: rt } = this.#ensureReady();
397
398
  const jose = await this.#ensureJose();
398
- const { key, kid } = await this.#keystore.getSigningKey();
399
- const rt = this.#runtime;
399
+ const { key, kid } = await keystore.getSigningKey();
400
400
  return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
401
401
  alg: "EdDSA",
402
402
  kid,
@@ -433,7 +433,7 @@ var TokenService = class extends Service {
433
433
  family,
434
434
  replacedBy: null,
435
435
  createdAt: now,
436
- expiresAt: now + this.#runtime.refreshTtlS * 1e3,
436
+ expiresAt: now + this.#ensureReady().runtime.refreshTtlS * 1e3,
437
437
  lastUsedAt: null,
438
438
  lastUsedIp: null,
439
439
  lastUsedUserAgent: null,
@@ -444,8 +444,18 @@ var TokenService = class extends Service {
444
444
  raw
445
445
  };
446
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
+ */
447
452
  #ensureReady() {
448
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
+ };
449
459
  }
450
460
  async #ensureJose() {
451
461
  return this.#jose ??= await import("jose");
@@ -3,7 +3,7 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
3
3
  import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
4
4
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
5
5
  import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
6
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
6
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
7
7
  //#region nodefony/service/totp.ts
8
8
  const serviceName = "totp";
9
9
  function isFlushable(s) {
@@ -83,7 +83,8 @@ var TotpService = class extends Service {
83
83
  available: listTotpStores(),
84
84
  reason: "adapter posé au container (infra database déclarée)",
85
85
  configPath: "security.totp.store",
86
- location: readStoreLocation(existing)
86
+ location: readStoreLocation(existing),
87
+ connector: readStoreConnector(existing)
87
88
  });
88
89
  return existing;
89
90
  }
@@ -116,7 +117,8 @@ var TotpService = class extends Service {
116
117
  available: listTotpStores(),
117
118
  reason,
118
119
  configPath: "security.totp.store",
119
- location: readStoreLocation(store)
120
+ location: readStoreLocation(store),
121
+ connector: readStoreConnector(store)
120
122
  });
121
123
  return store;
122
124
  }