@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
package/dist/index.js
CHANGED
|
@@ -44,6 +44,10 @@ import { Authorization } from "./nodefony/service/authorization.js";
|
|
|
44
44
|
import { MemoryWebAuthnCredentialStore } from "./nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js";
|
|
45
45
|
import { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore } from "./nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
46
46
|
import { WebAuthnService } from "./nodefony/service/webAuthn.js";
|
|
47
|
+
import { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState } from "./nodefony/src/oauth/oauth2Client.js";
|
|
48
|
+
import { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
|
|
49
|
+
import { createDiscoveredOidcProvider, createOidcProvider } from "./nodefony/src/oauth/providers/oidc.js";
|
|
50
|
+
import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
|
|
47
51
|
import { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider } from "./nodefony/src/oauth/oauthProviderRegistry.js";
|
|
48
52
|
import { OAuth2Service } from "./nodefony/service/oauth2.js";
|
|
49
53
|
import { TOKEN_FACETS } from "./nodefony/src/token/tokenFilters.js";
|
|
@@ -148,4 +152,4 @@ Security = __decorate([services([
|
|
|
148
152
|
]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Security);
|
|
149
153
|
var security_default = Security;
|
|
150
154
|
//#endregion
|
|
151
|
-
export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InvalidTargetError, JwtAuthenticator, JwtKeystore, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Service, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, disableTotp, encryptSecret, generateApiKey, generateEphemeralKey, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, resolveJwtRuntime, securityConfigJsonSchema, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
|
|
155
|
+
export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InvalidTargetError, JwtAuthenticator, JwtKeystore, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, resolveJwtRuntime, securityConfigJsonSchema, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { messageNonRestreint, modeNonRestreint, readIfPresentSync, writeSecretSync } from "../src/token/secretFile.js";
|
|
2
|
-
import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
|
|
2
|
+
import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
//#region nodefony/command/security-token.ts
|
|
7
7
|
const options = {
|
|
8
|
+
runProfile: CONSOLE_DATA_RUN_PROFILE,
|
|
8
9
|
helpGroup: "COMPTES ET SECRETS",
|
|
9
10
|
showBanner: false,
|
|
10
11
|
kernelEvent: "onReady",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Command, askPasswordMasked } from "nodefony";
|
|
1
|
+
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
2
|
//#region nodefony/command/security-user-add.ts
|
|
3
3
|
const options = {
|
|
4
|
+
runProfile: CONSOLE_DATA_RUN_PROFILE,
|
|
4
5
|
helpGroup: "COMPTES ET SECRETS",
|
|
5
6
|
showBanner: false,
|
|
6
7
|
kernelEvent: "onPostReady"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Command } from "nodefony";
|
|
1
|
+
import { CONSOLE_DATA_RUN_PROFILE, Command } from "nodefony";
|
|
2
2
|
//#region nodefony/command/security-user-delete.ts
|
|
3
3
|
const options = {
|
|
4
|
+
runProfile: CONSOLE_DATA_RUN_PROFILE,
|
|
4
5
|
helpGroup: "COMPTES ET SECRETS",
|
|
5
6
|
showBanner: false,
|
|
6
7
|
kernelEvent: "onPostReady",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Command } from "nodefony";
|
|
1
|
+
import { CONSOLE_DATA_RUN_PROFILE, Command } from "nodefony";
|
|
2
2
|
//#region nodefony/command/security-user-list.ts
|
|
3
3
|
const options = {
|
|
4
|
+
runProfile: CONSOLE_DATA_RUN_PROFILE,
|
|
4
5
|
helpGroup: "COMPTES ET SECRETS",
|
|
5
6
|
showBanner: false,
|
|
6
7
|
kernelEvent: "onReady",
|
|
@@ -303,6 +303,7 @@ const studioSchema = z.strictObject({
|
|
|
303
303
|
const oauthProviderSchema = z.strictObject({
|
|
304
304
|
clientId: z.string().min(1).describe("Identifiant client OAuth délivré par le fournisseur."),
|
|
305
305
|
clientSecret: z.string().min(1).describe("Secret client OAuth — SECRET, fourni par env, jamais loggé."),
|
|
306
|
+
clientAuthMethod: z.enum(["client_secret_basic", "client_secret_post"]).optional().describe("Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). OMIS = `client_secret_basic`, ce que la RFC demande de préférer et ce que la plupart des serveurs annoncent par défaut. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans ses métadonnées (`token_endpoint_auth_methods_supported`). La méthode `none` (client public) n'est pas offerte ici : ce schéma exige un secret non vide."),
|
|
306
307
|
redirectUri: z.string().min(1).describe("URL de callback EXACTE (RFC 9700 : exact string matching) enregistrée chez le fournisseur — doit pointer sur .../oauth2/<provider>/callback."),
|
|
307
308
|
issuer: z.string().optional().describe("Émetteur OIDC self-hosted (Keycloak : URL du realm, ex. https://kc.example/realms/app). REQUIS pour keycloak ; ignoré par les fournisseurs à endpoints fixes (Google/GitHub)."),
|
|
308
309
|
scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
|
|
@@ -316,8 +317,8 @@ const oauth2Schema = z.strictObject({
|
|
|
316
317
|
allowSignup: z.boolean().default(true).describe("true = crée une ligne locale au 1er login (JIT, Shadow User). false = un compte préexistant lié est requis (fail-closed)."),
|
|
317
318
|
successRedirect: z.string().default("/").describe("Redirection après login réussi."),
|
|
318
319
|
failureRedirect: z.string().default("/login").describe("Redirection après échec (state invalide, refus utilisateur...)."),
|
|
319
|
-
providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/github ;
|
|
320
|
-
}).describe("Social login OAuth 2.0
|
|
320
|
+
providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/oidc/github ; tout autre serveur OpenID Connect en enregistrant une fabrique, décrit par son seul émetteur).")
|
|
321
|
+
}).describe("Social login OAuth 2.0 — Authorization Code + PKCE, session BFF.");
|
|
321
322
|
/**
|
|
322
323
|
* Règle d'autorisation d'un **namespace de canaux WebSocket** (subscribe/inbound)
|
|
323
324
|
* par préfixe. Contraintes cumulatives ; un champ absent = pas de contrainte sur
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
|
+
import { generateCodeVerifier, generateState } from "../src/oauth/oauth2Client.js";
|
|
3
4
|
import { getOAuthProviderFactory, listOAuthProviders } from "../src/oauth/oauthProviderRegistry.js";
|
|
4
5
|
import { Service } from "nodefony";
|
|
5
6
|
//#region nodefony/service/oauth2.ts
|
|
6
7
|
const serviceName = "oauth2";
|
|
7
8
|
/**
|
|
8
|
-
* **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code
|
|
9
|
-
* au-dessus d'`arctic`.
|
|
9
|
+
* **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
|
|
10
10
|
*
|
|
11
11
|
* Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
|
|
12
12
|
* ROPC), **PKCE S256** quand le fournisseur le supporte (RFC 7636), **state**
|
|
@@ -14,10 +14,11 @@ const serviceName = "oauth2";
|
|
|
14
14
|
* (le login produit une **session BFF**, gérée hors de ce service par le
|
|
15
15
|
* controller + `AuthFlow`).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Les fournisseurs sont construits **au premier login** (cold path — jamais au boot
|
|
18
|
+
* ni par requête) puis mémoïsés : c'est là que les points d'entrée d'un émetteur
|
|
19
|
+
* OIDC sont découverts, une seule fois par processus. Au boot (si `oauth2.enabled`)
|
|
20
|
+
* : seule la config est validée et les fournisseurs configurés sont confrontés au
|
|
21
|
+
* registre (un nom inconnu = WARNING, pas fatal).
|
|
21
22
|
*
|
|
22
23
|
* Le service ne touche **ni HTTP ni session** : il rend à l'appelant les éléments
|
|
23
24
|
* (URL, state, verifier) que le controller persiste en session — testable sans
|
|
@@ -26,7 +27,6 @@ const serviceName = "oauth2";
|
|
|
26
27
|
var OAuth2Service = class extends Service {
|
|
27
28
|
module;
|
|
28
29
|
#config = null;
|
|
29
|
-
#lib = null;
|
|
30
30
|
#providers = null;
|
|
31
31
|
#ready = false;
|
|
32
32
|
constructor(module) {
|
|
@@ -83,11 +83,14 @@ var OAuth2Service = class extends Service {
|
|
|
83
83
|
*/
|
|
84
84
|
async createAuthorization(provider) {
|
|
85
85
|
const resolved = await this.#resolveProvider(provider);
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const codeVerifier = resolved.provider.usesPkce ? lib.generateCodeVerifier() : null;
|
|
86
|
+
const state = generateState();
|
|
87
|
+
const codeVerifier = resolved.provider.usesPkce ? generateCodeVerifier() : null;
|
|
89
88
|
return {
|
|
90
|
-
url: resolved.provider.createAuthorizationURL(
|
|
89
|
+
url: resolved.provider.createAuthorizationURL({
|
|
90
|
+
state,
|
|
91
|
+
codeVerifier,
|
|
92
|
+
scopes: resolved.scopes
|
|
93
|
+
}).toString(),
|
|
91
94
|
state,
|
|
92
95
|
codeVerifier
|
|
93
96
|
};
|
|
@@ -102,10 +105,15 @@ var OAuth2Service = class extends Service {
|
|
|
102
105
|
*/
|
|
103
106
|
async exchangeAndProvision(provider, code, codeVerifier, returnedIss) {
|
|
104
107
|
const { provider: p } = await this.#resolveProvider(provider);
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
const policy = p.issuerPolicy;
|
|
109
|
+
if (policy !== null) {
|
|
110
|
+
if (returnedIss !== null && returnedIss !== policy.issuer) throw new AuthenticationError("OAuth issuer mismatch");
|
|
111
|
+
if (returnedIss === null && policy.requireIssParameter) throw new AuthenticationError("OAuth issuer missing");
|
|
107
112
|
}
|
|
108
|
-
const tokens = await p.validateAuthorizationCode(
|
|
113
|
+
const tokens = await p.validateAuthorizationCode({
|
|
114
|
+
code,
|
|
115
|
+
codeVerifier
|
|
116
|
+
});
|
|
109
117
|
const profile = await p.fetchProfile(tokens);
|
|
110
118
|
const cfg = this.#config.oauth2;
|
|
111
119
|
const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
|
|
@@ -114,37 +122,38 @@ var OAuth2Service = class extends Service {
|
|
|
114
122
|
allowSignup: cfg.allowSignup
|
|
115
123
|
})).identifier };
|
|
116
124
|
}
|
|
117
|
-
|
|
125
|
+
#resolveProvider(name) {
|
|
118
126
|
this.#ensureReady();
|
|
119
127
|
this.#providers ??= /* @__PURE__ */ new Map();
|
|
120
128
|
const cached = this.#providers.get(name);
|
|
121
129
|
if (cached) return cached;
|
|
130
|
+
const pending = this.#buildProvider(name);
|
|
131
|
+
this.#providers.set(name, pending);
|
|
132
|
+
pending.catch(() => this.#providers?.delete(name));
|
|
133
|
+
return pending;
|
|
134
|
+
}
|
|
135
|
+
async #buildProvider(name) {
|
|
122
136
|
const cfg = this.#config.oauth2.providers[name];
|
|
123
137
|
if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
|
|
124
138
|
const factory = getOAuthProviderFactory(name);
|
|
125
139
|
if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
|
|
126
|
-
const provider = factory({
|
|
127
|
-
arctic: await this.#ensureLib(),
|
|
140
|
+
const provider = await factory({
|
|
128
141
|
clientId: cfg.clientId,
|
|
129
142
|
clientSecret: cfg.clientSecret,
|
|
143
|
+
clientAuthMethod: cfg.clientAuthMethod,
|
|
130
144
|
redirectUri: cfg.redirectUri,
|
|
131
145
|
issuer: cfg.issuer
|
|
132
146
|
});
|
|
133
|
-
|
|
147
|
+
return {
|
|
134
148
|
provider,
|
|
135
149
|
scopes: cfg.scopes.length > 0 ? cfg.scopes : provider.defaultScopes
|
|
136
150
|
};
|
|
137
|
-
this.#providers.set(name, resolved);
|
|
138
|
-
return resolved;
|
|
139
151
|
}
|
|
140
152
|
#resolveProvisioner() {
|
|
141
153
|
const users = this.get("users");
|
|
142
154
|
if (!users || typeof users.provisionOAuthUser !== "function") throw new AuthenticationError("OAuth provisioning indisponible (le service users n'implémente pas IOAuthUserProvisioner)");
|
|
143
155
|
return users;
|
|
144
156
|
}
|
|
145
|
-
async #ensureLib() {
|
|
146
|
-
return this.#lib ??= await import("arctic");
|
|
147
|
-
}
|
|
148
157
|
#ensureReady() {
|
|
149
158
|
if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
|
|
150
159
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region nodefony/src/oauth/httpJson.ts
|
|
2
|
+
/**
|
|
3
|
+
* Lecture BORNÉE d'un corps JSON — la brique de transport commune aux trois
|
|
4
|
+
* appels sortants du social login (découverte, point de jeton, API d'un
|
|
5
|
+
* fournisseur non-OIDC).
|
|
6
|
+
*
|
|
7
|
+
* Elle existe parce qu'une borne posée APRÈS `response.text()` ne protège plus
|
|
8
|
+
* rien : le corps est déjà entièrement en mémoire quand on mesure sa longueur.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Lit un corps JSON en refusant de dépasser une taille — la borne est vérifiée
|
|
12
|
+
* PENDANT la lecture, pas après : un corps déjà entièrement en mémoire ne se
|
|
13
|
+
* refuse plus.
|
|
14
|
+
*
|
|
15
|
+
* @param response - réponse dont le corps reste à lire.
|
|
16
|
+
* @param maxBytes - plafond, en octets réels du flux.
|
|
17
|
+
* @param subject - ce qu'on lisait, pour que l'erreur soit exploitable.
|
|
18
|
+
* @returns la valeur JSON telle quelle — un objet OU un tableau (l'API d'un
|
|
19
|
+
* fournisseur rend les deux ; c'est à l'appelant d'exiger la forme qu'il attend).
|
|
20
|
+
* @throws Error - corps trop gros ou illisible.
|
|
21
|
+
*/
|
|
22
|
+
async function readJsonBounded(response, maxBytes, subject) {
|
|
23
|
+
const announced = response.headers.get("content-length");
|
|
24
|
+
if (announced !== null && Number(announced) > maxBytes) throw new Error(`${subject} → corps hors gabarit (${announced} octets)`);
|
|
25
|
+
const body = response.body;
|
|
26
|
+
let text;
|
|
27
|
+
if (body === null) {
|
|
28
|
+
text = await response.text();
|
|
29
|
+
if (text.length > maxBytes) throw new Error(`${subject} → corps hors gabarit`);
|
|
30
|
+
} else {
|
|
31
|
+
const reader = body.getReader();
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let seen = 0;
|
|
34
|
+
for (;;) {
|
|
35
|
+
const { done, value } = await reader.read();
|
|
36
|
+
if (done) break;
|
|
37
|
+
seen += value.byteLength;
|
|
38
|
+
if (seen > maxBytes) {
|
|
39
|
+
await reader.cancel();
|
|
40
|
+
throw new Error(`${subject} → corps hors gabarit (> ${maxBytes} octets)`);
|
|
41
|
+
}
|
|
42
|
+
chunks.push(value);
|
|
43
|
+
}
|
|
44
|
+
text = Buffer.concat(chunks).toString("utf8");
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`${subject} → corps illisible (JSON attendu)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Comme {@link readJsonBounded}, mais exige un OBJET — la forme de toute réponse
|
|
54
|
+
* normalisée par une RFC (document de métadonnées, réponse d'un point de jeton).
|
|
55
|
+
*
|
|
56
|
+
* @throws Error - corps trop gros, illisible, ou qui n'est pas un objet JSON.
|
|
57
|
+
*/
|
|
58
|
+
async function readJsonObjectBounded(response, maxBytes, subject) {
|
|
59
|
+
const payload = await readJsonBounded(response, maxBytes, subject);
|
|
60
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error(`${subject} → corps inattendu (objet JSON attendu)`);
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { readJsonBounded, readJsonObjectBounded };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
|
+
import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
|
|
3
|
+
//#region nodefony/src/oauth/metadata.ts
|
|
4
|
+
/**
|
|
5
|
+
* **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
|
|
6
|
+
* CLIENTE de la règle que le cœur porte déjà.
|
|
7
|
+
*
|
|
8
|
+
* C'est ce qui remplace, à soi seul, une classe par fournisseur : les points
|
|
9
|
+
* d'entrée ne sont plus écrits en dur, ils sont demandés à l'émetteur. Ajouter un
|
|
10
|
+
* fournisseur OIDC (Microsoft Entra, Auth0, Okta, Authentik...) ne demande donc
|
|
11
|
+
* PLUS de code : son seul émetteur suffit.
|
|
12
|
+
*
|
|
13
|
+
* @remarks **Ce module ne réimplémente RIEN de la RFC 8414.** La normalisation de
|
|
14
|
+
* l'émetteur (`canonicalIssuer`), l'ordre normatif des URL bien connues
|
|
15
|
+
* (`issuerMetadataUrls`) et l'égalité stricte du §3.3 (`validateIssuerMetadata`)
|
|
16
|
+
* vivent dans `nodefony` — la même implémentation sert à PUBLIER nos métadonnées
|
|
17
|
+
* et à LIRE celles d'autrui, sans quoi les deux faces divergeraient en silence.
|
|
18
|
+
* Il n'ajoute que le transport : requête bornée, et lecture des deux points
|
|
19
|
+
* d'entrée dont le flux *Authorization Code* a besoin.
|
|
20
|
+
*/
|
|
21
|
+
/** Un émetteur muet ne doit pas retenir la requête de login. */
|
|
22
|
+
const DISCOVERY_TIMEOUT_MS = 1e4;
|
|
23
|
+
/** Au-delà, le document n'est plus un document de métadonnées — on refuse de lire. */
|
|
24
|
+
const MAX_METADATA_BYTES = 1048576;
|
|
25
|
+
async function fetchMetadataDocument(url, options) {
|
|
26
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
27
|
+
headers: {
|
|
28
|
+
Accept: "application/json",
|
|
29
|
+
"User-Agent": "nodefony"
|
|
30
|
+
},
|
|
31
|
+
redirect: "error",
|
|
32
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS)
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
|
|
35
|
+
return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
|
|
36
|
+
}
|
|
37
|
+
function requireEndpoint(document, field, issuer) {
|
|
38
|
+
const value = document[field];
|
|
39
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(value);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
|
|
45
|
+
}
|
|
46
|
+
if (url.protocol !== "https:") throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https.`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Interroge un serveur d'autorisation et rend ses points d'entrée.
|
|
51
|
+
*
|
|
52
|
+
* Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
|
|
53
|
+
* RFC 8414 §3.1 : insertion oauth → insertion oidc → ajout oidc), et la réponse
|
|
54
|
+
* est CONFRONTÉE à l'émetteur demandé par `validateIssuerMetadata` (§3.3). C'est
|
|
55
|
+
* cette garde qui empêche un émetteur détourné d'imposer ses propres points
|
|
56
|
+
* d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
|
|
57
|
+
*
|
|
58
|
+
* @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
|
|
59
|
+
* @param options - transport injectable et délai d'attente.
|
|
60
|
+
* @returns Les points d'entrée, prêts pour `OAuth2Client`.
|
|
61
|
+
* @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
|
|
62
|
+
*/
|
|
63
|
+
async function discoverAuthorizationServer(rawIssuer, options = {}) {
|
|
64
|
+
const issuer = canonicalIssuer(rawIssuer);
|
|
65
|
+
const failures = [];
|
|
66
|
+
for (const candidate of issuerMetadataUrls(issuer)) {
|
|
67
|
+
let document;
|
|
68
|
+
try {
|
|
69
|
+
document = await fetchMetadataDocument(candidate, options);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const identity = validateIssuerMetadata(document, issuer);
|
|
75
|
+
const methods = document.code_challenge_methods_supported;
|
|
76
|
+
return {
|
|
77
|
+
issuer: identity.issuer,
|
|
78
|
+
jwksUri: identity.jwksUri,
|
|
79
|
+
authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
|
|
80
|
+
tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
|
|
81
|
+
codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
|
|
82
|
+
issParameterSupported: document.authorization_response_iss_parameter_supported === true
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { discoverAuthorizationServer };
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
//#region nodefony/src/oauth/oauth2Client.ts
|
|
4
|
+
/**
|
|
5
|
+
* Client **OAuth 2.0 / Authorization Code** minimal — la face cliente du protocole
|
|
6
|
+
* dont Nodefony écrit déjà la face serveur (émetteur de jetons, métadonnées,
|
|
7
|
+
* ressource protégée). Aucune dépendance : `node:crypto` pour l'entropie, `fetch`
|
|
8
|
+
* pour l'échange.
|
|
9
|
+
*
|
|
10
|
+
* Posture OAuth 2.1 (RFC 9700) : Authorization Code seul, **PKCE S256** (RFC 7636)
|
|
11
|
+
* quand le fournisseur le supporte, `state` anti-CSRF, jamais d'implicit ni de ROPC.
|
|
12
|
+
*
|
|
13
|
+
* @remarks Le flux vit sur un chemin FROID (un login humain) : les quelques
|
|
14
|
+
* allocations et l'unique requête sortante n'entrent dans aucun chemin de requête.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Entropie tirée pour `state` et `code_verifier` : 32 octets, soit 43 caractères
|
|
18
|
+
* en base64url — exactement la borne basse du `code_verifier` (RFC 7636 §4.1),
|
|
19
|
+
* dont l'alphabet est inclus dans les caractères `unreserved` exigés.
|
|
20
|
+
*/
|
|
21
|
+
const ENTROPY_BYTES = 32;
|
|
22
|
+
/** Au-delà, la réponse d'un point de jeton n'est plus plausible — on refuse d'analyser. */
|
|
23
|
+
const MAX_TOKEN_RESPONSE_BYTES = 1048576;
|
|
24
|
+
/** Un serveur d'autorisation muet ne doit pas retenir la requête de login. */
|
|
25
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 1e4;
|
|
26
|
+
/**
|
|
27
|
+
* Tire un `state` anti-CSRF (RFC 6749 §10.12, RFC 9700 §4.7) — 256 bits issus du
|
|
28
|
+
* générateur cryptographique du système.
|
|
29
|
+
*/
|
|
30
|
+
function generateState() {
|
|
31
|
+
return randomBytes(ENTROPY_BYTES).toString("base64url");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Tire un `code_verifier` PKCE (RFC 7636 §4.1) — 43 caractères de l'alphabet
|
|
35
|
+
* `unreserved`, porteurs de 256 bits d'entropie.
|
|
36
|
+
*/
|
|
37
|
+
function generateCodeVerifier() {
|
|
38
|
+
return randomBytes(ENTROPY_BYTES).toString("base64url");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Calcule le `code_challenge` de la méthode **S256** (RFC 7636 §4.2) :
|
|
42
|
+
* `BASE64URL(SHA256(ASCII(code_verifier)))`.
|
|
43
|
+
*
|
|
44
|
+
* @remarks La méthode `plain` n'est jamais proposée — OAuth 2.1 et la RFC 9700
|
|
45
|
+
* §2.1.1 l'excluent : elle ne protège pas d'un code intercepté.
|
|
46
|
+
*/
|
|
47
|
+
function createCodeChallenge(codeVerifier) {
|
|
48
|
+
assertCodeVerifier(codeVerifier);
|
|
49
|
+
return createHash("sha256").update(codeVerifier, "ascii").digest("base64url");
|
|
50
|
+
}
|
|
51
|
+
/** Grammaire d'un `code_verifier` : 43 à 128 caractères `unreserved` (RFC 7636 §4.1). */
|
|
52
|
+
const CODE_VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
53
|
+
/**
|
|
54
|
+
* Refuse un `code_verifier` hors grammaire AVANT de s'en servir.
|
|
55
|
+
*
|
|
56
|
+
* @remarks Sans cette garde, un appelant qui fournit son propre secret (le
|
|
57
|
+
* contrat l'autorise) obtiendrait un défi calculé sur une valeur que le serveur
|
|
58
|
+
* d'autorisation rejettera plus tard : l'erreur sortirait au retour, sous la
|
|
59
|
+
* forme d'un `invalid_grant` que rien ne relie à sa cause.
|
|
60
|
+
*
|
|
61
|
+
* @throws Error - la valeur ne respecte pas la grammaire de la RFC 7636 §4.1.
|
|
62
|
+
*/
|
|
63
|
+
function assertCodeVerifier(codeVerifier) {
|
|
64
|
+
if (!CODE_VERIFIER.test(codeVerifier)) throw new Error("code_verifier invalide : 43 à 128 caractères parmi [A-Za-z0-9-._~] (RFC 7636 §4.1).");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Encode une valeur selon `application/x-www-form-urlencoded`, la forme qu'exige
|
|
68
|
+
* l'authentification cliente HTTP Basic (RFC 6749 §2.3.1).
|
|
69
|
+
*/
|
|
70
|
+
function formUrlencode(value) {
|
|
71
|
+
return new URLSearchParams([["", value]]).toString().slice(1);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Refus du serveur d'autorisation au point de jeton (RFC 6749 §5.2). Porte le
|
|
75
|
+
* code `error` normalisé — la seule partie de la réponse sûre à journaliser.
|
|
76
|
+
*/
|
|
77
|
+
var OAuth2RequestError = class extends Error {
|
|
78
|
+
/** Code normalisé (`invalid_grant`, `invalid_client`, ...) — RFC 6749 §5.2. */
|
|
79
|
+
code;
|
|
80
|
+
/** Description lisible fournie par le serveur, ou `null`. */
|
|
81
|
+
description;
|
|
82
|
+
constructor(code, description) {
|
|
83
|
+
super(description === null ? code : `${code}: ${description}`);
|
|
84
|
+
this.name = "OAuth2RequestError";
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.description = description;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Jetons rendus par le point de jeton (RFC 6749 §5.1), enveloppés pour qu'un champ
|
|
91
|
+
* attendu mais absent lève une erreur NOMMÉE au lieu de propager un `undefined`
|
|
92
|
+
* jusqu'au décodage du profil.
|
|
93
|
+
*/
|
|
94
|
+
var OAuth2Tokens = class {
|
|
95
|
+
/** Corps JSON brut de la réponse — donne accès aux extensions du fournisseur. */
|
|
96
|
+
data;
|
|
97
|
+
constructor(data) {
|
|
98
|
+
this.data = data;
|
|
99
|
+
}
|
|
100
|
+
#requireString(field) {
|
|
101
|
+
const value = this.data[field];
|
|
102
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`Réponse du point de jeton sans champ « ${field} ».`);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
/** Jeton d'accès (RFC 6749 §5.1). */
|
|
106
|
+
accessToken() {
|
|
107
|
+
return this.#requireString("access_token");
|
|
108
|
+
}
|
|
109
|
+
/** Type du jeton d'accès — `Bearer` en pratique (RFC 6750). */
|
|
110
|
+
tokenType() {
|
|
111
|
+
return this.#requireString("token_type");
|
|
112
|
+
}
|
|
113
|
+
/** Jeton d'identité OIDC (OpenID Connect Core §3.1.3.3). */
|
|
114
|
+
idToken() {
|
|
115
|
+
return this.#requireString("id_token");
|
|
116
|
+
}
|
|
117
|
+
/** `true` si le serveur a émis un jeton de rafraîchissement. */
|
|
118
|
+
hasRefreshToken() {
|
|
119
|
+
return typeof this.data.refresh_token === "string";
|
|
120
|
+
}
|
|
121
|
+
/** Jeton de rafraîchissement (RFC 6749 §1.5). */
|
|
122
|
+
refreshToken() {
|
|
123
|
+
return this.#requireString("refresh_token");
|
|
124
|
+
}
|
|
125
|
+
/** Durée de vie restante du jeton d'accès, en secondes. */
|
|
126
|
+
accessTokenExpiresInSeconds() {
|
|
127
|
+
const value = this.data.expires_in;
|
|
128
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Réponse du point de jeton sans champ « expires_in ».");
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
/** Instant d'expiration du jeton d'accès, dérivé de `expires_in`. */
|
|
132
|
+
accessTokenExpiresAt() {
|
|
133
|
+
return new Date(Date.now() + this.accessTokenExpiresInSeconds() * 1e3);
|
|
134
|
+
}
|
|
135
|
+
/** `true` si le serveur a annoncé les portées effectivement accordées. */
|
|
136
|
+
hasScopes() {
|
|
137
|
+
return typeof this.data.scope === "string";
|
|
138
|
+
}
|
|
139
|
+
/** Portées accordées, telles que le serveur les a annoncées (RFC 6749 §3.3). */
|
|
140
|
+
scopes() {
|
|
141
|
+
return this.#requireString("scope").split(" ");
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Les paramètres que le protocole POSE lui-même, et qu'un appelant ne peut donc
|
|
146
|
+
* pas fournir en supplément.
|
|
147
|
+
*
|
|
148
|
+
* Sans cette garde, `additionalParameters` deviendrait une porte pour réécrire
|
|
149
|
+
* `client_id` ou `redirect_uri` — c'est-à-dire pour faire émettre par ce client
|
|
150
|
+
* une requête qui ne le désigne plus. Le refus est explicite et nomme la clé :
|
|
151
|
+
* un paramètre silencieusement ignoré serait pire, l'appelant croirait l'avoir
|
|
152
|
+
* envoyé.
|
|
153
|
+
*/
|
|
154
|
+
const RESERVED_PARAMETERS = /* @__PURE__ */ new Set([
|
|
155
|
+
"response_type",
|
|
156
|
+
"client_id",
|
|
157
|
+
"client_secret",
|
|
158
|
+
"redirect_uri",
|
|
159
|
+
"state",
|
|
160
|
+
"scope",
|
|
161
|
+
"code",
|
|
162
|
+
"code_verifier",
|
|
163
|
+
"code_challenge",
|
|
164
|
+
"code_challenge_method",
|
|
165
|
+
"grant_type"
|
|
166
|
+
]);
|
|
167
|
+
/**
|
|
168
|
+
* Verse des paramètres supplémentaires sans jamais recouvrir ceux du protocole.
|
|
169
|
+
*
|
|
170
|
+
* @param target - la collection en construction (requête ou corps).
|
|
171
|
+
* @param extra - ce que l'appelant ajoute, tel quel.
|
|
172
|
+
* @throws Error - une clé réservée au protocole a été fournie.
|
|
173
|
+
*/
|
|
174
|
+
function applyAdditionalParameters(target, extra) {
|
|
175
|
+
if (extra === void 0) return;
|
|
176
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
177
|
+
if (RESERVED_PARAMETERS.has(key)) throw new Error(`Paramètre « ${key} » réservé au protocole : il est posé par le client, pas par l'appelant.`);
|
|
178
|
+
target.set(key, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
|
|
183
|
+
* échange le code contre des jetons.
|
|
184
|
+
*
|
|
185
|
+
* Un exemplaire porte les endpoints DÉJÀ résolus — par découverte de métadonnées
|
|
186
|
+
* ({@link discoverAuthorizationServer}) ou en dur pour un fournisseur qui n'en
|
|
187
|
+
* publie pas (GitHub).
|
|
188
|
+
*/
|
|
189
|
+
var OAuth2Client = class {
|
|
190
|
+
#options;
|
|
191
|
+
constructor(options) {
|
|
192
|
+
this.#options = options;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Construit l'URL d'autorisation (RFC 6749 §4.1.1). Le `code_challenge` S256 est
|
|
196
|
+
* ajouté dès qu'un `codeVerifier` est fourni.
|
|
197
|
+
*
|
|
198
|
+
* @param request - ce qu'on demande au point d'autorisation.
|
|
199
|
+
* @returns l'URL vers laquelle rediriger l'utilisateur.
|
|
200
|
+
* @throws Error - un paramètre supplémentaire empiète sur le protocole.
|
|
201
|
+
*/
|
|
202
|
+
createAuthorizationURL(request) {
|
|
203
|
+
const url = new URL(this.#options.authorizationEndpoint);
|
|
204
|
+
applyAdditionalParameters(url.searchParams, request.additionalParameters);
|
|
205
|
+
url.searchParams.set("response_type", "code");
|
|
206
|
+
url.searchParams.set("client_id", this.#options.clientId);
|
|
207
|
+
url.searchParams.set("redirect_uri", this.#options.redirectUri);
|
|
208
|
+
url.searchParams.set("state", request.state);
|
|
209
|
+
if (request.scopes.length > 0) url.searchParams.set("scope", request.scopes.join(" "));
|
|
210
|
+
if (request.codeVerifier !== null) {
|
|
211
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
212
|
+
url.searchParams.set("code_challenge", createCodeChallenge(request.codeVerifier));
|
|
213
|
+
}
|
|
214
|
+
return url;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Échange le code d'autorisation contre des jetons (RFC 6749 §4.1.3), de serveur
|
|
218
|
+
* à serveur — le secret client ne quitte jamais ce canal.
|
|
219
|
+
*
|
|
220
|
+
* @param request - ce qu'on présente au point de jeton.
|
|
221
|
+
* @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
|
|
222
|
+
* @throws Error - réponse inintelligible, hors gabarit, serveur injoignable, ou
|
|
223
|
+
* paramètre supplémentaire empiétant sur le protocole.
|
|
224
|
+
*/
|
|
225
|
+
async validateAuthorizationCode(request) {
|
|
226
|
+
const body = new URLSearchParams();
|
|
227
|
+
applyAdditionalParameters(body, request.additionalParameters);
|
|
228
|
+
body.set("grant_type", "authorization_code");
|
|
229
|
+
body.set("code", request.code);
|
|
230
|
+
body.set("redirect_uri", this.#options.redirectUri);
|
|
231
|
+
if (request.codeVerifier !== null) body.set("code_verifier", request.codeVerifier);
|
|
232
|
+
const headers = {
|
|
233
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
234
|
+
Accept: "application/json",
|
|
235
|
+
"User-Agent": "nodefony"
|
|
236
|
+
};
|
|
237
|
+
switch (this.#options.clientAuthMethod) {
|
|
238
|
+
case "client_secret_basic":
|
|
239
|
+
headers.Authorization = `Basic ${this.#basicCredentials()}`;
|
|
240
|
+
break;
|
|
241
|
+
case "client_secret_post":
|
|
242
|
+
body.set("client_id", this.#options.clientId);
|
|
243
|
+
body.set("client_secret", this.#options.clientSecret);
|
|
244
|
+
break;
|
|
245
|
+
case "none": body.set("client_id", this.#options.clientId);
|
|
246
|
+
}
|
|
247
|
+
const response = await (this.#options.fetch ?? globalThis.fetch)(this.#options.tokenEndpoint, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers,
|
|
250
|
+
body: body.toString(),
|
|
251
|
+
redirect: "error",
|
|
252
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
|
|
253
|
+
});
|
|
254
|
+
const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
|
|
255
|
+
if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null);
|
|
256
|
+
if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
|
|
257
|
+
return new OAuth2Tokens(data);
|
|
258
|
+
}
|
|
259
|
+
#basicCredentials() {
|
|
260
|
+
const id = formUrlencode(this.#options.clientId);
|
|
261
|
+
const secret = formUrlencode(this.#options.clientSecret);
|
|
262
|
+
return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
//#endregion
|
|
266
|
+
export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState };
|