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

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.
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";
@@ -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,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",
@@ -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"
@@ -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,6 +303,7 @@ 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)."),
@@ -316,8 +317,8 @@ const oauth2Schema = z.strictObject({
316
317
  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
318
  successRedirect: z.string().default("/").describe("Redirection après login réussi."),
318
319
  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.");
320
+ 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).")
321
+ }).describe("Social login OAuth 2.0 — Authorization Code + PKCE, session BFF.");
321
322
  /**
322
323
  * Règle d'autorisation d'un **namespace de canaux WebSocket** (subscribe/inbound)
323
324
  * par préfixe. Contraintes cumulatives ; un champ absent = pas de contrainte sur
@@ -1,12 +1,12 @@
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
+ * **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
10
10
  *
11
11
  * Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
12
12
  * ROPC), **PKCE S256** quand le fournisseur le supporte (RFC 7636), **state**
@@ -14,10 +14,11 @@ const serviceName = "oauth2";
14
14
  * (le login produit une **session BFF**, gérée hors de ce service par le
15
15
  * controller + `AuthFlow`).
16
16
  *
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).
17
+ * Les fournisseurs sont construits **au premier login** (cold path — jamais au boot
18
+ * ni par requête) puis mémoïsés : c'est là que les points d'entrée d'un émetteur
19
+ * OIDC sont découverts, une seule fois par processus. Au boot (si `oauth2.enabled`)
20
+ * : seule la config est validée et les fournisseurs configurés sont confrontés au
21
+ * registre (un nom inconnu = WARNING, pas fatal).
21
22
  *
22
23
  * Le service ne touche **ni HTTP ni session** : il rend à l'appelant les éléments
23
24
  * (URL, state, verifier) que le controller persiste en session — testable sans
@@ -26,7 +27,6 @@ const serviceName = "oauth2";
26
27
  var OAuth2Service = class extends Service {
27
28
  module;
28
29
  #config = null;
29
- #lib = null;
30
30
  #providers = null;
31
31
  #ready = false;
32
32
  constructor(module) {
@@ -83,11 +83,14 @@ var OAuth2Service = class extends Service {
83
83
  */
84
84
  async createAuthorization(provider) {
85
85
  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;
86
+ const state = generateState();
87
+ const codeVerifier = resolved.provider.usesPkce ? generateCodeVerifier() : null;
89
88
  return {
90
- url: resolved.provider.createAuthorizationURL(state, codeVerifier, resolved.scopes).toString(),
89
+ url: resolved.provider.createAuthorizationURL({
90
+ state,
91
+ codeVerifier,
92
+ scopes: resolved.scopes
93
+ }).toString(),
91
94
  state,
92
95
  codeVerifier
93
96
  };
@@ -102,10 +105,15 @@ var OAuth2Service = class extends Service {
102
105
  */
103
106
  async exchangeAndProvision(provider, code, codeVerifier, returnedIss) {
104
107
  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");
108
+ const policy = p.issuerPolicy;
109
+ if (policy !== null) {
110
+ if (returnedIss !== null && returnedIss !== policy.issuer) throw new AuthenticationError("OAuth issuer mismatch");
111
+ if (returnedIss === null && policy.requireIssParameter) throw new AuthenticationError("OAuth issuer missing");
107
112
  }
108
- const tokens = await p.validateAuthorizationCode(code, codeVerifier);
113
+ const tokens = await p.validateAuthorizationCode({
114
+ code,
115
+ codeVerifier
116
+ });
109
117
  const profile = await p.fetchProfile(tokens);
110
118
  const cfg = this.#config.oauth2;
111
119
  const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
@@ -114,37 +122,38 @@ var OAuth2Service = class extends Service {
114
122
  allowSignup: cfg.allowSignup
115
123
  })).identifier };
116
124
  }
117
- async #resolveProvider(name) {
125
+ #resolveProvider(name) {
118
126
  this.#ensureReady();
119
127
  this.#providers ??= /* @__PURE__ */ new Map();
120
128
  const cached = this.#providers.get(name);
121
129
  if (cached) return cached;
130
+ const pending = this.#buildProvider(name);
131
+ this.#providers.set(name, pending);
132
+ pending.catch(() => this.#providers?.delete(name));
133
+ return pending;
134
+ }
135
+ async #buildProvider(name) {
122
136
  const cfg = this.#config.oauth2.providers[name];
123
137
  if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
124
138
  const factory = getOAuthProviderFactory(name);
125
139
  if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
126
- const provider = factory({
127
- arctic: await this.#ensureLib(),
140
+ const provider = await factory({
128
141
  clientId: cfg.clientId,
129
142
  clientSecret: cfg.clientSecret,
143
+ clientAuthMethod: cfg.clientAuthMethod,
130
144
  redirectUri: cfg.redirectUri,
131
145
  issuer: cfg.issuer
132
146
  });
133
- const resolved = {
147
+ return {
134
148
  provider,
135
149
  scopes: cfg.scopes.length > 0 ? cfg.scopes : provider.defaultScopes
136
150
  };
137
- this.#providers.set(name, resolved);
138
- return resolved;
139
151
  }
140
152
  #resolveProvisioner() {
141
153
  const users = this.get("users");
142
154
  if (!users || typeof users.provisionOAuthUser !== "function") throw new AuthenticationError("OAuth provisioning indisponible (le service users n'implémente pas IOAuthUserProvisioner)");
143
155
  return users;
144
156
  }
145
- async #ensureLib() {
146
- return this.#lib ??= await import("arctic");
147
- }
148
157
  #ensureReady() {
149
158
  if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
150
159
  }
@@ -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 };
@@ -0,0 +1,88 @@
1
+ import { readJsonObjectBounded } from "./httpJson.js";
2
+ import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
3
+ //#region nodefony/src/oauth/metadata.ts
4
+ /**
5
+ * **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
6
+ * CLIENTE de la règle que le cœur porte déjà.
7
+ *
8
+ * C'est ce qui remplace, à soi seul, une classe par fournisseur : les points
9
+ * d'entrée ne sont plus écrits en dur, ils sont demandés à l'émetteur. Ajouter un
10
+ * fournisseur OIDC (Microsoft Entra, Auth0, Okta, Authentik...) ne demande donc
11
+ * PLUS de code : son seul émetteur suffit.
12
+ *
13
+ * @remarks **Ce module ne réimplémente RIEN de la RFC 8414.** La normalisation de
14
+ * l'émetteur (`canonicalIssuer`), l'ordre normatif des URL bien connues
15
+ * (`issuerMetadataUrls`) et l'égalité stricte du §3.3 (`validateIssuerMetadata`)
16
+ * vivent dans `nodefony` — la même implémentation sert à PUBLIER nos métadonnées
17
+ * et à LIRE celles d'autrui, sans quoi les deux faces divergeraient en silence.
18
+ * Il n'ajoute que le transport : requête bornée, et lecture des deux points
19
+ * d'entrée dont le flux *Authorization Code* a besoin.
20
+ */
21
+ /** Un émetteur muet ne doit pas retenir la requête de login. */
22
+ const DISCOVERY_TIMEOUT_MS = 1e4;
23
+ /** Au-delà, le document n'est plus un document de métadonnées — on refuse de lire. */
24
+ const MAX_METADATA_BYTES = 1048576;
25
+ async function fetchMetadataDocument(url, options) {
26
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
27
+ headers: {
28
+ Accept: "application/json",
29
+ "User-Agent": "nodefony"
30
+ },
31
+ redirect: "error",
32
+ signal: AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS)
33
+ });
34
+ if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
35
+ return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
36
+ }
37
+ function requireEndpoint(document, field, issuer) {
38
+ const value = document[field];
39
+ if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
40
+ let url;
41
+ try {
42
+ url = new URL(value);
43
+ } catch {
44
+ throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
45
+ }
46
+ if (url.protocol !== "https:") throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https.`);
47
+ return value;
48
+ }
49
+ /**
50
+ * Interroge un serveur d'autorisation et rend ses points d'entrée.
51
+ *
52
+ * Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
53
+ * RFC 8414 §3.1 : insertion oauth → insertion oidc → ajout oidc), et la réponse
54
+ * est CONFRONTÉE à l'émetteur demandé par `validateIssuerMetadata` (§3.3). C'est
55
+ * cette garde qui empêche un émetteur détourné d'imposer ses propres points
56
+ * d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
57
+ *
58
+ * @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
59
+ * @param options - transport injectable et délai d'attente.
60
+ * @returns Les points d'entrée, prêts pour `OAuth2Client`.
61
+ * @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
62
+ */
63
+ async function discoverAuthorizationServer(rawIssuer, options = {}) {
64
+ const issuer = canonicalIssuer(rawIssuer);
65
+ const failures = [];
66
+ for (const candidate of issuerMetadataUrls(issuer)) {
67
+ let document;
68
+ try {
69
+ document = await fetchMetadataDocument(candidate, options);
70
+ } catch (error) {
71
+ failures.push(error instanceof Error ? error.message : String(error));
72
+ continue;
73
+ }
74
+ const identity = validateIssuerMetadata(document, issuer);
75
+ const methods = document.code_challenge_methods_supported;
76
+ return {
77
+ issuer: identity.issuer,
78
+ jwksUri: identity.jwksUri,
79
+ authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
80
+ tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
81
+ codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
82
+ issParameterSupported: document.authorization_response_iss_parameter_supported === true
83
+ };
84
+ }
85
+ throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
86
+ }
87
+ //#endregion
88
+ export { discoverAuthorizationServer };
@@ -0,0 +1,266 @@
1
+ import { readJsonObjectBounded } from "./httpJson.js";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ //#region nodefony/src/oauth/oauth2Client.ts
4
+ /**
5
+ * Client **OAuth 2.0 / Authorization Code** minimal — la face cliente du protocole
6
+ * dont Nodefony écrit déjà la face serveur (émetteur de jetons, métadonnées,
7
+ * ressource protégée). Aucune dépendance : `node:crypto` pour l'entropie, `fetch`
8
+ * pour l'échange.
9
+ *
10
+ * Posture OAuth 2.1 (RFC 9700) : Authorization Code seul, **PKCE S256** (RFC 7636)
11
+ * quand le fournisseur le supporte, `state` anti-CSRF, jamais d'implicit ni de ROPC.
12
+ *
13
+ * @remarks Le flux vit sur un chemin FROID (un login humain) : les quelques
14
+ * allocations et l'unique requête sortante n'entrent dans aucun chemin de requête.
15
+ */
16
+ /**
17
+ * Entropie tirée pour `state` et `code_verifier` : 32 octets, soit 43 caractères
18
+ * en base64url — exactement la borne basse du `code_verifier` (RFC 7636 §4.1),
19
+ * dont l'alphabet est inclus dans les caractères `unreserved` exigés.
20
+ */
21
+ const ENTROPY_BYTES = 32;
22
+ /** Au-delà, la réponse d'un point de jeton n'est plus plausible — on refuse d'analyser. */
23
+ const MAX_TOKEN_RESPONSE_BYTES = 1048576;
24
+ /** Un serveur d'autorisation muet ne doit pas retenir la requête de login. */
25
+ const TOKEN_REQUEST_TIMEOUT_MS = 1e4;
26
+ /**
27
+ * Tire un `state` anti-CSRF (RFC 6749 §10.12, RFC 9700 §4.7) — 256 bits issus du
28
+ * générateur cryptographique du système.
29
+ */
30
+ function generateState() {
31
+ return randomBytes(ENTROPY_BYTES).toString("base64url");
32
+ }
33
+ /**
34
+ * Tire un `code_verifier` PKCE (RFC 7636 §4.1) — 43 caractères de l'alphabet
35
+ * `unreserved`, porteurs de 256 bits d'entropie.
36
+ */
37
+ function generateCodeVerifier() {
38
+ return randomBytes(ENTROPY_BYTES).toString("base64url");
39
+ }
40
+ /**
41
+ * Calcule le `code_challenge` de la méthode **S256** (RFC 7636 §4.2) :
42
+ * `BASE64URL(SHA256(ASCII(code_verifier)))`.
43
+ *
44
+ * @remarks La méthode `plain` n'est jamais proposée — OAuth 2.1 et la RFC 9700
45
+ * §2.1.1 l'excluent : elle ne protège pas d'un code intercepté.
46
+ */
47
+ function createCodeChallenge(codeVerifier) {
48
+ assertCodeVerifier(codeVerifier);
49
+ return createHash("sha256").update(codeVerifier, "ascii").digest("base64url");
50
+ }
51
+ /** Grammaire d'un `code_verifier` : 43 à 128 caractères `unreserved` (RFC 7636 §4.1). */
52
+ const CODE_VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
53
+ /**
54
+ * Refuse un `code_verifier` hors grammaire AVANT de s'en servir.
55
+ *
56
+ * @remarks Sans cette garde, un appelant qui fournit son propre secret (le
57
+ * contrat l'autorise) obtiendrait un défi calculé sur une valeur que le serveur
58
+ * d'autorisation rejettera plus tard : l'erreur sortirait au retour, sous la
59
+ * forme d'un `invalid_grant` que rien ne relie à sa cause.
60
+ *
61
+ * @throws Error - la valeur ne respecte pas la grammaire de la RFC 7636 §4.1.
62
+ */
63
+ function assertCodeVerifier(codeVerifier) {
64
+ if (!CODE_VERIFIER.test(codeVerifier)) throw new Error("code_verifier invalide : 43 à 128 caractères parmi [A-Za-z0-9-._~] (RFC 7636 §4.1).");
65
+ }
66
+ /**
67
+ * Encode une valeur selon `application/x-www-form-urlencoded`, la forme qu'exige
68
+ * l'authentification cliente HTTP Basic (RFC 6749 §2.3.1).
69
+ */
70
+ function formUrlencode(value) {
71
+ return new URLSearchParams([["", value]]).toString().slice(1);
72
+ }
73
+ /**
74
+ * Refus du serveur d'autorisation au point de jeton (RFC 6749 §5.2). Porte le
75
+ * code `error` normalisé — la seule partie de la réponse sûre à journaliser.
76
+ */
77
+ var OAuth2RequestError = class extends Error {
78
+ /** Code normalisé (`invalid_grant`, `invalid_client`, ...) — RFC 6749 §5.2. */
79
+ code;
80
+ /** Description lisible fournie par le serveur, ou `null`. */
81
+ description;
82
+ constructor(code, description) {
83
+ super(description === null ? code : `${code}: ${description}`);
84
+ this.name = "OAuth2RequestError";
85
+ this.code = code;
86
+ this.description = description;
87
+ }
88
+ };
89
+ /**
90
+ * Jetons rendus par le point de jeton (RFC 6749 §5.1), enveloppés pour qu'un champ
91
+ * attendu mais absent lève une erreur NOMMÉE au lieu de propager un `undefined`
92
+ * jusqu'au décodage du profil.
93
+ */
94
+ var OAuth2Tokens = class {
95
+ /** Corps JSON brut de la réponse — donne accès aux extensions du fournisseur. */
96
+ data;
97
+ constructor(data) {
98
+ this.data = data;
99
+ }
100
+ #requireString(field) {
101
+ const value = this.data[field];
102
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Réponse du point de jeton sans champ « ${field} ».`);
103
+ return value;
104
+ }
105
+ /** Jeton d'accès (RFC 6749 §5.1). */
106
+ accessToken() {
107
+ return this.#requireString("access_token");
108
+ }
109
+ /** Type du jeton d'accès — `Bearer` en pratique (RFC 6750). */
110
+ tokenType() {
111
+ return this.#requireString("token_type");
112
+ }
113
+ /** Jeton d'identité OIDC (OpenID Connect Core §3.1.3.3). */
114
+ idToken() {
115
+ return this.#requireString("id_token");
116
+ }
117
+ /** `true` si le serveur a émis un jeton de rafraîchissement. */
118
+ hasRefreshToken() {
119
+ return typeof this.data.refresh_token === "string";
120
+ }
121
+ /** Jeton de rafraîchissement (RFC 6749 §1.5). */
122
+ refreshToken() {
123
+ return this.#requireString("refresh_token");
124
+ }
125
+ /** Durée de vie restante du jeton d'accès, en secondes. */
126
+ accessTokenExpiresInSeconds() {
127
+ const value = this.data.expires_in;
128
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Réponse du point de jeton sans champ « expires_in ».");
129
+ return value;
130
+ }
131
+ /** Instant d'expiration du jeton d'accès, dérivé de `expires_in`. */
132
+ accessTokenExpiresAt() {
133
+ return new Date(Date.now() + this.accessTokenExpiresInSeconds() * 1e3);
134
+ }
135
+ /** `true` si le serveur a annoncé les portées effectivement accordées. */
136
+ hasScopes() {
137
+ return typeof this.data.scope === "string";
138
+ }
139
+ /** Portées accordées, telles que le serveur les a annoncées (RFC 6749 §3.3). */
140
+ scopes() {
141
+ return this.#requireString("scope").split(" ");
142
+ }
143
+ };
144
+ /**
145
+ * Les paramètres que le protocole POSE lui-même, et qu'un appelant ne peut donc
146
+ * pas fournir en supplément.
147
+ *
148
+ * Sans cette garde, `additionalParameters` deviendrait une porte pour réécrire
149
+ * `client_id` ou `redirect_uri` — c'est-à-dire pour faire émettre par ce client
150
+ * une requête qui ne le désigne plus. Le refus est explicite et nomme la clé :
151
+ * un paramètre silencieusement ignoré serait pire, l'appelant croirait l'avoir
152
+ * envoyé.
153
+ */
154
+ const RESERVED_PARAMETERS = /* @__PURE__ */ new Set([
155
+ "response_type",
156
+ "client_id",
157
+ "client_secret",
158
+ "redirect_uri",
159
+ "state",
160
+ "scope",
161
+ "code",
162
+ "code_verifier",
163
+ "code_challenge",
164
+ "code_challenge_method",
165
+ "grant_type"
166
+ ]);
167
+ /**
168
+ * Verse des paramètres supplémentaires sans jamais recouvrir ceux du protocole.
169
+ *
170
+ * @param target - la collection en construction (requête ou corps).
171
+ * @param extra - ce que l'appelant ajoute, tel quel.
172
+ * @throws Error - une clé réservée au protocole a été fournie.
173
+ */
174
+ function applyAdditionalParameters(target, extra) {
175
+ if (extra === void 0) return;
176
+ for (const [key, value] of Object.entries(extra)) {
177
+ if (RESERVED_PARAMETERS.has(key)) throw new Error(`Paramètre « ${key} » réservé au protocole : il est posé par le client, pas par l'appelant.`);
178
+ target.set(key, value);
179
+ }
180
+ }
181
+ /**
182
+ * Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
183
+ * échange le code contre des jetons.
184
+ *
185
+ * Un exemplaire porte les endpoints DÉJÀ résolus — par découverte de métadonnées
186
+ * ({@link discoverAuthorizationServer}) ou en dur pour un fournisseur qui n'en
187
+ * publie pas (GitHub).
188
+ */
189
+ var OAuth2Client = class {
190
+ #options;
191
+ constructor(options) {
192
+ this.#options = options;
193
+ }
194
+ /**
195
+ * Construit l'URL d'autorisation (RFC 6749 §4.1.1). Le `code_challenge` S256 est
196
+ * ajouté dès qu'un `codeVerifier` est fourni.
197
+ *
198
+ * @param request - ce qu'on demande au point d'autorisation.
199
+ * @returns l'URL vers laquelle rediriger l'utilisateur.
200
+ * @throws Error - un paramètre supplémentaire empiète sur le protocole.
201
+ */
202
+ createAuthorizationURL(request) {
203
+ const url = new URL(this.#options.authorizationEndpoint);
204
+ applyAdditionalParameters(url.searchParams, request.additionalParameters);
205
+ url.searchParams.set("response_type", "code");
206
+ url.searchParams.set("client_id", this.#options.clientId);
207
+ url.searchParams.set("redirect_uri", this.#options.redirectUri);
208
+ url.searchParams.set("state", request.state);
209
+ if (request.scopes.length > 0) url.searchParams.set("scope", request.scopes.join(" "));
210
+ if (request.codeVerifier !== null) {
211
+ url.searchParams.set("code_challenge_method", "S256");
212
+ url.searchParams.set("code_challenge", createCodeChallenge(request.codeVerifier));
213
+ }
214
+ return url;
215
+ }
216
+ /**
217
+ * Échange le code d'autorisation contre des jetons (RFC 6749 §4.1.3), de serveur
218
+ * à serveur — le secret client ne quitte jamais ce canal.
219
+ *
220
+ * @param request - ce qu'on présente au point de jeton.
221
+ * @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
222
+ * @throws Error - réponse inintelligible, hors gabarit, serveur injoignable, ou
223
+ * paramètre supplémentaire empiétant sur le protocole.
224
+ */
225
+ async validateAuthorizationCode(request) {
226
+ const body = new URLSearchParams();
227
+ applyAdditionalParameters(body, request.additionalParameters);
228
+ body.set("grant_type", "authorization_code");
229
+ body.set("code", request.code);
230
+ body.set("redirect_uri", this.#options.redirectUri);
231
+ if (request.codeVerifier !== null) body.set("code_verifier", request.codeVerifier);
232
+ const headers = {
233
+ "Content-Type": "application/x-www-form-urlencoded",
234
+ Accept: "application/json",
235
+ "User-Agent": "nodefony"
236
+ };
237
+ switch (this.#options.clientAuthMethod) {
238
+ case "client_secret_basic":
239
+ headers.Authorization = `Basic ${this.#basicCredentials()}`;
240
+ break;
241
+ case "client_secret_post":
242
+ body.set("client_id", this.#options.clientId);
243
+ body.set("client_secret", this.#options.clientSecret);
244
+ break;
245
+ case "none": body.set("client_id", this.#options.clientId);
246
+ }
247
+ const response = await (this.#options.fetch ?? globalThis.fetch)(this.#options.tokenEndpoint, {
248
+ method: "POST",
249
+ headers,
250
+ body: body.toString(),
251
+ redirect: "error",
252
+ signal: AbortSignal.timeout(this.#options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
253
+ });
254
+ const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
255
+ if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null);
256
+ if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
257
+ return new OAuth2Tokens(data);
258
+ }
259
+ #basicCredentials() {
260
+ const id = formUrlencode(this.#options.clientId);
261
+ const secret = formUrlencode(this.#options.clientSecret);
262
+ return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
263
+ }
264
+ };
265
+ //#endregion
266
+ export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState };