@fluixi/oauth2 1.0.0-alpha.1
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/LICENSE +21 -0
- package/dist/api-guard.cjs +185 -0
- package/dist/api-guard.d.ts +94 -0
- package/dist/api-guard.d.ts.map +1 -0
- package/dist/api-guard.js +127 -0
- package/dist/api-guard.mjs +141 -0
- package/dist/browser-FDUOEUM2.mjs +3544 -0
- package/dist/browser-ONMHX3Z3.mjs +3545 -0
- package/dist/browser.cjs +735 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +448 -0
- package/dist/browser.mjs +704 -0
- package/dist/callback.cjs +125 -0
- package/dist/callback.d.ts +113 -0
- package/dist/callback.d.ts.map +1 -0
- package/dist/callback.js +183 -0
- package/dist/callback.mjs +106 -0
- package/dist/chunk-25WE6UWI.mjs +10 -0
- package/dist/chunk-4NSOGPLM.mjs +15 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-OGSFNTKA.mjs +13 -0
- package/dist/client-auth.cjs +115 -0
- package/dist/client-auth.d.ts +43 -0
- package/dist/client-auth.d.ts.map +1 -0
- package/dist/client-auth.js +112 -0
- package/dist/client-auth.mjs +84 -0
- package/dist/discovery.cjs +86 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +51 -0
- package/dist/discovery.mjs +63 -0
- package/dist/dpop-LVPWWADA.mjs +80 -0
- package/dist/dpop-NS7NKOWM.mjs +79 -0
- package/dist/dpop-store.cjs +152 -0
- package/dist/dpop-store.d.ts +37 -0
- package/dist/dpop-store.d.ts.map +1 -0
- package/dist/dpop-store.js +119 -0
- package/dist/dpop-store.mjs +121 -0
- package/dist/dpop.cjs +117 -0
- package/dist/dpop.d.ts +72 -0
- package/dist/dpop.d.ts.map +1 -0
- package/dist/dpop.js +114 -0
- package/dist/dpop.mjs +86 -0
- package/dist/env-config.cjs +366 -0
- package/dist/env-config.d.ts +103 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +184 -0
- package/dist/env-config.mjs +343 -0
- package/dist/env-credentials.cjs +82 -0
- package/dist/env-credentials.d.ts +68 -0
- package/dist/env-credentials.d.ts.map +1 -0
- package/dist/env-credentials.js +76 -0
- package/dist/env-credentials.mjs +51 -0
- package/dist/index.cjs +940 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.mjs +909 -0
- package/dist/pkce.cjs +87 -0
- package/dist/pkce.d.ts +58 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +112 -0
- package/dist/pkce.mjs +66 -0
- package/dist/presets.cjs +240 -0
- package/dist/presets.d.ts +140 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +182 -0
- package/dist/presets.mjs +217 -0
- package/dist/return-to.cjs +46 -0
- package/dist/return-to.d.ts +49 -0
- package/dist/return-to.d.ts.map +1 -0
- package/dist/return-to.js +71 -0
- package/dist/return-to.mjs +25 -0
- package/dist/roles.cjs +56 -0
- package/dist/roles.d.ts +35 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +37 -0
- package/dist/roles.mjs +35 -0
- package/dist/server-client.cjs +134 -0
- package/dist/server-client.d.ts +12 -0
- package/dist/server-client.d.ts.map +1 -0
- package/dist/server-client.js +107 -0
- package/dist/server-client.mjs +111 -0
- package/dist/server.cjs +470 -0
- package/dist/server.d.ts +89 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +360 -0
- package/dist/server.mjs +441 -0
- package/dist/service-client.cjs +244 -0
- package/dist/service-client.d.ts +89 -0
- package/dist/service-client.d.ts.map +1 -0
- package/dist/service-client.js +112 -0
- package/dist/service-client.mjs +213 -0
- package/dist/service.cjs +903 -0
- package/dist/service.d.ts +96 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +129 -0
- package/dist/service.mjs +874 -0
- package/dist/tokens.cjs +29 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +21 -0
- package/dist/tokens.mjs +8 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/types.cjs +33 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.mjs +12 -0
- package/dist/verify.cjs +724 -0
- package/dist/verify.d.ts +205 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +371 -0
- package/dist/verify.mjs +590 -0
- package/package.json +121 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client_credentials`: a token for the service itself, with no user involved.
|
|
3
|
+
*
|
|
4
|
+
* The other flows in this package answer "who is using this app". This answers "which
|
|
5
|
+
* service is calling", which is the other half of most systems: a background job reading
|
|
6
|
+
* an API, one service talking to another, a webhook handler that needs to write back.
|
|
7
|
+
*
|
|
8
|
+
* Server side only, and not negotiably so. The grant is the client secret, so anything
|
|
9
|
+
* holding it can mint tokens as this service. A browser cannot keep one.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const orders = createServiceClient({
|
|
13
|
+
* issuer: process.env.OIDC_ISSUER!,
|
|
14
|
+
* clientId: 'reports-job',
|
|
15
|
+
* clientSecret: process.env.REPORTS_SECRET!,
|
|
16
|
+
* scopes: ['orders:read'],
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* const response = await fetch('https://api.example.com/orders', {
|
|
20
|
+
* headers: { authorization: `Bearer ${await orders.token()}` },
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { type ClientAuthMethodInput } from './client-auth.js';
|
|
25
|
+
export interface ServiceClientOptions {
|
|
26
|
+
/** The issuer, for discovery. Give `tokenEndpoint` instead to skip the request. */
|
|
27
|
+
issuer?: string;
|
|
28
|
+
tokenEndpoint?: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
/** Not needed with `private_key_jwt`. */
|
|
31
|
+
clientSecret?: string;
|
|
32
|
+
/** What the service is asking to do. Provider-specific. */
|
|
33
|
+
scopes?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Some providers want the API being called named here rather than in a scope. Auth0
|
|
36
|
+
* issues an opaque token instead of a JWT without it.
|
|
37
|
+
*/
|
|
38
|
+
audience?: string;
|
|
39
|
+
/**
|
|
40
|
+
* How this service proves who it is.
|
|
41
|
+
*
|
|
42
|
+
* `basic` puts the secret in an Authorization header, `body` in the form. `private_key_jwt`
|
|
43
|
+
* sends a short-lived assertion signed with a key the provider knows the public half of,
|
|
44
|
+
* and no secret travels at all.
|
|
45
|
+
*
|
|
46
|
+
* Not merely a preference everywhere. Okta's org authorization server refuses client
|
|
47
|
+
* credentials with anything but `private_key_jwt`, and FAPI requires it.
|
|
48
|
+
*/
|
|
49
|
+
clientAuthMethod?: ClientAuthMethodInput;
|
|
50
|
+
/**
|
|
51
|
+
* The signing key for `private_key_jwt`, as a JWK.
|
|
52
|
+
*
|
|
53
|
+
* The provider holds the public half. Nothing derived from it leaves this process
|
|
54
|
+
* except a signature, which is the point: there is no shared secret to leak.
|
|
55
|
+
*/
|
|
56
|
+
privateKey?: Record<string, unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* The `kid` to name in the assertion header.
|
|
59
|
+
*
|
|
60
|
+
* Needed when the provider holds more than one key for this client, so it knows which
|
|
61
|
+
* to verify against. Taken from the key itself when the key carries one.
|
|
62
|
+
*/
|
|
63
|
+
keyId?: string;
|
|
64
|
+
/** Signing algorithm for the assertion. Default `RS256`. */
|
|
65
|
+
assertionAlgorithm?: string;
|
|
66
|
+
/** Extra parameters on the token request, for anything a provider adds. */
|
|
67
|
+
extraParams?: Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* Fetch a new token this many milliseconds before the current one expires.
|
|
70
|
+
*
|
|
71
|
+
* Default 30 seconds. A token that expires in flight fails a request that was fine
|
|
72
|
+
* when it started, which is the failure this exists to avoid.
|
|
73
|
+
*/
|
|
74
|
+
refreshSkewMs?: number;
|
|
75
|
+
}
|
|
76
|
+
export interface ServiceClient {
|
|
77
|
+
/** A valid access token, from cache when one is still good. */
|
|
78
|
+
token(): Promise<string>;
|
|
79
|
+
/** Everything the token endpoint returned, for a caller that needs the scope or expiry. */
|
|
80
|
+
current(): Promise<{
|
|
81
|
+
accessToken: string;
|
|
82
|
+
expiresAt?: number;
|
|
83
|
+
scope?: string;
|
|
84
|
+
}>;
|
|
85
|
+
/** Drop the cached token, so the next call fetches a new one. */
|
|
86
|
+
reset(): void;
|
|
87
|
+
}
|
|
88
|
+
export declare function createServiceClient(options: ServiceClientOptions): ServiceClient;
|
|
89
|
+
//# sourceMappingURL=service-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-client.d.ts","sourceRoot":"","sources":["../src/service-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAKL,KAAK,qBAAqB,EAC3B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,qBAAqB,CAAC;IACzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,2EAA2E;IAC3E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,2FAA2F;IAC3F,OAAO,IAAI,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChF,iEAAiE;IACjE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAsGhF"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client_credentials`: a token for the service itself, with no user involved.
|
|
3
|
+
*
|
|
4
|
+
* The other flows in this package answer "who is using this app". This answers "which
|
|
5
|
+
* service is calling", which is the other half of most systems: a background job reading
|
|
6
|
+
* an API, one service talking to another, a webhook handler that needs to write back.
|
|
7
|
+
*
|
|
8
|
+
* Server side only, and not negotiably so. The grant is the client secret, so anything
|
|
9
|
+
* holding it can mint tokens as this service. A browser cannot keep one.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const orders = createServiceClient({
|
|
13
|
+
* issuer: process.env.OIDC_ISSUER!,
|
|
14
|
+
* clientId: 'reports-job',
|
|
15
|
+
* clientSecret: process.env.REPORTS_SECRET!,
|
|
16
|
+
* scopes: ['orders:read'],
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* const response = await fetch('https://api.example.com/orders', {
|
|
20
|
+
* headers: { authorization: `Bearer ${await orders.token()}` },
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { applyClientAuth, assertUsable, resolveMethod, } from './client-auth.js';
|
|
25
|
+
import { discover } from './discovery.js';
|
|
26
|
+
import { expiryFrom } from './pkce.js';
|
|
27
|
+
import { OAuthError } from './types.js';
|
|
28
|
+
export function createServiceClient(options) {
|
|
29
|
+
const auth = {
|
|
30
|
+
clientId: options.clientId,
|
|
31
|
+
method: options.clientAuthMethod,
|
|
32
|
+
clientSecret: options.clientSecret,
|
|
33
|
+
privateKey: options.privateKey,
|
|
34
|
+
keyId: options.keyId,
|
|
35
|
+
assertionAlgorithm: options.assertionAlgorithm,
|
|
36
|
+
};
|
|
37
|
+
// This grant *is* the credential, so a client with nothing to prove cannot use it. That
|
|
38
|
+
// also keeps it off a browser, which can hold neither a secret nor a key.
|
|
39
|
+
if (resolveMethod(auth) === 'none') {
|
|
40
|
+
throw new Error('[fluixi/oauth2] createServiceClient needs credentials: a clientSecret, or private_key_jwt with a key. This grant is the credential, so it belongs on a server and never in a browser.');
|
|
41
|
+
}
|
|
42
|
+
assertUsable(auth, 'createServiceClient');
|
|
43
|
+
if (!options.issuer && !options.tokenEndpoint) {
|
|
44
|
+
throw new Error('[fluixi/oauth2] createServiceClient needs an issuer or a tokenEndpoint.');
|
|
45
|
+
}
|
|
46
|
+
const skew = options.refreshSkewMs ?? 30_000;
|
|
47
|
+
let cached = null;
|
|
48
|
+
/**
|
|
49
|
+
* The request in flight, if any.
|
|
50
|
+
*
|
|
51
|
+
* Without it, a burst of calls on a cold cache each start their own token request, and
|
|
52
|
+
* a provider that rate-limits the token endpoint sees them all at once.
|
|
53
|
+
*/
|
|
54
|
+
let pending = null;
|
|
55
|
+
let endpoint = options.tokenEndpoint ?? null;
|
|
56
|
+
async function resolveEndpoint() {
|
|
57
|
+
if (endpoint)
|
|
58
|
+
return endpoint;
|
|
59
|
+
const endpoints = await discover(options.issuer);
|
|
60
|
+
endpoint = endpoints.token;
|
|
61
|
+
return endpoint;
|
|
62
|
+
}
|
|
63
|
+
const stillGood = () => cached !== null && (cached.expiresAt === undefined || cached.expiresAt - skew > Date.now());
|
|
64
|
+
async function request() {
|
|
65
|
+
const url = await resolveEndpoint();
|
|
66
|
+
const headers = {
|
|
67
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
68
|
+
accept: 'application/json',
|
|
69
|
+
};
|
|
70
|
+
const form = new URLSearchParams({ grant_type: 'client_credentials', ...options.extraParams });
|
|
71
|
+
if (options.scopes?.length)
|
|
72
|
+
form.set('scope', options.scopes.join(' '));
|
|
73
|
+
if (options.audience)
|
|
74
|
+
form.set('audience', options.audience);
|
|
75
|
+
await applyClientAuth(auth, url, headers, form);
|
|
76
|
+
const response = await fetch(url, { method: 'POST', headers, body: form.toString() });
|
|
77
|
+
const payload = (await response.json().catch(() => ({})));
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
// The description carries the reason; the code alone rarely does. Both in the
|
|
80
|
+
// message, because a caller logging `error.message` should not have to know to
|
|
81
|
+
// reach for `error.description` as well.
|
|
82
|
+
const reason = [payload.error, payload.error_description].filter(Boolean).join(': ');
|
|
83
|
+
throw new OAuthError(`The token endpoint refused this service: ${reason || response.status}`, String(payload.error ?? 'client_credentials_failed'), typeof payload.error_description === 'string' ? payload.error_description : undefined);
|
|
84
|
+
}
|
|
85
|
+
const accessToken = String(payload.access_token ?? '');
|
|
86
|
+
if (!accessToken) {
|
|
87
|
+
throw new OAuthError('The token response carried no access_token.', 'no_access_token');
|
|
88
|
+
}
|
|
89
|
+
cached = {
|
|
90
|
+
accessToken,
|
|
91
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
92
|
+
scope: typeof payload.scope === 'string' ? payload.scope : undefined,
|
|
93
|
+
};
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
async function current() {
|
|
97
|
+
if (stillGood())
|
|
98
|
+
return cached;
|
|
99
|
+
// One request per cold cache, however many callers arrive at once.
|
|
100
|
+
pending ??= request().finally(() => {
|
|
101
|
+
pending = null;
|
|
102
|
+
});
|
|
103
|
+
return pending;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
current,
|
|
107
|
+
token: async () => (await current()).accessToken,
|
|
108
|
+
reset: () => {
|
|
109
|
+
cached = null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/*! @fluixi/oauth2 v1.0.0-alpha.1 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
|
|
2
|
+
import "./chunk-25WE6UWI.mjs";
|
|
3
|
+
|
|
4
|
+
// src/types.ts
|
|
5
|
+
var OAuthError = class extends Error {
|
|
6
|
+
constructor(message, code, description) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.description = description;
|
|
10
|
+
this.name = "OAuthError";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// src/client-auth.ts
|
|
15
|
+
var CANONICAL = {
|
|
16
|
+
basic: "client_secret_basic",
|
|
17
|
+
body: "client_secret_post",
|
|
18
|
+
client_secret_basic: "client_secret_basic",
|
|
19
|
+
client_secret_post: "client_secret_post",
|
|
20
|
+
private_key_jwt: "private_key_jwt",
|
|
21
|
+
none: "none"
|
|
22
|
+
};
|
|
23
|
+
function resolveMethod(auth) {
|
|
24
|
+
if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
|
|
25
|
+
if (auth.privateKey) return "private_key_jwt";
|
|
26
|
+
return auth.clientSecret ? "client_secret_basic" : "none";
|
|
27
|
+
}
|
|
28
|
+
function assertUsable(auth, who) {
|
|
29
|
+
if (!auth.clientId) throw new Error(`[fluixi/oauth2] ${who} needs a clientId.`);
|
|
30
|
+
const method = resolveMethod(auth);
|
|
31
|
+
if (method === "private_key_jwt" && !auth.privateKey) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`[fluixi/oauth2] ${who}: private_key_jwt needs \`privateKey\`, the JWK whose public half the provider holds.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if ((method === "client_secret_basic" || method === "client_secret_post") && !auth.clientSecret) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`[fluixi/oauth2] ${who}: ${method} needs a \`clientSecret\`. A public client should use \`method: 'none'\`, which PKCE is what protects.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return method;
|
|
42
|
+
}
|
|
43
|
+
async function loadJose() {
|
|
44
|
+
try {
|
|
45
|
+
return await import("./browser-ONMHX3Z3.mjs");
|
|
46
|
+
} catch {
|
|
47
|
+
throw new OAuthError(
|
|
48
|
+
"private_key_jwt needs `jose`. Install it: npm i jose. It is an optional peer, so a client using a secret does not carry it.",
|
|
49
|
+
"jose_missing"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function signAssertion(auth, endpoint) {
|
|
54
|
+
const jose = await loadJose();
|
|
55
|
+
const alg = auth.assertionAlgorithm ?? "RS256";
|
|
56
|
+
const key = await jose.importJWK(auth.privateKey, alg);
|
|
57
|
+
const kid = auth.keyId ?? auth.privateKey.kid;
|
|
58
|
+
return new jose.SignJWT({}).setProtectedHeader(kid ? { alg, kid } : { alg }).setIssuer(auth.clientId).setSubject(auth.clientId).setAudience(endpoint).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime("1m").sign(key);
|
|
59
|
+
}
|
|
60
|
+
async function applyClientAuth(auth, endpoint, headers, form) {
|
|
61
|
+
switch (resolveMethod(auth)) {
|
|
62
|
+
case "client_secret_basic": {
|
|
63
|
+
const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
|
|
64
|
+
headers.authorization = `Basic ${btoa(pair)}`;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "client_secret_post":
|
|
68
|
+
form.set("client_id", auth.clientId);
|
|
69
|
+
form.set("client_secret", auth.clientSecret);
|
|
70
|
+
break;
|
|
71
|
+
case "private_key_jwt":
|
|
72
|
+
form.set("client_id", auth.clientId);
|
|
73
|
+
form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
74
|
+
form.set("client_assertion", await signAssertion(auth, endpoint));
|
|
75
|
+
break;
|
|
76
|
+
case "none":
|
|
77
|
+
form.set("client_id", auth.clientId);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/discovery.ts
|
|
83
|
+
var cache = /* @__PURE__ */ new Map();
|
|
84
|
+
function documentUrl(issuer) {
|
|
85
|
+
return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
86
|
+
}
|
|
87
|
+
async function discover(issuer, fetcher = fetch) {
|
|
88
|
+
const cached = cache.get(issuer);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const pending = (async () => {
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetcher(documentUrl(issuer));
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new OAuthError(
|
|
96
|
+
`Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
|
|
97
|
+
"discovery_unreachable"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new OAuthError(
|
|
102
|
+
`Discovery failed for ${issuer}: ${response.status}`,
|
|
103
|
+
"discovery_failed"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const doc = await response.json();
|
|
107
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
|
108
|
+
throw new OAuthError(
|
|
109
|
+
`${issuer} published a discovery document with no authorization or token endpoint.`,
|
|
110
|
+
"discovery_incomplete"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
authorization: doc.authorization_endpoint,
|
|
115
|
+
token: doc.token_endpoint,
|
|
116
|
+
userinfo: doc.userinfo_endpoint,
|
|
117
|
+
endSession: doc.end_session_endpoint,
|
|
118
|
+
jwks: doc.jwks_uri,
|
|
119
|
+
introspection: doc.introspection_endpoint,
|
|
120
|
+
revocation: doc.revocation_endpoint,
|
|
121
|
+
issuer: doc.issuer ?? issuer
|
|
122
|
+
};
|
|
123
|
+
})();
|
|
124
|
+
cache.set(issuer, pending);
|
|
125
|
+
pending.catch(() => cache.delete(issuer));
|
|
126
|
+
return pending;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/pkce.ts
|
|
130
|
+
function expiryFrom(expiresIn, now = Date.now()) {
|
|
131
|
+
const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
|
|
132
|
+
return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/service-client.ts
|
|
136
|
+
function createServiceClient(options) {
|
|
137
|
+
const auth = {
|
|
138
|
+
clientId: options.clientId,
|
|
139
|
+
method: options.clientAuthMethod,
|
|
140
|
+
clientSecret: options.clientSecret,
|
|
141
|
+
privateKey: options.privateKey,
|
|
142
|
+
keyId: options.keyId,
|
|
143
|
+
assertionAlgorithm: options.assertionAlgorithm
|
|
144
|
+
};
|
|
145
|
+
if (resolveMethod(auth) === "none") {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"[fluixi/oauth2] createServiceClient needs credentials: a clientSecret, or private_key_jwt with a key. This grant is the credential, so it belongs on a server and never in a browser."
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
assertUsable(auth, "createServiceClient");
|
|
151
|
+
if (!options.issuer && !options.tokenEndpoint) {
|
|
152
|
+
throw new Error("[fluixi/oauth2] createServiceClient needs an issuer or a tokenEndpoint.");
|
|
153
|
+
}
|
|
154
|
+
const skew = options.refreshSkewMs ?? 3e4;
|
|
155
|
+
let cached = null;
|
|
156
|
+
let pending = null;
|
|
157
|
+
let endpoint = options.tokenEndpoint ?? null;
|
|
158
|
+
async function resolveEndpoint() {
|
|
159
|
+
if (endpoint) return endpoint;
|
|
160
|
+
const endpoints = await discover(options.issuer);
|
|
161
|
+
endpoint = endpoints.token;
|
|
162
|
+
return endpoint;
|
|
163
|
+
}
|
|
164
|
+
const stillGood = () => cached !== null && (cached.expiresAt === void 0 || cached.expiresAt - skew > Date.now());
|
|
165
|
+
async function request() {
|
|
166
|
+
const url = await resolveEndpoint();
|
|
167
|
+
const headers = {
|
|
168
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
169
|
+
accept: "application/json"
|
|
170
|
+
};
|
|
171
|
+
const form = new URLSearchParams({ grant_type: "client_credentials", ...options.extraParams });
|
|
172
|
+
if (options.scopes?.length) form.set("scope", options.scopes.join(" "));
|
|
173
|
+
if (options.audience) form.set("audience", options.audience);
|
|
174
|
+
await applyClientAuth(auth, url, headers, form);
|
|
175
|
+
const response = await fetch(url, { method: "POST", headers, body: form.toString() });
|
|
176
|
+
const payload = await response.json().catch(() => ({}));
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const reason = [payload.error, payload.error_description].filter(Boolean).join(": ");
|
|
179
|
+
throw new OAuthError(
|
|
180
|
+
`The token endpoint refused this service: ${reason || response.status}`,
|
|
181
|
+
String(payload.error ?? "client_credentials_failed"),
|
|
182
|
+
typeof payload.error_description === "string" ? payload.error_description : void 0
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const accessToken = String(payload.access_token ?? "");
|
|
186
|
+
if (!accessToken) {
|
|
187
|
+
throw new OAuthError("The token response carried no access_token.", "no_access_token");
|
|
188
|
+
}
|
|
189
|
+
cached = {
|
|
190
|
+
accessToken,
|
|
191
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
192
|
+
scope: typeof payload.scope === "string" ? payload.scope : void 0
|
|
193
|
+
};
|
|
194
|
+
return cached;
|
|
195
|
+
}
|
|
196
|
+
async function current() {
|
|
197
|
+
if (stillGood()) return cached;
|
|
198
|
+
pending ??= request().finally(() => {
|
|
199
|
+
pending = null;
|
|
200
|
+
});
|
|
201
|
+
return pending;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
current,
|
|
205
|
+
token: async () => (await current()).accessToken,
|
|
206
|
+
reset: () => {
|
|
207
|
+
cached = null;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export {
|
|
212
|
+
createServiceClient
|
|
213
|
+
};
|