@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
@@ -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 };
@@ -1,4 +1,4 @@
1
- import { createOidcProvider } from "./providers/oidc.js";
1
+ import { createDiscoveredOidcProvider } from "./providers/oidc.js";
2
2
  import { createGithubProvider } from "./providers/github.js";
3
3
  //#region nodefony/src/oauth/oauthProviderRegistry.ts
4
4
  const factories = /* @__PURE__ */ new Map();
@@ -17,21 +17,10 @@ function getOAuthProviderFactory(name) {
17
17
  function listOAuthProviders() {
18
18
  return [...factories.keys()];
19
19
  }
20
- registerOAuthProvider("google", (ctx) => createOidcProvider({
21
- name: "google",
22
- client: new ctx.arctic.Google(ctx.clientId, ctx.clientSecret, ctx.redirectUri),
23
- issuer: "https://accounts.google.com",
24
- decodeIdToken: ctx.arctic.decodeIdToken
25
- }));
26
- registerOAuthProvider("keycloak", (ctx) => {
27
- if (!ctx.issuer) throw new Error("OAuth provider \"keycloak\" : config \"issuer\" requise (URL du realm, ex. https://kc.example/realms/app).");
28
- return createOidcProvider({
29
- name: "keycloak",
30
- client: new ctx.arctic.KeyCloak(ctx.issuer, ctx.clientId, ctx.clientSecret, ctx.redirectUri),
31
- issuer: ctx.issuer,
32
- decodeIdToken: ctx.arctic.decodeIdToken
33
- });
34
- });
20
+ const GOOGLE_ISSUER = "https://accounts.google.com";
21
+ registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
22
+ registerOAuthProvider("keycloak", (ctx) => createDiscoveredOidcProvider("keycloak", ctx));
23
+ registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx));
35
24
  registerOAuthProvider("github", createGithubProvider);
36
25
  //#endregion
37
26
  export { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider };
@@ -1,7 +1,15 @@
1
+ import { readJsonBounded } from "../httpJson.js";
2
+ import { OAuth2Client } from "../oauth2Client.js";
1
3
  //#region nodefony/src/oauth/providers/github.ts
2
4
  /** Scopes minimaux : profil public + emails (l'email primaire peut être privé). */
3
5
  const DEFAULT_SCOPES = ["read:user", "user:email"];
4
6
  const API = "https://api.github.com";
7
+ /** Une API muette ne doit pas retenir le callback jusqu'aux délais du runtime. */
8
+ const API_TIMEOUT_MS = 1e4;
9
+ /** Au-delà, ce n'est plus un profil : on refuse de lire. */
10
+ const MAX_PROFILE_BYTES = 1048576;
11
+ const AUTHORIZATION_ENDPOINT = "https://github.com/login/oauth/authorize";
12
+ const TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token";
5
13
  function ghHeaders(accessToken) {
6
14
  return {
7
15
  Authorization: `Bearer ${accessToken}`,
@@ -11,27 +19,44 @@ function ghHeaders(accessToken) {
11
19
  };
12
20
  }
13
21
  async function ghGet(url, accessToken) {
14
- const res = await fetch(url, { headers: ghHeaders(accessToken) });
22
+ const res = await fetch(url, {
23
+ headers: ghHeaders(accessToken),
24
+ redirect: "error",
25
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
26
+ });
15
27
  if (!res.ok) throw new Error(`GitHub API ${url} → ${res.status}`);
16
- return res.json();
28
+ return readJsonBounded(res, MAX_PROFILE_BYTES, `GitHub API ${url}`);
17
29
  }
18
30
  /**
19
31
  * Fournisseur **GitHub** (OAuth 2.0 simple, NON-OIDC). Pas de PKCE, pas d'ID
20
32
  * token : le profil est lu via l'API REST (`/user`), et l'email — souvent privé —
21
33
  * via `/user/emails` (scope `user:email`). GitHub n'émet pas de paramètre `iss`
22
- * (`expectedIssuer = null`) : la défense anti-CSRF repose sur le `state`.
34
+ * (`issuerPolicy = null`) : la défense anti-CSRF repose sur le `state`.
23
35
  */
24
36
  function createGithubProvider(ctx) {
25
- const client = new ctx.arctic.GitHub(ctx.clientId, ctx.clientSecret, ctx.redirectUri);
37
+ const client = new OAuth2Client({
38
+ authorizationEndpoint: AUTHORIZATION_ENDPOINT,
39
+ tokenEndpoint: TOKEN_ENDPOINT,
40
+ clientId: ctx.clientId,
41
+ clientSecret: ctx.clientSecret,
42
+ clientAuthMethod: ctx.clientAuthMethod ?? "client_secret_basic",
43
+ redirectUri: ctx.redirectUri
44
+ });
26
45
  return {
27
46
  usesPkce: false,
28
- expectedIssuer: null,
47
+ issuerPolicy: null,
29
48
  defaultScopes: DEFAULT_SCOPES,
30
- createAuthorizationURL(state, _codeVerifier, scopes) {
31
- return client.createAuthorizationURL(state, scopes);
49
+ createAuthorizationURL(request) {
50
+ return client.createAuthorizationURL({
51
+ ...request,
52
+ codeVerifier: null
53
+ });
32
54
  },
33
- validateAuthorizationCode(code, _codeVerifier) {
34
- return client.validateAuthorizationCode(code);
55
+ validateAuthorizationCode(request) {
56
+ return client.validateAuthorizationCode({
57
+ ...request,
58
+ codeVerifier: null
59
+ });
35
60
  },
36
61
  async fetchProfile(tokens) {
37
62
  const accessToken = tokens.accessToken();
@@ -1,3 +1,5 @@
1
+ import { OAuth2Client } from "../oauth2Client.js";
2
+ import { discoverAuthorizationServer } from "../metadata.js";
1
3
  //#region nodefony/src/oauth/providers/oidc.ts
2
4
  const DEFAULT_OIDC_SCOPES = [
3
5
  "openid",
@@ -5,14 +7,42 @@ const DEFAULT_OIDC_SCOPES = [
5
7
  "email"
6
8
  ];
7
9
  /**
10
+ * Lit les claims d'un ID token SANS vérifier sa signature.
11
+ *
12
+ * @remarks C'est ce qu'autorise OpenID Connect Core §3.1.3.7 dans le flux
13
+ * *Authorization Code* : le jeton vient d'être reçu du point de jeton, sur un
14
+ * canal TLS direct et authentifié — il n'a traversé ni le navigateur ni un tiers.
15
+ * `jose` est chargé paresseusement, comme partout ailleurs dans ce module : il
16
+ * n'entre jamais dans le coût du boot.
17
+ */
18
+ async function decodeIdTokenClaims(idToken) {
19
+ return (await import("jose")).decodeJwt(idToken);
20
+ }
21
+ /**
22
+ * Éprouve les claims OBLIGATOIRES d'un ID token (OpenID Connect Core §3.1.3.7).
23
+ *
24
+ * @remarks La SIGNATURE n'est pas vérifiée — le jeton vient d'être reçu du point
25
+ * de jeton sur un canal TLS direct, ce que la norme admet explicitement. Mais les
26
+ * autres exigences du même paragraphe ne coûtent aucun réseau et ferment de vrais
27
+ * écarts : un jeton d'un autre émetteur (point 2), délivré à une autre
28
+ * application (point 3), ou périmé (point 9), n'a rien à faire ici.
29
+ *
30
+ * @throws Error - un claim obligatoire est absent, discordant ou périmé.
31
+ */
32
+ function assertIdTokenClaims(claims, opts) {
33
+ if (typeof claims.sub !== "string" || claims.sub.length === 0) throw new Error(`${opts.name}: ID token sans claim 'sub'.`);
34
+ if (claims.iss !== opts.issuer) throw new Error(`${opts.name}: ID token émis par « ${String(claims.iss)} », attendu « ${opts.issuer} ».`);
35
+ const aud = claims.aud;
36
+ if (!(aud === opts.clientId || Array.isArray(aud) && aud.includes(opts.clientId))) throw new Error(`${opts.name}: ID token délivré à une autre application.`);
37
+ if (typeof claims.exp !== "number" || claims.exp * 1e3 <= Date.now()) throw new Error(`${opts.name}: ID token périmé ou sans 'exp'.`);
38
+ }
39
+ /**
8
40
  * Fabrique un {@link IOAuthProvider} **générique OIDC** — couvre TOUT fournisseur
9
41
  * OpenID Connect sans code spécifique : le profil se lit toujours pareil (claims
10
- * standard `sub`/`email`/`email_verified`/`name` de l'ID token). Ajouter un
11
- * fournisseur OIDC = une entrée de quelques lignes (nom + classe arctic + issuer),
12
- * pas un fichier.
42
+ * standard `sub`/`email`/`email_verified`/`name` de l'ID token).
13
43
  *
14
- * PKCE S256 systématique (RFC 7636) ; le profil vient de l'ID token signé obtenu
15
- * du token endpoint via TLS (décodage suffisant en code flow, RFC 8725).
44
+ * PKCE S256 systématique (RFC 7636) ; le profil vient de l'ID token obtenu du
45
+ * point de jeton via TLS.
16
46
  */
17
47
  function createOidcProvider(opts) {
18
48
  const requireVerifier = (codeVerifier) => {
@@ -21,18 +51,27 @@ function createOidcProvider(opts) {
21
51
  };
22
52
  return {
23
53
  usesPkce: true,
24
- expectedIssuer: opts.issuer,
54
+ issuerPolicy: {
55
+ issuer: opts.issuer,
56
+ requireIssParameter: opts.issParameterSupported === true
57
+ },
25
58
  defaultScopes: opts.defaultScopes ?? DEFAULT_OIDC_SCOPES,
26
- createAuthorizationURL(state, codeVerifier, scopes) {
27
- return opts.client.createAuthorizationURL(state, requireVerifier(codeVerifier), scopes);
59
+ createAuthorizationURL(request) {
60
+ return opts.client.createAuthorizationURL({
61
+ ...request,
62
+ codeVerifier: requireVerifier(request.codeVerifier)
63
+ });
28
64
  },
29
- validateAuthorizationCode(code, codeVerifier) {
30
- return opts.client.validateAuthorizationCode(code, requireVerifier(codeVerifier));
65
+ validateAuthorizationCode(request) {
66
+ return opts.client.validateAuthorizationCode({
67
+ ...request,
68
+ codeVerifier: requireVerifier(request.codeVerifier)
69
+ });
31
70
  },
32
71
  async fetchProfile(tokens) {
33
- const claims = opts.decodeIdToken(tokens.idToken());
72
+ const claims = await opts.decodeIdToken(tokens.idToken());
73
+ assertIdTokenClaims(claims, opts);
34
74
  const sub = claims.sub;
35
- if (typeof sub !== "string" || sub.length === 0) throw new Error(`${opts.name}: ID token sans claim 'sub'.`);
36
75
  return {
37
76
  provider: opts.name,
38
77
  providerId: sub,
@@ -44,5 +83,40 @@ function createOidcProvider(opts) {
44
83
  }
45
84
  };
46
85
  }
86
+ /**
87
+ * Construit un fournisseur OIDC **en demandant ses points d'entrée à l'émetteur**
88
+ * (RFC 8414 / OpenID Connect Discovery) — aucune URL n'est écrite en dur.
89
+ *
90
+ * C'est ce qui permet d'enregistrer n'importe quel fournisseur OpenID Connect sans
91
+ * écrire une ligne de code : seul son émetteur le distingue.
92
+ *
93
+ * @param name - nom sous lequel le fournisseur est configuré.
94
+ * @param options - émetteur explicite, transport injectable, délai d'attente.
95
+ * @throws Error - émetteur absent, découverte impossible, ou serveur annonçant ne
96
+ * pas supporter PKCE S256 alors que ce fournisseur l'exige.
97
+ */
98
+ async function createDiscoveredOidcProvider(name, ctx, options = {}) {
99
+ const effectiveIssuer = options.issuer ?? ctx.issuer;
100
+ if (!effectiveIssuer) throw new Error(`OAuth provider "${name}" : config "issuer" requise (URL de l'émetteur OIDC).`);
101
+ const metadata = await discoverAuthorizationServer(effectiveIssuer, options);
102
+ if (metadata.codeChallengeMethodsSupported !== null && !metadata.codeChallengeMethodsSupported.includes("S256")) throw new Error(`OAuth provider "${name}" : l'émetteur « ${effectiveIssuer} » n'annonce pas PKCE S256 (RFC 7636).`);
103
+ return createOidcProvider({
104
+ name,
105
+ issuer: metadata.issuer,
106
+ issParameterSupported: metadata.issParameterSupported,
107
+ clientId: ctx.clientId,
108
+ decodeIdToken: decodeIdTokenClaims,
109
+ client: new OAuth2Client({
110
+ authorizationEndpoint: metadata.authorizationEndpoint,
111
+ tokenEndpoint: metadata.tokenEndpoint,
112
+ clientId: ctx.clientId,
113
+ clientSecret: ctx.clientSecret,
114
+ clientAuthMethod: ctx.clientAuthMethod ?? "client_secret_basic",
115
+ redirectUri: ctx.redirectUri,
116
+ fetch: options.fetch,
117
+ timeoutMs: options.timeoutMs
118
+ })
119
+ });
120
+ }
47
121
  //#endregion
48
- export { createOidcProvider };
122
+ export { createDiscoveredOidcProvider, createOidcProvider };
@@ -114,9 +114,16 @@ export type { IWebAuthnUser, IWebAuthnAssertionResult, } from "./nodefony/servic
114
114
  export type { IWebAuthnCredential } from "./nodefony/contracts/IWebAuthnCredential.js";
115
115
  export type { IWebAuthnCredentialStore, IWebAuthnCredentialSummary, IWebAuthnListQuery, WebAuthnAuthUpdate, } from "./nodefony/contracts/IWebAuthnCredentialStore.js";
116
116
  export type { IOAuthAuthorization } from "./nodefony/service/oauth2.js";
117
- export type { IOAuthProvider } from "./nodefony/contracts/IOAuthProvider.js";
117
+ export type { IOAuthProvider, IIssuerPolicy, } from "./nodefony/contracts/IOAuthProvider.js";
118
118
  export { registerOAuthProvider, getOAuthProviderFactory, listOAuthProviders, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
119
119
  export type { OAuthProviderFactory, IOAuthProviderContext, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
120
+ export { OAuth2Client, OAuth2Tokens, OAuth2RequestError, generateState, generateCodeVerifier, createCodeChallenge, } from "./nodefony/src/oauth/oauth2Client.js";
121
+ export type { IOAuth2ClientOptions } from "./nodefony/src/oauth/oauth2Client.js";
122
+ export { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
123
+ export type { IDiscoveredAuthorizationServer, IDiscoveryOptions, } from "./nodefony/src/oauth/metadata.js";
124
+ export { createOidcProvider, createDiscoveredOidcProvider, } from "./nodefony/src/oauth/providers/oidc.js";
125
+ export type { IOidcPkceClient, IOidcProviderOptions, } from "./nodefony/src/oauth/providers/oidc.js";
126
+ export { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
120
127
  export { MemoryAuditStore } from "./nodefony/src/audit/MemoryAuditStore.js";
121
128
  export type { AuditStoreSnapshot } from "./nodefony/src/audit/MemoryAuditStore.js";
122
129
  export type { IAuditEvent, IAuditEventDraft, IAuditEventFlags, AuditCategory, AuditOutcome, } from "./nodefony/contracts/IAuditEvent.js";
@@ -228,12 +228,18 @@ export declare const securityConfigSchema: z.ZodObject<{
228
228
  providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
229
229
  clientId: z.ZodString;
230
230
  clientSecret: z.ZodString;
231
+ clientAuthMethod: z.ZodOptional<z.ZodEnum<{
232
+ client_secret_basic: "client_secret_basic";
233
+ client_secret_post: "client_secret_post";
234
+ }>>;
231
235
  redirectUri: z.ZodString;
232
236
  issuer: z.ZodOptional<z.ZodString>;
233
237
  scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
234
238
  successRedirect: z.ZodOptional<z.ZodString>;
235
239
  failureRedirect: z.ZodOptional<z.ZodString>;
236
240
  defaultRoles: z.ZodOptional<z.ZodArray<z.ZodString>>;
241
+ label: z.ZodOptional<z.ZodString>;
242
+ hidden: z.ZodDefault<z.ZodBoolean>;
237
243
  }, z.core.$strict>>>;
238
244
  }, z.core.$strict>>;
239
245
  apiKeys: z.ZodDefault<z.ZodObject<{