@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 +5 -1
- package/dist/nodefony/command/security-token.js +2 -1
- package/dist/nodefony/command/security-user-add.js +2 -1
- package/dist/nodefony/command/security-user-delete.js +2 -1
- package/dist/nodefony/command/security-user-list.js +2 -1
- package/dist/nodefony/config/config.js +3 -2
- package/dist/nodefony/service/oauth2.js +32 -23
- package/dist/nodefony/src/oauth/httpJson.js +64 -0
- package/dist/nodefony/src/oauth/metadata.js +88 -0
- package/dist/nodefony/src/oauth/oauth2Client.js +266 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +5 -16
- package/dist/nodefony/src/oauth/providers/github.js +34 -9
- package/dist/nodefony/src/oauth/providers/oidc.js +87 -13
- package/dist/types/index.d.ts +8 -1
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +45 -14
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +1 -1
- package/dist/types/nodefony/service/oauth2.d.ts +6 -6
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +28 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +174 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +34 -17
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +1 -1
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +40 -15
- package/docs/oauth2.md +128 -66
- package/package.json +9 -10
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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, {
|
|
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
|
|
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
|
-
* (`
|
|
34
|
+
* (`issuerPolicy = null`) : la défense anti-CSRF repose sur le `state`.
|
|
23
35
|
*/
|
|
24
36
|
function createGithubProvider(ctx) {
|
|
25
|
-
const client = new
|
|
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
|
-
|
|
47
|
+
issuerPolicy: null,
|
|
29
48
|
defaultScopes: DEFAULT_SCOPES,
|
|
30
|
-
createAuthorizationURL(
|
|
31
|
-
return client.createAuthorizationURL(
|
|
49
|
+
createAuthorizationURL(request) {
|
|
50
|
+
return client.createAuthorizationURL({
|
|
51
|
+
...request,
|
|
52
|
+
codeVerifier: null
|
|
53
|
+
});
|
|
32
54
|
},
|
|
33
|
-
validateAuthorizationCode(
|
|
34
|
-
return client.validateAuthorizationCode(
|
|
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).
|
|
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
|
|
15
|
-
*
|
|
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
|
-
|
|
54
|
+
issuerPolicy: {
|
|
55
|
+
issuer: opts.issuer,
|
|
56
|
+
requireIssParameter: opts.issParameterSupported === true
|
|
57
|
+
},
|
|
25
58
|
defaultScopes: opts.defaultScopes ?? DEFAULT_OIDC_SCOPES,
|
|
26
|
-
createAuthorizationURL(
|
|
27
|
-
return opts.client.createAuthorizationURL(
|
|
59
|
+
createAuthorizationURL(request) {
|
|
60
|
+
return opts.client.createAuthorizationURL({
|
|
61
|
+
...request,
|
|
62
|
+
codeVerifier: requireVerifier(request.codeVerifier)
|
|
63
|
+
});
|
|
28
64
|
},
|
|
29
|
-
validateAuthorizationCode(
|
|
30
|
-
return opts.client.validateAuthorizationCode(
|
|
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 };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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 "
|
|
1
|
+
import type { IAuthorizationRequest, ITokenRequest, OAuth2Tokens } from "../src/oauth/oauth2Client.js";
|
|
2
2
|
import type { IOAuthProfile } from "@nodefony/user";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
28
|
-
* `null`
|
|
29
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
+
}
|