@nodefony/security 10.0.0-alpha.3 → 10.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.149.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.148.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.149.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
@@ -76,8 +76,8 @@ import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
76
76
  import SecurityToken from "./nodefony/command/security-token.js";
77
77
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
78
78
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
79
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
80
- import __decorate from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
79
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
80
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
81
81
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
82
82
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
83
83
  import "./nodefony/errors/index.js";
@@ -1,5 +1,5 @@
1
1
  import { readIfPresentSync } from "../src/token/secretFile.js";
2
- import { Command } from "nodefony";
2
+ import { Command, diskManifestReader, manifestFileWith, readManifestCode } from "nodefony";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { appendFileSync } from "node:fs";
@@ -114,7 +114,10 @@ var SecuritySecrets = class extends Command {
114
114
  const dotenvLocal = this.#read(".env.local");
115
115
  const dotenv = this.#read(".env") + "\n" + dotenvLocal;
116
116
  const envTs = this.#read("env.ts");
117
- const cfgTs = this.#read("nodefony.config.ts");
117
+ const cfgTs = readManifestCode(this.#root(), diskManifestReader);
118
+ const cfgFile = manifestFileWith(this.#root(), diskManifestReader, /satisfies\s+ISecurityConfigInput/);
119
+ const cfgRel = path.relative(this.#root(), cfgFile).split(path.sep).join("/");
120
+ const cfgIsFragment = cfgRel !== "nodefony.config.ts";
118
121
  const missingInDotenv = KEYS.filter((k) => !new RegExp(`^\\s*${k}\\s*=`, "m").test(dotenv));
119
122
  const missingInEnvTs = KEYS.filter((k) => !envTs.includes(k));
120
123
  const WIRING = {
@@ -143,8 +146,9 @@ var SecuritySecrets = class extends Command {
143
146
  w(`${BOLD}2. Fichier ${CYAN}env.ts${RESET}${BOLD} — la déclaration typée${RESET} ${DIM}(env.ts est le seul lecteur de process.env)${RESET}\n`);
144
147
  if (missingInEnvTs.length === 0) w(` ${GREEN}✓ déjà déclarées${RESET}\n\n`);
145
148
  else w(` ajoute dans le defineEnv({ … }) :\n\n` + missingInEnvTs.map((k) => ` ${k}: envString({ optional: true }),`).join("\n") + `\n\n`);
146
- w(`${BOLD}3. Fichier ${CYAN}nodefony.config.ts${RESET}${BOLD} — le câblage vers le module security${RESET}\n`);
149
+ w(`${BOLD}3. Fichier ${CYAN}${cfgRel}${RESET}${BOLD} — le câblage vers le module security${RESET}\n`);
147
150
  if (missingInCfg.length === 0) w(` ${GREEN}✓ déjà câblées${RESET}\n\n`);
151
+ else if (cfgIsFragment) w(` complète le descripteur ${CYAN}securityConfig${RESET} :\n\n` + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n\n`);
148
152
  else w(" complète l'entrée security du manifeste modules :\n\n use(\"@nodefony/security\", {\n" + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n }),\n\n`);
149
153
  const jwtCable = /keystore\s*:/u.test(cfgTs);
150
154
  w(`${BOLD}4. Fichier ${CYAN}nodefony.config.ts${RESET}${BOLD} — les clés de SIGNATURE des jetons${RESET} ${DIM}(jwt.keystore)${RESET}\n`);
@@ -306,10 +306,10 @@ var SecurityToken = class extends Command {
306
306
  let targets = requested ?? [];
307
307
  if (requested === void 0) {
308
308
  const presents = this.#agentsPresents();
309
- const porteurs = presents.filter((c) => alreadyHasKey(c.forme, this.#contentOf(c), MCP_TOKEN_ENV));
310
- const added = presents.filter((c) => !porteurs.includes(c));
311
- targets = porteurs;
312
- if (porteurs.length === 0 && added.length > 0 && process.stdin.isTTY) {
309
+ const carriers = presents.filter((c) => alreadyHasKey(c.forme, this.#contentOf(c), MCP_TOKEN_ENV));
310
+ const added = presents.filter((c) => !carriers.includes(c));
311
+ targets = carriers;
312
+ if (carriers.length === 0 && added.length > 0 && process.stdin.isTTY) {
313
313
  const { checkbox } = await chargePrompts();
314
314
  const chosen = await checkbox({
315
315
  message: "Poser le jeton chez quels agents ?",
@@ -320,7 +320,7 @@ var SecurityToken = class extends Command {
320
320
  }))
321
321
  });
322
322
  targets = added.filter((c) => chosen.includes(c.key));
323
- } else if (porteurs.length === 0) targets = added;
323
+ } else if (carriers.length === 0) targets = added;
324
324
  else if (added.length > 0) w(`${DIM} ${added.map((c) => c.name).join(", ")} ${added.length > 1 ? "sont présents" : "est présent"} mais ne porte${added.length > 1 ? "nt" : ""} pas encore le jeton — ajoute --agent ${added.map((c) => c.key).join(",")}.${RESET}\n\n`);
325
325
  }
326
326
  if (this.#writeForAgents(token, w, targets) === 0) w(`${YELLOW}⚠ aucun agent reconnu dans ce projet — rien n'est écrit.${RESET}\n${DIM} Les agents connus rangent leur configuration ici :${RESET}\n` + AGENT_TARGETS.map((c) => `${DIM} ${c.name} : ${c.scope === "projet" ? c.file : `$${c.home ?? "HOME"}/${c.file}`}${RESET}\n`).join("") + `\n Le geste qui vaut pour TOUS — dans le shell d'où tu lances l'agent :\n\n ${BOLD}export ${MCP_TOKEN_ENV}=${token}${RESET}\n\n`);
@@ -55,9 +55,9 @@ var SecurityUserAdd = class extends Command {
55
55
  #knownRoles() {
56
56
  const hierarchy = ((this.kernel?.modules)?.security?.options)?.roleHierarchy;
57
57
  const all = /* @__PURE__ */ new Set([ROLE_BASE]);
58
- for (const [porteur, couverts] of Object.entries(hierarchy ?? {})) {
59
- all.add(porteur);
60
- for (const c of couverts) all.add(c);
58
+ for (const [role, covered] of Object.entries(hierarchy ?? {})) {
59
+ all.add(role);
60
+ for (const c of covered) all.add(c);
61
61
  }
62
62
  return [ROLE_BASE, ...[...all].filter((r) => r !== ROLE_BASE).sort()];
63
63
  }
@@ -309,7 +309,9 @@ const oauthProviderSchema = z.strictObject({
309
309
  scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
310
310
  successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
311
311
  failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
312
- defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur.")
312
+ defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur."),
313
+ label: z.string().min(1).optional().describe("Libellé du bouton sur l'écran de connexion. OMIS = dérivé du nom du fournisseur (`keycloak` → « Keycloak », `oidc` → « OIDC », `mon-idp` → « Mon Idp »). À poser quand la marque ne se devine pas du nom de la clé (« Connexion agent », « Annuaire interne »)."),
314
+ hidden: z.boolean().default(false).describe("Retire le bouton de l'écran de connexion SANS désactiver le fournisseur : le flux `/authorize` reste ouvert et fonctionnel. C'est la seule différence avec le fait de ne pas le configurer. Deux usages : une FIXTURE de développement qui pointe vers un serveur fictif (bouton mort), et un fournisseur réservé à un point d'entrée particulier (lien direct, sous-domaine) plutôt qu'offert à tout visiteur.")
313
315
  }).describe("Fournisseur OAuth/OIDC (secrets via env).");
314
316
  const oauth2Schema = z.strictObject({
315
317
  enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
3
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
4
4
  import { randomBytes } from "node:crypto";
5
5
  //#region nodefony/service/auditService.ts
6
6
  const serviceName = "auditService";
@@ -50,7 +50,7 @@ var AuditService = class extends Service {
50
50
  let storeName = config.audit.store;
51
51
  let reason = `store explicitement configuré ("${storeName}")`;
52
52
  if (storeName === AUTO_STORE) {
53
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listAuditStores());
53
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listAuditStores(), "memory", runNeedsExternalServices(this.kernel));
54
54
  storeName = auto.store;
55
55
  reason = auto.reason;
56
56
  this.log(`audit.store "auto" → "${storeName}" (${auto.reason})`, "INFO");
@@ -62,7 +62,7 @@ var AuditService = class extends Service {
62
62
  this.log(`${msg} — audit désactivé (journal de sécurité non collecté)`, "CRITIC");
63
63
  return;
64
64
  }
65
- if (storeName === "memory" && this.kernel?.environment === "production") this.log("audit.store \"memory\" en PRODUCTION — journal de sécurité volatil et per-pod : perdu au redémarrage, invisible des autres pods, rétention de conformité impossible. Déclarer une infra durable (NF_DATABASE_URL).", "WARNING");
65
+ if (storeName === "memory" && this.kernel?.environment === "production") this.log("audit.store \"memory\" en PRODUCTION — journal de sécurité volatil et per-pod : perdu au redémarrage, invisible des autres pods, rétention de conformité impossible. " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
66
66
  this.#enabled = true;
67
67
  this.#idPrefix = randomBytes(4).toString("hex");
68
68
  this.#store = factory({
@@ -6,6 +6,58 @@ import { Service } from "nodefony";
6
6
  //#region nodefony/service/oauth2.ts
7
7
  const serviceName = "oauth2";
8
8
  /**
9
+ * Sigles qui se lisent en capitales — les capitaliser mot à mot rendrait
10
+ * « Oidc », « Sso », qu'aucun utilisateur ne reconnaît comme la technologie.
11
+ */
12
+ const ACRONYMS = /* @__PURE__ */ new Set([
13
+ "oidc",
14
+ "sso",
15
+ "saml",
16
+ "ldap",
17
+ "cas",
18
+ "adfs",
19
+ "iam"
20
+ ]);
21
+ /**
22
+ * Marques dont la casse INTERNE ne se devine pas d'un nom en minuscules.
23
+ *
24
+ * Capitaliser la première lettre rendrait « Github », que la marque n'écrit
25
+ * jamais ainsi — et c'est précisément le nom que l'utilisateur cherche des yeux
26
+ * sur un bouton. Vu à l'écran, pas déduit : la première version de cette
27
+ * fonction affichait « Github » là où la console montrait « GitHub » avant.
28
+ */
29
+ const CANONICAL_LABELS = {
30
+ github: "GitHub",
31
+ gitlab: "GitLab",
32
+ google: "Google",
33
+ keycloak: "Keycloak",
34
+ microsoft: "Microsoft",
35
+ auth0: "Auth0",
36
+ okta: "Okta",
37
+ linkedin: "LinkedIn",
38
+ paypal: "PayPal",
39
+ youtube: "YouTube"
40
+ };
41
+ /**
42
+ * Libellé affichable d'un fournisseur, quand sa configuration n'en donne pas.
43
+ *
44
+ * Un écran de connexion ne doit JAMAIS montrer un identifiant technique brut :
45
+ * `mon-idp-interne` sur un bouton ne dit rien à qui doit cliquer. À défaut de
46
+ * marque connue, le nom de la clé de configuration est ce qui s'en rapproche le
47
+ * plus — mais rendu lisible : séparateurs en espaces, initiales en capitales,
48
+ * sigles préservés.
49
+ *
50
+ * Fonction PURE, donc éprouvable sans boot ni réseau.
51
+ *
52
+ * @param name - nom du fournisseur, tel qu'il est écrit dans la configuration
53
+ * @returns le libellé à afficher sur le bouton
54
+ */
55
+ function oauthDisplayLabel(name) {
56
+ const canonical = CANONICAL_LABELS[name.toLowerCase()];
57
+ if (canonical !== void 0) return canonical;
58
+ return name.split(/[-_.\s]+/).filter((word) => word.length > 0).map((word) => ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
59
+ }
60
+ /**
9
61
  * **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
10
62
  *
11
63
  * Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
@@ -57,13 +109,38 @@ var OAuth2Service = class extends Service {
57
109
  isEnabled() {
58
110
  return this.#ready;
59
111
  }
60
- /** Noms des fournisseurs configurés ET connus du registre (UI : boutons à afficher). */
112
+ /**
113
+ * Noms des fournisseurs OPÉRATIONNELS — configurés ET connus du registre.
114
+ *
115
+ * 🔴 C'est la **garde d'autorisation** : `/authorize` refuse en 404 tout nom
116
+ * absent de cette liste. Elle répond donc à « ce flux peut-il s'ouvrir ? »,
117
+ * jamais à « ce bouton doit-il s'afficher ? » — pour l'écran, voir
118
+ * {@link listDisplayProviders}. Confondre les deux ferait d'un masquage une
119
+ * désactivation, et couperait les bancs qui exercent une fixture masquée.
120
+ */
61
121
  listProviders() {
62
122
  if (!this.#ready || this.#config === null) return [];
63
123
  const known = new Set(listOAuthProviders());
64
124
  return Object.keys(this.#config.oauth2.providers).filter((n) => known.has(n));
65
125
  }
66
126
  /**
127
+ * Fournisseurs à MONTRER sur l'écran de connexion, libellés compris.
128
+ *
129
+ * Rend TOUT fournisseur opérationnel — y compris ceux dont le framework ne
130
+ * connaît pas la marque, qui sont précisément ceux qu'une application
131
+ * enregistre elle-même. Le seul retrait possible est explicite et se lit dans
132
+ * la configuration du fournisseur (`hidden: true`), à côté de la raison qui
133
+ * l'a motivé ; il ne désactive rien.
134
+ */
135
+ listDisplayProviders() {
136
+ if (this.#config === null) return [];
137
+ const configured = this.#config.oauth2.providers;
138
+ return this.listProviders().filter((name) => configured[name]?.hidden !== true).map((name) => ({
139
+ name,
140
+ label: configured[name]?.label ?? oauthDisplayLabel(name)
141
+ }));
142
+ }
143
+ /**
67
144
  * Redirections post-login (succès / échec) — lues par le controller.
68
145
  * Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
69
146
  */
@@ -159,4 +236,4 @@ var OAuth2Service = class extends Service {
159
236
  }
160
237
  };
161
238
  //#endregion
162
- export { OAuth2Service, OAuth2Service as default };
239
+ export { OAuth2Service, OAuth2Service as default, oauthDisplayLabel };
@@ -6,7 +6,7 @@ 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, readStoreLocation, refusedAdminScopes, resolveAutoStore } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, 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";
@@ -59,7 +59,7 @@ var TokenService = class extends Service {
59
59
  let storeName = config.tokenStore.store;
60
60
  let reason = `store explicitement configuré ("${storeName}")`;
61
61
  if (storeName === AUTO_STORE) {
62
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores());
62
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", runNeedsExternalServices(this.kernel));
63
63
  storeName = auto.store;
64
64
  reason = auto.reason;
65
65
  this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
@@ -71,7 +71,7 @@ var TokenService = class extends Service {
71
71
  this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
72
72
  return;
73
73
  }
74
- if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. Déclarer une infra durable (NF_DATABASE_URL) ou un store persistant.", "WARNING");
74
+ if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
75
75
  this.#store = factory({
76
76
  container: this.container,
77
77
  config
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
6
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
7
7
  //#region nodefony/service/totp.ts
8
8
  const serviceName = "totp";
9
9
  function isFlushable(s) {
@@ -90,7 +90,7 @@ var TotpService = class extends Service {
90
90
  let driver = config.totp.store;
91
91
  let reason = `store explicitement configuré ("${driver}")`;
92
92
  if (driver === AUTO_STORE) {
93
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores());
93
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", runNeedsExternalServices(this.kernel));
94
94
  driver = auto.store;
95
95
  reason = auto.reason;
96
96
  this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -102,7 +102,7 @@ var TotpService = class extends Service {
102
102
  this.log(`${msg} — 2FA indisponible`, "CRITIC");
103
103
  return null;
104
104
  }
105
- if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). Déclarer une infra durable (NF_DATABASE_URL) ou charger @nodefony/drizzle.", "WARNING");
105
+ 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");
106
106
  const store = factory({
107
107
  container: this.container,
108
108
  config
@@ -2,7 +2,7 @@ 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, readStoreLocation, resolveAutoStore } from "nodefony";
5
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
6
6
  import { Buffer } from "node:buffer";
7
7
  //#region nodefony/service/webAuthn.ts
8
8
  const serviceName = "webauthn";
@@ -74,7 +74,7 @@ var WebAuthnService = class extends Service {
74
74
  let driver = config.passkeys.store;
75
75
  reason = `store explicitement configuré ("${driver}")`;
76
76
  if (driver === AUTO_STORE) {
77
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores());
77
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", runNeedsExternalServices(this.kernel));
78
78
  driver = auto.store;
79
79
  reason = auto.reason;
80
80
  this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -86,7 +86,7 @@ var WebAuthnService = class extends Service {
86
86
  this.log(`${msg} — passkeys indisponibles`, "CRITIC");
87
87
  return;
88
88
  }
89
- if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). Déclarer une infra durable (NF_DATABASE_URL).", "WARNING");
89
+ if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
90
90
  this.#store = factory({
91
91
  container: this.container,
92
92
  config
@@ -6,7 +6,7 @@ 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, readStoreLocation, resolveAutoStore } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { Buffer } from "node:buffer";
12
12
  import { schemaMismatchOf } from "@nodefony/http";
@@ -153,7 +153,7 @@ var WebhookService = class extends Service {
153
153
  let driver = config.webhooks.store;
154
154
  let reason = `store explicitement configuré ("${driver}")`;
155
155
  if (driver === AUTO_STORE) {
156
- const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores());
156
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", runNeedsExternalServices(this.kernel));
157
157
  driver = auto.store;
158
158
  reason = auto.reason;
159
159
  this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
@@ -165,7 +165,7 @@ var WebhookService = class extends Service {
165
165
  this.log(`${msg} — webhooks indisponibles`, "CRITIC");
166
166
  return null;
167
167
  }
168
- 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. Déclarer une infra durable (NF_DATABASE_URL).", "WARNING");
168
+ 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");
169
169
  const store = factory({
170
170
  container: this.container,
171
171
  config
@@ -238,6 +238,8 @@ export declare const securityConfigSchema: z.ZodObject<{
238
238
  successRedirect: z.ZodOptional<z.ZodString>;
239
239
  failureRedirect: z.ZodOptional<z.ZodString>;
240
240
  defaultRoles: z.ZodOptional<z.ZodArray<z.ZodString>>;
241
+ label: z.ZodOptional<z.ZodString>;
242
+ hidden: z.ZodDefault<z.ZodBoolean>;
241
243
  }, z.core.$strict>>>;
242
244
  }, z.core.$strict>>;
243
245
  apiKeys: z.ZodDefault<z.ZodObject<{
@@ -1,4 +1,26 @@
1
1
  import { Service, Module } from "nodefony";
2
+ /**
3
+ * Libellé affichable d'un fournisseur, quand sa configuration n'en donne pas.
4
+ *
5
+ * Un écran de connexion ne doit JAMAIS montrer un identifiant technique brut :
6
+ * `mon-idp-interne` sur un bouton ne dit rien à qui doit cliquer. À défaut de
7
+ * marque connue, le nom de la clé de configuration est ce qui s'en rapproche le
8
+ * plus — mais rendu lisible : séparateurs en espaces, initiales en capitales,
9
+ * sigles préservés.
10
+ *
11
+ * Fonction PURE, donc éprouvable sans boot ni réseau.
12
+ *
13
+ * @param name - nom du fournisseur, tel qu'il est écrit dans la configuration
14
+ * @returns le libellé à afficher sur le bouton
15
+ */
16
+ export declare function oauthDisplayLabel(name: string): string;
17
+ /** Un fournisseur tel que l'écran de connexion doit le présenter. */
18
+ export interface IOAuthDisplayProvider {
19
+ /** Nom technique — celui que l'URL `/authorize` attend. */
20
+ readonly name: string;
21
+ /** Libellé du bouton : celui de la config, sinon dérivé du nom. */
22
+ readonly label: string;
23
+ }
2
24
  /** Données à porter en session entre `authorize` et `callback` (anti-replay). */
3
25
  export interface IOAuthAuthorization {
4
26
  /** URL d'autorisation vers laquelle rediriger l'utilisateur. */
@@ -33,8 +55,26 @@ declare class OAuth2Service extends Service {
33
55
  constructor(module: Module);
34
56
  /** `true` si le social login est opérationnel (activé + boot OK). */
35
57
  isEnabled(): boolean;
36
- /** Noms des fournisseurs configurés ET connus du registre (UI : boutons à afficher). */
58
+ /**
59
+ * Noms des fournisseurs OPÉRATIONNELS — configurés ET connus du registre.
60
+ *
61
+ * 🔴 C'est la **garde d'autorisation** : `/authorize` refuse en 404 tout nom
62
+ * absent de cette liste. Elle répond donc à « ce flux peut-il s'ouvrir ? »,
63
+ * jamais à « ce bouton doit-il s'afficher ? » — pour l'écran, voir
64
+ * {@link listDisplayProviders}. Confondre les deux ferait d'un masquage une
65
+ * désactivation, et couperait les bancs qui exercent une fixture masquée.
66
+ */
37
67
  listProviders(): string[];
68
+ /**
69
+ * Fournisseurs à MONTRER sur l'écran de connexion, libellés compris.
70
+ *
71
+ * Rend TOUT fournisseur opérationnel — y compris ceux dont le framework ne
72
+ * connaît pas la marque, qui sont précisément ceux qu'une application
73
+ * enregistre elle-même. Le seul retrait possible est explicite et se lit dans
74
+ * la configuration du fournisseur (`hidden: true`), à côté de la raison qui
75
+ * l'a motivé ; il ne désactive rien.
76
+ */
77
+ listDisplayProviders(): IOAuthDisplayProvider[];
38
78
  /**
39
79
  * Redirections post-login (succès / échec) — lues par le controller.
40
80
  * Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
package/docs/oauth2.md CHANGED
@@ -252,7 +252,7 @@ curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
252
252
 
253
253
  # 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
254
254
  curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
255
- # {"providers":["github"]}
255
+ # {"providers":[{"name":"github","label":"GitHub"}]}
256
256
  ```
257
257
 
258
258
  Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.test.ts` (6 cas).
@@ -569,9 +569,31 @@ Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en
569
569
  ## 📡 Observabilité — Studio
570
570
 
571
571
  L'écran de connexion de Studio consomme directement le data plane : il interroge
572
- `/nodefony/security/api/oauth2/providers` (`Login.tsx:341`) et n'affiche **que** les fournisseurs
573
- opérationnels zéro bouton mort. Le clic déclenche la redirection vers `authorize`
574
- (`Login.tsx:84`).
572
+ `/nodefony/security/api/oauth2/providers` et affiche **tout** ce que cette route lui rend — zéro
573
+ bouton mort, et zéro fournisseur légitime masqué. Le clic déclenche la redirection vers `authorize`.
574
+
575
+ C'est le SERVEUR qui décide de la liste et des libellés, parce qu'il est le seul à lire la
576
+ configuration. L'écran ne connaît que des icônes de marque, pour l'esthétique : un fournisseur
577
+ qu'il ne reconnaît pas reçoit une icône neutre et reste affiché. Filtrer côté écran sur une table
578
+ de marques masquerait précisément les fournisseurs qu'une application enregistre elle-même —
579
+ Keycloak, ou un OIDC d'entreprise.
580
+
581
+ **Retirer un bouton sans fermer le flux** — `hidden: true` sur un fournisseur :
582
+
583
+ ```ts
584
+ providers: {
585
+ "test-oidc": { /* … */ hidden: true }, // absent de l'écran…
586
+ }
587
+ ```
588
+
589
+ …mais `/authorize` continue de répondre `302` : **masquer n'est pas désactiver**. Les deux usages
590
+ sont une fixture de développement qui pointe vers un serveur fictif (le bouton serait mort), et un
591
+ fournisseur réservé à un point d'entrée particulier. Pour le désactiver vraiment, il faut le
592
+ retirer de la configuration.
593
+
594
+ **Le libellé** vient de `label`, sinon il est dérivé du nom de la clé (`keycloak` → « Keycloak »,
595
+ `oidc` → « OIDC », `mon-idp` → « Mon Idp ») : un écran de connexion ne montre jamais un identifiant
596
+ technique brut.
575
597
 
576
598
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
577
599
  `AuthFlow.establishSessionFor()` (`authFlow.ts:229-236`), consultable dans l'écran **Audit**. La
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-alpha.3",
3
+ "version": "10.0.0-alpha.4",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -50,12 +50,12 @@
50
50
  "zod": "^4.4.3"
51
51
  },
52
52
  "devDependencies": {
53
- "@nodefony/framework": "^10.0.0-alpha.3",
54
- "@nodefony/http": "^10.0.0-alpha.3",
55
- "@nodefony/user": "^10.0.0-alpha.3",
53
+ "@nodefony/framework": "^10.0.0-alpha.4",
54
+ "@nodefony/http": "^10.0.0-alpha.4",
55
+ "@nodefony/user": "^10.0.0-alpha.4",
56
56
  "@types/node": "26.4.1",
57
57
  "@vitest/coverage-v8": "5.0.0",
58
- "nodefony": "^10.0.0-alpha.3",
58
+ "nodefony": "^10.0.0-alpha.4",
59
59
  "rimraf": "6.1.3",
60
60
  "vitest": "5.0.0"
61
61
  },
@@ -63,10 +63,10 @@
63
63
  "readmeFilename": "README.md",
64
64
  "contributors": [],
65
65
  "peerDependencies": {
66
- "@nodefony/framework": "^10.0.0-alpha.3",
67
- "@nodefony/http": "^10.0.0-alpha.3",
68
- "@nodefony/user": "^10.0.0-alpha.3",
69
- "nodefony": "^10.0.0-alpha.3"
66
+ "@nodefony/framework": "^10.0.0-alpha.4",
67
+ "@nodefony/http": "^10.0.0-alpha.4",
68
+ "@nodefony/user": "^10.0.0-alpha.4",
69
+ "nodefony": "^10.0.0-alpha.4"
70
70
  },
71
71
  "files": [
72
72
  "dist",