@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.
@@ -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,6 +228,10 @@ 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>>;
@@ -1,9 +1,36 @@
1
- import type { OAuth2Tokens } from "arctic";
1
+ import type { IAuthorizationRequest, ITokenRequest, OAuth2Tokens } from "../src/oauth/oauth2Client.js";
2
2
  import type { IOAuthProfile } from "@nodefony/user";
3
3
  /**
4
- * Adaptateur d'**un fournisseur OAuth/OIDC**, façade UNIFORME au-dessus d'une
5
- * classe `arctic` — masque les divergences entre fournisseurs derrière un contrat
6
- * stable consommé par `OAuth2Service` :
4
+ * Ce qu'on exige du paramètre `iss` renvoyé par le serveur d'autorisation
5
+ * (RFC 9207).
6
+ *
7
+ * @remarks La règle a **trois** états, pas deux — et c'est ce que le booléen
8
+ * seul ne pouvait pas dire. La RFC §2.4 impose au client d'extraire `iss`
9
+ * « **if the parameter is present** », et son §2.3 fait annoncer le support par
10
+ * les métadonnées de l'émetteur. Refuser une réponse sans `iss` d'un serveur qui
11
+ * n'a jamais promis de l'émettre revient donc à refuser un serveur CONFORME —
12
+ * Microsoft Entra en est un.
13
+ *
14
+ * La défense anti-mix-up ne repose d'ailleurs pas sur ce seul paramètre : chaque
15
+ * fournisseur a son URL de redirection propre (`…/{provider}/callback`) et le
16
+ * flux vérifie que le fournisseur de retour est celui qui a démarré, ce que la
17
+ * RFC 9700 §4.4.2.2 donne comme défense principale. `iss` est la seconde ceinture.
18
+ */
19
+ export interface IIssuerPolicy {
20
+ /** Émetteur attendu, sous sa forme canonique. */
21
+ readonly issuer: string;
22
+ /**
23
+ * `true` si le serveur ANNONCE émettre `iss`
24
+ * (`authorization_response_iss_parameter_supported`) : son absence est alors
25
+ * une promesse non tenue, donc un refus. `false` : absent, on continue ;
26
+ * présent, il doit correspondre.
27
+ */
28
+ readonly requireIssParameter: boolean;
29
+ }
30
+ /**
31
+ * Adaptateur d'**un fournisseur OAuth/OIDC**, façade UNIFORME au-dessus d'un client
32
+ * OAuth 2.0 — masque les divergences entre fournisseurs derrière un contrat stable
33
+ * consommé par `OAuth2Service` :
7
34
  *
8
35
  * - **PKCE ou non** : Google attend `createAuthorizationURL(state, codeVerifier,
9
36
  * scopes)` ; GitHub `createAuthorizationURL(state, scopes)` (pas de
@@ -13,9 +40,9 @@ import type { IOAuthProfile } from "@nodefony/user";
13
40
  * l'API du fournisseur (GitHub `/user`). Le résultat est toujours normalisé en
14
41
  * {@link IOAuthProfile}.
15
42
  *
16
- * @remarks `arctic` n'est référencé ici qu'en **type** (`import type`, effacé à la
17
- * compilation) l'instance runtime est chargée paresseusement par le service et
18
- * injectée aux fabriques. Aucune dépendance runtime n'entre par ce contrat.
43
+ * @remarks Ce contrat n'introduit aucune dépendance : le client OAuth 2.0 sous-jacent
44
+ * est écrit dans le module même, et un fournisseur maison peut l'implémenter sans
45
+ * rien installer.
19
46
  */
20
47
  export interface IOAuthProvider {
21
48
  /**
@@ -24,24 +51,28 @@ export interface IOAuthProvider {
24
51
  */
25
52
  readonly usesPkce: boolean;
26
53
  /**
27
- * Identifiant d'émetteur attendu pour la défense anti-mix-up (RFC 9207), ou
28
- * `null` si le fournisseur n'émet pas le paramètre `iss` (ex. GitHub, non-OIDC).
29
- * Quand non-`null`, le service **rejette** une réponse dont l'`iss` diffère ou
30
- * manque.
54
+ * Politique de vérification du paramètre `iss` (anti-mix-up, RFC 9207), ou
55
+ * `null` pour un fournisseur qui ne relève pas de cette défense (GitHub,
56
+ * non-OIDC).
31
57
  */
32
- readonly expectedIssuer: string | null;
58
+ readonly issuerPolicy: IIssuerPolicy | null;
33
59
  /** Scopes appliqués quand la configuration n'en précise aucun. */
34
60
  readonly defaultScopes: string[];
35
61
  /**
36
62
  * Construit l'URL d'autorisation (étape 1). `codeVerifier` est non-`null`
37
63
  * lorsque {@link usesPkce} ; les fournisseurs sans PKCE l'ignorent.
64
+ *
65
+ * @remarks La forme est un OBJET pour que les paramètres normalisés encore
66
+ * absents — `resource` (RFC 8707), `nonce`, `prompt`… — s'ajoutent plus tard
67
+ * sans rupture. Ce contrat est EXPORTÉ, donc gelé à la publication : une
68
+ * signature positionnelle y aurait figé l'impossibilité de les accueillir.
38
69
  */
39
- createAuthorizationURL(state: string, codeVerifier: string | null, scopes: string[]): URL;
70
+ createAuthorizationURL(request: IAuthorizationRequest): URL;
40
71
  /**
41
72
  * Échange le `code` d'autorisation contre des jetons (étape 2, canal serveur).
42
73
  * `codeVerifier` doit correspondre à celui de l'étape 1 si {@link usesPkce}.
43
74
  */
44
- validateAuthorizationCode(code: string, codeVerifier: string | null): Promise<OAuth2Tokens>;
75
+ validateAuthorizationCode(request: ITokenRequest): Promise<OAuth2Tokens>;
45
76
  /**
46
77
  * Récupère et **normalise** le profil de l'utilisateur à partir des jetons.
47
78
  *
@@ -92,7 +92,7 @@ export interface IAccessTokenRecord {
92
92
  secretHash: string;
93
93
  /** Algorithme de hachage du secret (agilité crypto, migration future). Ex. `"sha256"`. */
94
94
  hashAlg: string;
95
- /** Client OAuth émetteur (slot OAuth2/arctic) ; `null` = non-OAuth. */
95
+ /** Client OAuth émetteur (slot OAuth2) ; `null` = non-OAuth. */
96
96
  clientId: string | null;
97
97
  /** Confirmation sender-constrained : `jkt` (DPoP RFC 9449) / `x5t#S256` (mTLS RFC 8705) ; `null` = bearer simple. */
98
98
  cnf: string | null;
@@ -9,8 +9,7 @@ export interface IOAuthAuthorization {
9
9
  readonly codeVerifier: string | null;
10
10
  }
11
11
  /**
12
- * **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*
13
- * au-dessus d'`arctic`.
12
+ * **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
14
13
  *
15
14
  * Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
16
15
  * ROPC), **PKCE S256** quand le fournisseur le supporte (RFC 7636), **state**
@@ -18,10 +17,11 @@ export interface IOAuthAuthorization {
18
17
  * (le login produit une **session BFF**, gérée hors de ce service par le
19
18
  * controller + `AuthFlow`).
20
19
  *
21
- * `arctic` est **importé paresseusement** au premier login (cold path — jamais au
22
- * boot ni par requête), comme `@simplewebauthn`/`jose`. Au boot (si
23
- * `oauth2.enabled`) : seule la config est validée et les fournisseurs configurés
24
- * sont confrontés au registre (un nom inconnu = WARNING, pas fatal).
20
+ * Les fournisseurs sont construits **au premier login** (cold path — jamais au boot
21
+ * ni par requête) puis mémoïsés : c'est là que les points d'entrée d'un émetteur
22
+ * OIDC sont découverts, une seule fois par processus. Au boot (si `oauth2.enabled`)
23
+ * : seule la config est validée et les fournisseurs configurés sont confrontés au
24
+ * registre (un nom inconnu = WARNING, pas fatal).
25
25
  *
26
26
  * Le service ne touche **ni HTTP ni session** : il rend à l'appelant les éléments
27
27
  * (URL, state, verifier) que le controller persiste en session — testable sans
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Lecture BORNÉE d'un corps JSON — la brique de transport commune aux trois
3
+ * appels sortants du social login (découverte, point de jeton, API d'un
4
+ * fournisseur non-OIDC).
5
+ *
6
+ * Elle existe parce qu'une borne posée APRÈS `response.text()` ne protège plus
7
+ * rien : le corps est déjà entièrement en mémoire quand on mesure sa longueur.
8
+ */
9
+ /**
10
+ * Lit un corps JSON en refusant de dépasser une taille — la borne est vérifiée
11
+ * PENDANT la lecture, pas après : un corps déjà entièrement en mémoire ne se
12
+ * refuse plus.
13
+ *
14
+ * @param response - réponse dont le corps reste à lire.
15
+ * @param maxBytes - plafond, en octets réels du flux.
16
+ * @param subject - ce qu'on lisait, pour que l'erreur soit exploitable.
17
+ * @returns la valeur JSON telle quelle — un objet OU un tableau (l'API d'un
18
+ * fournisseur rend les deux ; c'est à l'appelant d'exiger la forme qu'il attend).
19
+ * @throws Error - corps trop gros ou illisible.
20
+ */
21
+ export declare function readJsonBounded(response: Response, maxBytes: number, subject: string): Promise<unknown>;
22
+ /**
23
+ * Comme {@link readJsonBounded}, mais exige un OBJET — la forme de toute réponse
24
+ * normalisée par une RFC (document de métadonnées, réponse d'un point de jeton).
25
+ *
26
+ * @throws Error - corps trop gros, illisible, ou qui n'est pas un objet JSON.
27
+ */
28
+ export declare function readJsonObjectBounded(response: Response, maxBytes: number, subject: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Points d'entrée retenus d'un serveur d'autorisation — le sous-ensemble dont le
3
+ * flux *Authorization Code* a besoin (RFC 8414 §2).
4
+ *
5
+ * @remarks Le nom dit **ce qu'on a découvert chez autrui**, à ne pas confondre
6
+ * avec `IAuthorizationServerMetadata` du cœur, qui décrit le document que Nodefony
7
+ * PUBLIE (champs bruts de la RFC).
8
+ */
9
+ export interface IDiscoveredAuthorizationServer {
10
+ /** Émetteur canonique, vérifié identique à celui interrogé (RFC 8414 §3.3). */
11
+ readonly issuer: string;
12
+ /** Point d'autorisation (RFC 6749 §3.1). */
13
+ readonly authorizationEndpoint: string;
14
+ /** Point de jeton (RFC 6749 §3.2). */
15
+ readonly tokenEndpoint: string;
16
+ /** Jeu de clés de signature — la porte d'une future vérification d'ID token. */
17
+ readonly jwksUri: string;
18
+ /** Méthodes PKCE annoncées (RFC 7636), ou `null` si le serveur n'en publie aucune. */
19
+ readonly codeChallengeMethodsSupported: string[] | null;
20
+ /**
21
+ * `true` si le serveur ANNONCE émettre le paramètre `iss` dans sa réponse
22
+ * d'autorisation (RFC 9207 §2.3). Absent du document ⇒ `false` : on ne peut
23
+ * alors pas exiger ce qu'il n'a pas promis.
24
+ */
25
+ readonly issParameterSupported: boolean;
26
+ }
27
+ /** Réglages de la découverte — l'injection de `fetch` est la voie pour éprouver sans TLS. */
28
+ export interface IDiscoveryOptions {
29
+ /**
30
+ * Implémentation de `fetch` à employer.
31
+ *
32
+ * @remarks C'est ce que prescrit le cœur pour éprouver le mécanisme sans TLS :
33
+ * un émetteur en clair est refusé (RFC 8414 §2), on injecte donc le transport
34
+ * plutôt que d'affaiblir la règle.
35
+ */
36
+ readonly fetch?: typeof globalThis.fetch;
37
+ /** Délai d'attente par URL candidate, en millisecondes. */
38
+ readonly timeoutMs?: number;
39
+ }
40
+ /**
41
+ * Interroge un serveur d'autorisation et rend ses points d'entrée.
42
+ *
43
+ * Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
44
+ * RFC 8414 §3.1 : insertion oauth → insertion oidc → ajout oidc), et la réponse
45
+ * est CONFRONTÉE à l'émetteur demandé par `validateIssuerMetadata` (§3.3). C'est
46
+ * cette garde qui empêche un émetteur détourné d'imposer ses propres points
47
+ * d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
48
+ *
49
+ * @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
50
+ * @param options - transport injectable et délai d'attente.
51
+ * @returns Les points d'entrée, prêts pour `OAuth2Client`.
52
+ * @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
53
+ */
54
+ export declare function discoverAuthorizationServer(rawIssuer: string, options?: IDiscoveryOptions): Promise<IDiscoveredAuthorizationServer>;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tire un `state` anti-CSRF (RFC 6749 §10.12, RFC 9700 §4.7) — 256 bits issus du
3
+ * générateur cryptographique du système.
4
+ */
5
+ export declare function generateState(): string;
6
+ /**
7
+ * Tire un `code_verifier` PKCE (RFC 7636 §4.1) — 43 caractères de l'alphabet
8
+ * `unreserved`, porteurs de 256 bits d'entropie.
9
+ */
10
+ export declare function generateCodeVerifier(): string;
11
+ /**
12
+ * Calcule le `code_challenge` de la méthode **S256** (RFC 7636 §4.2) :
13
+ * `BASE64URL(SHA256(ASCII(code_verifier)))`.
14
+ *
15
+ * @remarks La méthode `plain` n'est jamais proposée — OAuth 2.1 et la RFC 9700
16
+ * §2.1.1 l'excluent : elle ne protège pas d'un code intercepté.
17
+ */
18
+ export declare function createCodeChallenge(codeVerifier: string): string;
19
+ /**
20
+ * Refus du serveur d'autorisation au point de jeton (RFC 6749 §5.2). Porte le
21
+ * code `error` normalisé — la seule partie de la réponse sûre à journaliser.
22
+ */
23
+ export declare class OAuth2RequestError extends Error {
24
+ /** Code normalisé (`invalid_grant`, `invalid_client`, ...) — RFC 6749 §5.2. */
25
+ readonly code: string;
26
+ /** Description lisible fournie par le serveur, ou `null`. */
27
+ readonly description: string | null;
28
+ constructor(code: string, description: string | null);
29
+ }
30
+ /**
31
+ * Jetons rendus par le point de jeton (RFC 6749 §5.1), enveloppés pour qu'un champ
32
+ * attendu mais absent lève une erreur NOMMÉE au lieu de propager un `undefined`
33
+ * jusqu'au décodage du profil.
34
+ */
35
+ export declare class OAuth2Tokens {
36
+ #private;
37
+ /** Corps JSON brut de la réponse — donne accès aux extensions du fournisseur. */
38
+ readonly data: Record<string, unknown>;
39
+ constructor(data: Record<string, unknown>);
40
+ /** Jeton d'accès (RFC 6749 §5.1). */
41
+ accessToken(): string;
42
+ /** Type du jeton d'accès — `Bearer` en pratique (RFC 6750). */
43
+ tokenType(): string;
44
+ /** Jeton d'identité OIDC (OpenID Connect Core §3.1.3.3). */
45
+ idToken(): string;
46
+ /** `true` si le serveur a émis un jeton de rafraîchissement. */
47
+ hasRefreshToken(): boolean;
48
+ /** Jeton de rafraîchissement (RFC 6749 §1.5). */
49
+ refreshToken(): string;
50
+ /** Durée de vie restante du jeton d'accès, en secondes. */
51
+ accessTokenExpiresInSeconds(): number;
52
+ /** Instant d'expiration du jeton d'accès, dérivé de `expires_in`. */
53
+ accessTokenExpiresAt(): Date;
54
+ /** `true` si le serveur a annoncé les portées effectivement accordées. */
55
+ hasScopes(): boolean;
56
+ /** Portées accordées, telles que le serveur les a annoncées (RFC 6749 §3.3). */
57
+ scopes(): string[];
58
+ }
59
+ /**
60
+ * Comment le client s'authentifie au point de jeton (RFC 6749 §2.3, et le
61
+ * registre `token_endpoint_auth_method` d'OpenID Connect Discovery).
62
+ *
63
+ * 🔴 Elle se DÉCLARE, elle ne se déduit plus de la vacuité du secret. La
64
+ * convention implicite « secret non vide ⇒ Basic » avait deux défauts : elle ne
65
+ * pouvait pas exprimer `client_secret_post`, que certains serveurs exigent et
66
+ * annoncent dans leurs métadonnées (`token_endpoint_auth_methods_supported`,
67
+ * déjà découvert et lu par personne) ; et elle rendait un client public
68
+ * indiscernable d'un client dont le secret manque par erreur — deux situations
69
+ * qu'un serveur traite très différemment.
70
+ *
71
+ * L'union est ouverte par le bas : `private_key_jwt` (qu'Apple réclame)
72
+ * s'ajoutera ici sans toucher à la forme des appels.
73
+ */
74
+ export type OAuth2ClientAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
75
+ /**
76
+ * Étape 1 — ce qu'on demande au point d'autorisation.
77
+ *
78
+ * La forme est un OBJET, et c'est tout l'enjeu : les paramètres normalisés qui
79
+ * manquent encore — `resource` (RFC 8707, exigé par le Model Context Protocol),
80
+ * `nonce`, `prompt`, `max_age`, `login_hint`, `access_type` — s'ajouteront en
81
+ * champs nommés sans toucher à un seul appelant. Une signature positionnelle
82
+ * aurait figé cette impossibilité à la publication de la 10.0.0.
83
+ */
84
+ export interface IAuthorizationRequest {
85
+ /** Valeur anti-CSRF à retrouver au retour (RFC 6749 §10.12). */
86
+ readonly state: string;
87
+ /** Secret PKCE (RFC 7636), ou `null` pour un fournisseur qui n'en veut pas. */
88
+ readonly codeVerifier: string | null;
89
+ /** Portées demandées ; aucune n'est ajoutée d'office. */
90
+ readonly scopes: readonly string[];
91
+ /**
92
+ * Paramètres versés TELS QUELS dans la requête d'autorisation.
93
+ *
94
+ * Un champ dédié plutôt qu'une signature d'index sur tout l'objet : celle-ci
95
+ * accepterait `stat` pour `state` sans rien dire. Ici, ce qui est nommé est
96
+ * vérifié par le compilateur, et ce qui passe en supplément est déclaré comme
97
+ * tel.
98
+ */
99
+ readonly additionalParameters?: Readonly<Record<string, string>>;
100
+ }
101
+ /**
102
+ * Étape 2 — ce qu'on présente au point de jeton.
103
+ *
104
+ * Même raison d'être qu'{@link IAuthorizationRequest} : `resource` doit être
105
+ * répété ici (RFC 8707 §2.2), et rien ne pouvait s'ajouter à une signature
106
+ * positionnelle.
107
+ */
108
+ export interface ITokenRequest {
109
+ /** Code reçu sur l'URL de redirection (RFC 6749 §4.1.2). */
110
+ readonly code: string;
111
+ /** Secret PKCE de l'étape 1, ou `null`. */
112
+ readonly codeVerifier: string | null;
113
+ /** Paramètres versés TELS QUELS dans le corps de la requête de jeton. */
114
+ readonly additionalParameters?: Readonly<Record<string, string>>;
115
+ }
116
+ /** Points d'entrée d'un serveur d'autorisation et identité du client. */
117
+ export interface IOAuth2ClientOptions {
118
+ /** Point d'autorisation (RFC 6749 §3.1) — où l'utilisateur est redirigé. */
119
+ readonly authorizationEndpoint: string;
120
+ /** Point de jeton (RFC 6749 §3.2) — où le code est échangé, de serveur à serveur. */
121
+ readonly tokenEndpoint: string;
122
+ /** Identifiant client délivré par le fournisseur. */
123
+ readonly clientId: string;
124
+ /** Secret client — vide, et seulement vide, quand {@link clientAuthMethod} vaut `"none"`. */
125
+ readonly clientSecret: string;
126
+ /**
127
+ * Comment ce client s'authentifie au point de jeton.
128
+ *
129
+ * REQUIS, et volontairement : c'est ce qui remplace la convention implicite
130
+ * « secret non vide ⇒ Basic ». Un fournisseur doit dire ce qu'il fait, pas le
131
+ * laisser déduire d'une longueur de chaîne.
132
+ */
133
+ readonly clientAuthMethod: OAuth2ClientAuthMethod;
134
+ /** URL de redirection exacte, telle qu'enregistrée chez le fournisseur (RFC 9700 §4.1). */
135
+ readonly redirectUri: string;
136
+ /**
137
+ * Implémentation de `fetch` à employer — la voie pour éprouver l'échange sans
138
+ * réseau, plutôt que de remplacer le `fetch` global du processus.
139
+ */
140
+ readonly fetch?: typeof globalThis.fetch;
141
+ /** Délai d'attente de l'échange, en millisecondes. */
142
+ readonly timeoutMs?: number;
143
+ }
144
+ /**
145
+ * Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
146
+ * échange le code contre des jetons.
147
+ *
148
+ * Un exemplaire porte les endpoints DÉJÀ résolus — par découverte de métadonnées
149
+ * ({@link discoverAuthorizationServer}) ou en dur pour un fournisseur qui n'en
150
+ * publie pas (GitHub).
151
+ */
152
+ export declare class OAuth2Client {
153
+ #private;
154
+ constructor(options: IOAuth2ClientOptions);
155
+ /**
156
+ * Construit l'URL d'autorisation (RFC 6749 §4.1.1). Le `code_challenge` S256 est
157
+ * ajouté dès qu'un `codeVerifier` est fourni.
158
+ *
159
+ * @param request - ce qu'on demande au point d'autorisation.
160
+ * @returns l'URL vers laquelle rediriger l'utilisateur.
161
+ * @throws Error - un paramètre supplémentaire empiète sur le protocole.
162
+ */
163
+ createAuthorizationURL(request: IAuthorizationRequest): URL;
164
+ /**
165
+ * Échange le code d'autorisation contre des jetons (RFC 6749 §4.1.3), de serveur
166
+ * à serveur — le secret client ne quitte jamais ce canal.
167
+ *
168
+ * @param request - ce qu'on présente au point de jeton.
169
+ * @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
170
+ * @throws Error - réponse inintelligible, hors gabarit, serveur injoignable, ou
171
+ * paramètre supplémentaire empiétant sur le protocole.
172
+ */
173
+ validateAuthorizationCode(request: ITokenRequest): Promise<OAuth2Tokens>;
174
+ }