@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +7 -3
- package/dist/nodefony/command/security-secrets.js +7 -3
- package/dist/nodefony/command/security-token.js +7 -6
- package/dist/nodefony/command/security-user-add.js +5 -4
- 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 +6 -3
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/oauth2.js +111 -25
- package/dist/nodefony/service/tokenService.js +3 -3
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +3 -3
- package/dist/nodefony/service/webhooks.js +3 -3
- 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 +6 -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 +47 -7
- 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 +154 -70
- 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 {
|
|
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,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<{
|