@nodefony/security 10.0.0-alpha.2 → 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.
Files changed (34) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +7 -3
  4. package/dist/nodefony/command/security-secrets.js +7 -3
  5. package/dist/nodefony/command/security-token.js +7 -6
  6. package/dist/nodefony/command/security-user-add.js +5 -4
  7. package/dist/nodefony/command/security-user-delete.js +2 -1
  8. package/dist/nodefony/command/security-user-list.js +2 -1
  9. package/dist/nodefony/config/config.js +6 -3
  10. package/dist/nodefony/service/auditService.js +3 -3
  11. package/dist/nodefony/service/oauth2.js +111 -25
  12. package/dist/nodefony/service/tokenService.js +3 -3
  13. package/dist/nodefony/service/totp.js +3 -3
  14. package/dist/nodefony/service/webAuthn.js +3 -3
  15. package/dist/nodefony/service/webhooks.js +3 -3
  16. package/dist/nodefony/src/oauth/httpJson.js +64 -0
  17. package/dist/nodefony/src/oauth/metadata.js +88 -0
  18. package/dist/nodefony/src/oauth/oauth2Client.js +266 -0
  19. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +5 -16
  20. package/dist/nodefony/src/oauth/providers/github.js +34 -9
  21. package/dist/nodefony/src/oauth/providers/oidc.js +87 -13
  22. package/dist/types/index.d.ts +8 -1
  23. package/dist/types/nodefony/config/config.d.ts +6 -0
  24. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +45 -14
  25. package/dist/types/nodefony/contracts/ITokenStore.d.ts +1 -1
  26. package/dist/types/nodefony/service/oauth2.d.ts +47 -7
  27. package/dist/types/nodefony/src/oauth/httpJson.d.ts +28 -0
  28. package/dist/types/nodefony/src/oauth/metadata.d.ts +54 -0
  29. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +174 -0
  30. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +34 -17
  31. package/dist/types/nodefony/src/oauth/providers/github.d.ts +1 -1
  32. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +40 -15
  33. package/docs/oauth2.md +154 -70
  34. package/package.json +9 -10
@@ -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
@@ -44,6 +44,10 @@ import { Authorization } from "./nodefony/service/authorization.js";
44
44
  import { MemoryWebAuthnCredentialStore } from "./nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js";
45
45
  import { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore } from "./nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js";
46
46
  import { WebAuthnService } from "./nodefony/service/webAuthn.js";
47
+ import { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState } from "./nodefony/src/oauth/oauth2Client.js";
48
+ import { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
49
+ import { createDiscoveredOidcProvider, createOidcProvider } from "./nodefony/src/oauth/providers/oidc.js";
50
+ import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
47
51
  import { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider } from "./nodefony/src/oauth/oauthProviderRegistry.js";
48
52
  import { OAuth2Service } from "./nodefony/service/oauth2.js";
49
53
  import { TOKEN_FACETS } from "./nodefony/src/token/tokenFilters.js";
@@ -72,8 +76,8 @@ import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
72
76
  import SecurityToken from "./nodefony/command/security-token.js";
73
77
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
74
78
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
75
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
76
- 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";
77
81
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
78
82
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
79
83
  import "./nodefony/errors/index.js";
@@ -148,4 +152,4 @@ Security = __decorate([services([
148
152
  ]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Security);
149
153
  var security_default = Security;
150
154
  //#endregion
151
- export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InvalidTargetError, JwtAuthenticator, JwtKeystore, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Service, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, disableTotp, encryptSecret, generateApiKey, generateEphemeralKey, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, resolveJwtRuntime, securityConfigJsonSchema, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
155
+ export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InvalidTargetError, JwtAuthenticator, JwtKeystore, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, resolveJwtRuntime, securityConfigJsonSchema, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
@@ -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`);
@@ -1,10 +1,11 @@
1
1
  import { messageNonRestreint, modeNonRestreint, readIfPresentSync, writeSecretSync } from "../src/token/secretFile.js";
2
- import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
2
+ import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
3
3
  import path from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { spawnSync } from "node:child_process";
6
6
  //#region nodefony/command/security-token.ts
7
7
  const options = {
8
+ runProfile: CONSOLE_DATA_RUN_PROFILE,
8
9
  helpGroup: "COMPTES ET SECRETS",
9
10
  showBanner: false,
10
11
  kernelEvent: "onReady",
@@ -305,10 +306,10 @@ var SecurityToken = class extends Command {
305
306
  let targets = requested ?? [];
306
307
  if (requested === void 0) {
307
308
  const presents = this.#agentsPresents();
308
- const porteurs = presents.filter((c) => alreadyHasKey(c.forme, this.#contentOf(c), MCP_TOKEN_ENV));
309
- const added = presents.filter((c) => !porteurs.includes(c));
310
- targets = porteurs;
311
- 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) {
312
313
  const { checkbox } = await chargePrompts();
313
314
  const chosen = await checkbox({
314
315
  message: "Poser le jeton chez quels agents ?",
@@ -319,7 +320,7 @@ var SecurityToken = class extends Command {
319
320
  }))
320
321
  });
321
322
  targets = added.filter((c) => chosen.includes(c.key));
322
- } else if (porteurs.length === 0) targets = added;
323
+ } else if (carriers.length === 0) targets = added;
323
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`);
324
325
  }
325
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`);
@@ -1,6 +1,7 @@
1
- import { Command, askPasswordMasked } from "nodefony";
1
+ import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
2
  //#region nodefony/command/security-user-add.ts
3
3
  const options = {
4
+ runProfile: CONSOLE_DATA_RUN_PROFILE,
4
5
  helpGroup: "COMPTES ET SECRETS",
5
6
  showBanner: false,
6
7
  kernelEvent: "onPostReady"
@@ -54,9 +55,9 @@ var SecurityUserAdd = class extends Command {
54
55
  #knownRoles() {
55
56
  const hierarchy = ((this.kernel?.modules)?.security?.options)?.roleHierarchy;
56
57
  const all = /* @__PURE__ */ new Set([ROLE_BASE]);
57
- for (const [porteur, couverts] of Object.entries(hierarchy ?? {})) {
58
- all.add(porteur);
59
- 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);
60
61
  }
61
62
  return [ROLE_BASE, ...[...all].filter((r) => r !== ROLE_BASE).sort()];
62
63
  }
@@ -1,6 +1,7 @@
1
- import { Command } from "nodefony";
1
+ import { CONSOLE_DATA_RUN_PROFILE, Command } from "nodefony";
2
2
  //#region nodefony/command/security-user-delete.ts
3
3
  const options = {
4
+ runProfile: CONSOLE_DATA_RUN_PROFILE,
4
5
  helpGroup: "COMPTES ET SECRETS",
5
6
  showBanner: false,
6
7
  kernelEvent: "onPostReady",
@@ -1,6 +1,7 @@
1
- import { Command } from "nodefony";
1
+ import { CONSOLE_DATA_RUN_PROFILE, Command } from "nodefony";
2
2
  //#region nodefony/command/security-user-list.ts
3
3
  const options = {
4
+ runProfile: CONSOLE_DATA_RUN_PROFILE,
4
5
  helpGroup: "COMPTES ET SECRETS",
5
6
  showBanner: false,
6
7
  kernelEvent: "onReady",
@@ -303,12 +303,15 @@ const studioSchema = z.strictObject({
303
303
  const oauthProviderSchema = z.strictObject({
304
304
  clientId: z.string().min(1).describe("Identifiant client OAuth délivré par le fournisseur."),
305
305
  clientSecret: z.string().min(1).describe("Secret client OAuth — SECRET, fourni par env, jamais loggé."),
306
+ clientAuthMethod: z.enum(["client_secret_basic", "client_secret_post"]).optional().describe("Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). OMIS = `client_secret_basic`, ce que la RFC demande de préférer et ce que la plupart des serveurs annoncent par défaut. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans ses métadonnées (`token_endpoint_auth_methods_supported`). La méthode `none` (client public) n'est pas offerte ici : ce schéma exige un secret non vide."),
306
307
  redirectUri: z.string().min(1).describe("URL de callback EXACTE (RFC 9700 : exact string matching) enregistrée chez le fournisseur — doit pointer sur .../oauth2/<provider>/callback."),
307
308
  issuer: z.string().optional().describe("Émetteur OIDC self-hosted (Keycloak : URL du realm, ex. https://kc.example/realms/app). REQUIS pour keycloak ; ignoré par les fournisseurs à endpoints fixes (Google/GitHub)."),
308
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)."),
309
310
  successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
310
311
  failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
311
- 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.")
312
315
  }).describe("Fournisseur OAuth/OIDC (secrets via env).");
313
316
  const oauth2Schema = z.strictObject({
314
317
  enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
@@ -316,8 +319,8 @@ const oauth2Schema = z.strictObject({
316
319
  allowSignup: z.boolean().default(true).describe("true = crée une ligne locale au 1er login (JIT, Shadow User). false = un compte préexistant lié est requis (fail-closed)."),
317
320
  successRedirect: z.string().default("/").describe("Redirection après login réussi."),
318
321
  failureRedirect: z.string().default("/login").describe("Redirection après échec (state invalide, refus utilisateur...)."),
319
- providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/github ; +50 via arctic en enregistrant une fabrique).")
320
- }).describe("Social login OAuth 2.0 (arctic) — Authorization Code + PKCE, session BFF.");
322
+ providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/oidc/github ; tout autre serveur OpenID Connect en enregistrant une fabrique, décrit par son seul émetteur).")
323
+ }).describe("Social login OAuth 2.0 — Authorization Code + PKCE, session BFF.");
321
324
  /**
322
325
  * Règle d'autorisation d'un **namespace de canaux WebSocket** (subscribe/inbound)
323
326
  * par préfixe. Contraintes cumulatives ; un champ absent = pas de contrainte sur
@@ -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({
@@ -1,12 +1,64 @@
1
1
  import { AuthenticationError } from "../errors/AuthenticationError.js";
2
2
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
3
+ import { generateCodeVerifier, generateState } from "../src/oauth/oauth2Client.js";
3
4
  import { getOAuthProviderFactory, listOAuthProviders } from "../src/oauth/oauthProviderRegistry.js";
4
5
  import { Service } from "nodefony";
5
6
  //#region nodefony/service/oauth2.ts
6
7
  const serviceName = "oauth2";
7
8
  /**
8
- * **Social login OAuth 2.0** (P6 J9)orchestrateur du flux *Authorization Code*
9
- * au-dessus d'`arctic`.
9
+ * Sigles qui se lisent en capitalesles 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
+ /**
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 /
12
64
  * ROPC), **PKCE S256** quand le fournisseur le supporte (RFC 7636), **state**
@@ -14,10 +66,11 @@ const serviceName = "oauth2";
14
66
  * (le login produit une **session BFF**, gérée hors de ce service par le
15
67
  * controller + `AuthFlow`).
16
68
  *
17
- * `arctic` est **importé paresseusement** au premier login (cold path — jamais au
18
- * boot ni par requête), comme `@simplewebauthn`/`jose`. Au boot (si
19
- * `oauth2.enabled`) : seule la config est validée et les fournisseurs configurés
20
- * sont confrontés au registre (un nom inconnu = WARNING, pas fatal).
69
+ * Les fournisseurs sont construits **au premier login** (cold path — jamais au boot
70
+ * ni par requête) puis mémoïsés : c'est là que les points d'entrée d'un émetteur
71
+ * OIDC sont découverts, une seule fois par processus. Au boot (si `oauth2.enabled`)
72
+ * : seule la config est validée et les fournisseurs configurés sont confrontés au
73
+ * registre (un nom inconnu = WARNING, pas fatal).
21
74
  *
22
75
  * Le service ne touche **ni HTTP ni session** : il rend à l'appelant les éléments
23
76
  * (URL, state, verifier) que le controller persiste en session — testable sans
@@ -26,7 +79,6 @@ const serviceName = "oauth2";
26
79
  var OAuth2Service = class extends Service {
27
80
  module;
28
81
  #config = null;
29
- #lib = null;
30
82
  #providers = null;
31
83
  #ready = false;
32
84
  constructor(module) {
@@ -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
  */
@@ -83,11 +160,14 @@ var OAuth2Service = class extends Service {
83
160
  */
84
161
  async createAuthorization(provider) {
85
162
  const resolved = await this.#resolveProvider(provider);
86
- const lib = await this.#ensureLib();
87
- const state = lib.generateState();
88
- const codeVerifier = resolved.provider.usesPkce ? lib.generateCodeVerifier() : null;
163
+ const state = generateState();
164
+ const codeVerifier = resolved.provider.usesPkce ? generateCodeVerifier() : null;
89
165
  return {
90
- url: resolved.provider.createAuthorizationURL(state, codeVerifier, resolved.scopes).toString(),
166
+ url: resolved.provider.createAuthorizationURL({
167
+ state,
168
+ codeVerifier,
169
+ scopes: resolved.scopes
170
+ }).toString(),
91
171
  state,
92
172
  codeVerifier
93
173
  };
@@ -102,10 +182,15 @@ var OAuth2Service = class extends Service {
102
182
  */
103
183
  async exchangeAndProvision(provider, code, codeVerifier, returnedIss) {
104
184
  const { provider: p } = await this.#resolveProvider(provider);
105
- if (p.expectedIssuer !== null) {
106
- if (returnedIss === null || returnedIss !== p.expectedIssuer) throw new AuthenticationError("OAuth issuer mismatch");
185
+ const policy = p.issuerPolicy;
186
+ if (policy !== null) {
187
+ if (returnedIss !== null && returnedIss !== policy.issuer) throw new AuthenticationError("OAuth issuer mismatch");
188
+ if (returnedIss === null && policy.requireIssParameter) throw new AuthenticationError("OAuth issuer missing");
107
189
  }
108
- const tokens = await p.validateAuthorizationCode(code, codeVerifier);
190
+ const tokens = await p.validateAuthorizationCode({
191
+ code,
192
+ codeVerifier
193
+ });
109
194
  const profile = await p.fetchProfile(tokens);
110
195
  const cfg = this.#config.oauth2;
111
196
  const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
@@ -114,40 +199,41 @@ var OAuth2Service = class extends Service {
114
199
  allowSignup: cfg.allowSignup
115
200
  })).identifier };
116
201
  }
117
- async #resolveProvider(name) {
202
+ #resolveProvider(name) {
118
203
  this.#ensureReady();
119
204
  this.#providers ??= /* @__PURE__ */ new Map();
120
205
  const cached = this.#providers.get(name);
121
206
  if (cached) return cached;
207
+ const pending = this.#buildProvider(name);
208
+ this.#providers.set(name, pending);
209
+ pending.catch(() => this.#providers?.delete(name));
210
+ return pending;
211
+ }
212
+ async #buildProvider(name) {
122
213
  const cfg = this.#config.oauth2.providers[name];
123
214
  if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
124
215
  const factory = getOAuthProviderFactory(name);
125
216
  if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
126
- const provider = factory({
127
- arctic: await this.#ensureLib(),
217
+ const provider = await factory({
128
218
  clientId: cfg.clientId,
129
219
  clientSecret: cfg.clientSecret,
220
+ clientAuthMethod: cfg.clientAuthMethod,
130
221
  redirectUri: cfg.redirectUri,
131
222
  issuer: cfg.issuer
132
223
  });
133
- const resolved = {
224
+ return {
134
225
  provider,
135
226
  scopes: cfg.scopes.length > 0 ? cfg.scopes : provider.defaultScopes
136
227
  };
137
- this.#providers.set(name, resolved);
138
- return resolved;
139
228
  }
140
229
  #resolveProvisioner() {
141
230
  const users = this.get("users");
142
231
  if (!users || typeof users.provisionOAuthUser !== "function") throw new AuthenticationError("OAuth provisioning indisponible (le service users n'implémente pas IOAuthUserProvisioner)");
143
232
  return users;
144
233
  }
145
- async #ensureLib() {
146
- return this.#lib ??= await import("arctic");
147
- }
148
234
  #ensureReady() {
149
235
  if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
150
236
  }
151
237
  };
152
238
  //#endregion
153
- 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
@@ -0,0 +1,64 @@
1
+ //#region nodefony/src/oauth/httpJson.ts
2
+ /**
3
+ * Lecture BORNÉE d'un corps JSON — la brique de transport commune aux trois
4
+ * appels sortants du social login (découverte, point de jeton, API d'un
5
+ * fournisseur non-OIDC).
6
+ *
7
+ * Elle existe parce qu'une borne posée APRÈS `response.text()` ne protège plus
8
+ * rien : le corps est déjà entièrement en mémoire quand on mesure sa longueur.
9
+ */
10
+ /**
11
+ * Lit un corps JSON en refusant de dépasser une taille — la borne est vérifiée
12
+ * PENDANT la lecture, pas après : un corps déjà entièrement en mémoire ne se
13
+ * refuse plus.
14
+ *
15
+ * @param response - réponse dont le corps reste à lire.
16
+ * @param maxBytes - plafond, en octets réels du flux.
17
+ * @param subject - ce qu'on lisait, pour que l'erreur soit exploitable.
18
+ * @returns la valeur JSON telle quelle — un objet OU un tableau (l'API d'un
19
+ * fournisseur rend les deux ; c'est à l'appelant d'exiger la forme qu'il attend).
20
+ * @throws Error - corps trop gros ou illisible.
21
+ */
22
+ async function readJsonBounded(response, maxBytes, subject) {
23
+ const announced = response.headers.get("content-length");
24
+ if (announced !== null && Number(announced) > maxBytes) throw new Error(`${subject} → corps hors gabarit (${announced} octets)`);
25
+ const body = response.body;
26
+ let text;
27
+ if (body === null) {
28
+ text = await response.text();
29
+ if (text.length > maxBytes) throw new Error(`${subject} → corps hors gabarit`);
30
+ } else {
31
+ const reader = body.getReader();
32
+ const chunks = [];
33
+ let seen = 0;
34
+ for (;;) {
35
+ const { done, value } = await reader.read();
36
+ if (done) break;
37
+ seen += value.byteLength;
38
+ if (seen > maxBytes) {
39
+ await reader.cancel();
40
+ throw new Error(`${subject} → corps hors gabarit (> ${maxBytes} octets)`);
41
+ }
42
+ chunks.push(value);
43
+ }
44
+ text = Buffer.concat(chunks).toString("utf8");
45
+ }
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch {
49
+ throw new Error(`${subject} → corps illisible (JSON attendu)`);
50
+ }
51
+ }
52
+ /**
53
+ * Comme {@link readJsonBounded}, mais exige un OBJET — la forme de toute réponse
54
+ * normalisée par une RFC (document de métadonnées, réponse d'un point de jeton).
55
+ *
56
+ * @throws Error - corps trop gros, illisible, ou qui n'est pas un objet JSON.
57
+ */
58
+ async function readJsonObjectBounded(response, maxBytes, subject) {
59
+ const payload = await readJsonBounded(response, maxBytes, subject);
60
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error(`${subject} → corps inattendu (objet JSON attendu)`);
61
+ return payload;
62
+ }
63
+ //#endregion
64
+ export { readJsonBounded, readJsonObjectBounded };