@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
package/dist/verify.mjs
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/*! @fluixi/oauth2 v1.0.0-alpha.1 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
|
|
2
|
+
import {
|
|
3
|
+
OAuthError
|
|
4
|
+
} from "./chunk-4NSOGPLM.mjs";
|
|
5
|
+
import "./chunk-25WE6UWI.mjs";
|
|
6
|
+
|
|
7
|
+
// src/client-auth.ts
|
|
8
|
+
var CANONICAL = {
|
|
9
|
+
basic: "client_secret_basic",
|
|
10
|
+
body: "client_secret_post",
|
|
11
|
+
client_secret_basic: "client_secret_basic",
|
|
12
|
+
client_secret_post: "client_secret_post",
|
|
13
|
+
private_key_jwt: "private_key_jwt",
|
|
14
|
+
none: "none"
|
|
15
|
+
};
|
|
16
|
+
function resolveMethod(auth) {
|
|
17
|
+
if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
|
|
18
|
+
if (auth.privateKey) return "private_key_jwt";
|
|
19
|
+
return auth.clientSecret ? "client_secret_basic" : "none";
|
|
20
|
+
}
|
|
21
|
+
function assertUsable(auth, who) {
|
|
22
|
+
if (!auth.clientId) throw new Error(`[fluixi/oauth2] ${who} needs a clientId.`);
|
|
23
|
+
const method = resolveMethod(auth);
|
|
24
|
+
if (method === "private_key_jwt" && !auth.privateKey) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[fluixi/oauth2] ${who}: private_key_jwt needs \`privateKey\`, the JWK whose public half the provider holds.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if ((method === "client_secret_basic" || method === "client_secret_post") && !auth.clientSecret) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[fluixi/oauth2] ${who}: ${method} needs a \`clientSecret\`. A public client should use \`method: 'none'\`, which PKCE is what protects.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return method;
|
|
35
|
+
}
|
|
36
|
+
async function loadJose() {
|
|
37
|
+
try {
|
|
38
|
+
return await import("./browser-ONMHX3Z3.mjs");
|
|
39
|
+
} catch {
|
|
40
|
+
throw new OAuthError(
|
|
41
|
+
"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.",
|
|
42
|
+
"jose_missing"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function signAssertion(auth, endpoint) {
|
|
47
|
+
const jose = await loadJose();
|
|
48
|
+
const alg = auth.assertionAlgorithm ?? "RS256";
|
|
49
|
+
const key = await jose.importJWK(auth.privateKey, alg);
|
|
50
|
+
const kid = auth.keyId ?? auth.privateKey.kid;
|
|
51
|
+
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);
|
|
52
|
+
}
|
|
53
|
+
async function applyClientAuth(auth, endpoint, headers, form) {
|
|
54
|
+
switch (resolveMethod(auth)) {
|
|
55
|
+
case "client_secret_basic": {
|
|
56
|
+
const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
|
|
57
|
+
headers.authorization = `Basic ${btoa(pair)}`;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "client_secret_post":
|
|
61
|
+
form.set("client_id", auth.clientId);
|
|
62
|
+
form.set("client_secret", auth.clientSecret);
|
|
63
|
+
break;
|
|
64
|
+
case "private_key_jwt":
|
|
65
|
+
form.set("client_id", auth.clientId);
|
|
66
|
+
form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
67
|
+
form.set("client_assertion", await signAssertion(auth, endpoint));
|
|
68
|
+
break;
|
|
69
|
+
case "none":
|
|
70
|
+
form.set("client_id", auth.clientId);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/discovery.ts
|
|
76
|
+
var cache = /* @__PURE__ */ new Map();
|
|
77
|
+
function documentUrl(issuer) {
|
|
78
|
+
return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
79
|
+
}
|
|
80
|
+
async function discover(issuer, fetcher = fetch) {
|
|
81
|
+
const cached = cache.get(issuer);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
const pending = (async () => {
|
|
84
|
+
let response;
|
|
85
|
+
try {
|
|
86
|
+
response = await fetcher(documentUrl(issuer));
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new OAuthError(
|
|
89
|
+
`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)})`,
|
|
90
|
+
"discovery_unreachable"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new OAuthError(
|
|
95
|
+
`Discovery failed for ${issuer}: ${response.status}`,
|
|
96
|
+
"discovery_failed"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const doc = await response.json();
|
|
100
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
|
101
|
+
throw new OAuthError(
|
|
102
|
+
`${issuer} published a discovery document with no authorization or token endpoint.`,
|
|
103
|
+
"discovery_incomplete"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
authorization: doc.authorization_endpoint,
|
|
108
|
+
token: doc.token_endpoint,
|
|
109
|
+
userinfo: doc.userinfo_endpoint,
|
|
110
|
+
endSession: doc.end_session_endpoint,
|
|
111
|
+
jwks: doc.jwks_uri,
|
|
112
|
+
introspection: doc.introspection_endpoint,
|
|
113
|
+
revocation: doc.revocation_endpoint,
|
|
114
|
+
issuer: doc.issuer ?? issuer
|
|
115
|
+
};
|
|
116
|
+
})();
|
|
117
|
+
cache.set(issuer, pending);
|
|
118
|
+
pending.catch(() => cache.delete(issuer));
|
|
119
|
+
return pending;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/env-credentials.ts
|
|
123
|
+
async function jwkAt(path) {
|
|
124
|
+
const [{ readFileSync }, { homedir }] = await Promise.all([
|
|
125
|
+
import("node:fs"),
|
|
126
|
+
import("node:os")
|
|
127
|
+
]);
|
|
128
|
+
const file = path.replace(/^~(?=$|\/)/, homedir());
|
|
129
|
+
let text;
|
|
130
|
+
try {
|
|
131
|
+
text = readFileSync(file, "utf8");
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`[fluixi/oauth2] the key file ${file} cannot be read. A signing key is named by path so it can stay outside the repository.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(text);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error(`[fluixi/oauth2] ${file} is not JSON, and a private key is expected as a JWK.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function credentialsFromEnv(options) {
|
|
144
|
+
const source = options.env ?? (typeof process === "undefined" ? {} : process.env);
|
|
145
|
+
const upper = options.prefix.toUpperCase();
|
|
146
|
+
const prefer = (options.preferPrefix ?? `${upper}_INTROSPECT`).toUpperCase();
|
|
147
|
+
const read = (suffix) => source[`${prefer}_${suffix}`] ?? source[`${upper}_${suffix}`];
|
|
148
|
+
const clientId = read("CLIENT_ID") ?? options.clientId;
|
|
149
|
+
if (!clientId) return null;
|
|
150
|
+
const keyPath = read("PRIVATE_KEY");
|
|
151
|
+
if (keyPath) {
|
|
152
|
+
return {
|
|
153
|
+
clientId,
|
|
154
|
+
privateKey: await jwkAt(keyPath),
|
|
155
|
+
keyId: read("KEY_ID"),
|
|
156
|
+
clientAuthMethod: "private_key_jwt",
|
|
157
|
+
assertionAlgorithm: read("ASSERTION_ALG")
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const clientSecret = read("SECRET") ?? read("CLIENT_SECRET");
|
|
161
|
+
if (clientSecret) return { clientId, clientSecret };
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
function explainCredentials(options) {
|
|
165
|
+
const upper = options.prefix.toUpperCase();
|
|
166
|
+
const who = options.label ?? options.prefix;
|
|
167
|
+
return `This call authenticates its caller, so ${who} needs credentials. For a client with a shared secret, set ${upper}_CLIENT_SECRET. For one registered with a public key (Okta calls this "Public key / Private key"), set ${upper}_PRIVATE_KEY to the path of its private JWK and ${upper}_KEY_ID to the kid the provider lists. Prefix either with ${upper}_INTROSPECT_ to have a different client ask, which Okta requires: a public SPA cannot introspect at all.`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/roles.ts
|
|
171
|
+
var rolesFrom = {
|
|
172
|
+
/** Keycloak, realm roles. Where it puts them unless a client mapper is configured. */
|
|
173
|
+
keycloakRealm: (claims) => {
|
|
174
|
+
const realm = claims.realm_access;
|
|
175
|
+
return realm?.roles ?? [];
|
|
176
|
+
},
|
|
177
|
+
/** Keycloak, roles scoped to one client rather than the realm. */
|
|
178
|
+
keycloakClient: (clientId) => (claims) => {
|
|
179
|
+
const all = claims.resource_access;
|
|
180
|
+
return all?.[clientId]?.roles ?? [];
|
|
181
|
+
},
|
|
182
|
+
/** Okta, and anything else using a groups claim. Needs a groups claim on the token. */
|
|
183
|
+
groups: (claims) => Array.isArray(claims.groups) ? claims.groups : [],
|
|
184
|
+
/** Cognito, which namespaces the claim. */
|
|
185
|
+
cognito: (claims) => {
|
|
186
|
+
const groups = claims["cognito:groups"];
|
|
187
|
+
return Array.isArray(groups) ? groups : [];
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Auth0, which requires a namespaced custom claim added by an action.
|
|
191
|
+
*
|
|
192
|
+
* `rolesFrom.namespaced('https://adafri.com/roles')`. Auth0 silently drops a custom claim
|
|
193
|
+
* that is not a URI, so an empty list here usually means the namespace, not the action.
|
|
194
|
+
*/
|
|
195
|
+
namespaced: (claim) => (claims) => {
|
|
196
|
+
const value = claims[claim];
|
|
197
|
+
return Array.isArray(value) ? value : [];
|
|
198
|
+
},
|
|
199
|
+
/** The `scope` claim read as a role list, for a provider issuing no roles at all. */
|
|
200
|
+
scope: (claims) => typeof claims.scope === "string" ? claims.scope.split(" ") : []
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/api-guard.ts
|
|
204
|
+
var asList = (value) => value === void 0 ? [] : Array.isArray(value) ? value : [value];
|
|
205
|
+
function refuse(refusal) {
|
|
206
|
+
const body = { error: refusal.code };
|
|
207
|
+
if (refusal.detail) body.detail = refusal.detail;
|
|
208
|
+
return new Response(JSON.stringify(body), {
|
|
209
|
+
status: refusal.status,
|
|
210
|
+
headers: { "content-type": "application/json" }
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function createApiGuard(options) {
|
|
214
|
+
const answer = (refusal, request) => {
|
|
215
|
+
const response = options.onRefused ? options.onRefused(refusal, request) : refuse(refusal);
|
|
216
|
+
if (refusal.status === 401 && !response.headers.has("www-authenticate")) {
|
|
217
|
+
response.headers.set("www-authenticate", `Bearer error="${refusal.code}"`);
|
|
218
|
+
}
|
|
219
|
+
return response;
|
|
220
|
+
};
|
|
221
|
+
const rolesOf = (claims, what) => {
|
|
222
|
+
if (!options.roles) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`[fluixi/oauth2] this route requires ${what}, but createApiGuard was given no \`roles\`. Providers disagree on where roles live, so there is no default: pass one of \`rolesFrom\`, or a function reading the claim your provider uses.`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return options.roles(claims);
|
|
228
|
+
};
|
|
229
|
+
return (handler, requirements = {}) => async (request) => {
|
|
230
|
+
let claims;
|
|
231
|
+
try {
|
|
232
|
+
claims = await options.verify.fromRequest(request);
|
|
233
|
+
} catch (cause) {
|
|
234
|
+
const code = cause instanceof TokenError ? cause.code : "invalid_token";
|
|
235
|
+
return answer({ status: 401, code }, request);
|
|
236
|
+
}
|
|
237
|
+
const wanted = asList(requirements.role);
|
|
238
|
+
if (wanted.length) {
|
|
239
|
+
const held = rolesOf(claims, "a role");
|
|
240
|
+
if (!wanted.some((role) => held.includes(role))) {
|
|
241
|
+
return answer(
|
|
242
|
+
{
|
|
243
|
+
status: 403,
|
|
244
|
+
code: "insufficient_role",
|
|
245
|
+
detail: `needs ${wanted.length > 1 ? "one of " : ""}${wanted.join(", ")}`,
|
|
246
|
+
claims
|
|
247
|
+
},
|
|
248
|
+
request
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (requirements.allRoles?.length) {
|
|
253
|
+
const held = rolesOf(claims, "roles");
|
|
254
|
+
const missing = requirements.allRoles.filter((role) => !held.includes(role));
|
|
255
|
+
if (missing.length) {
|
|
256
|
+
return answer(
|
|
257
|
+
{ status: 403, code: "insufficient_role", detail: `needs ${missing.join(", ")}`, claims },
|
|
258
|
+
request
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const scopes = asList(requirements.scope);
|
|
263
|
+
if (scopes.length) {
|
|
264
|
+
const held = typeof claims.scope === "string" ? claims.scope.split(" ") : [];
|
|
265
|
+
if (!scopes.some((scope) => held.includes(scope))) {
|
|
266
|
+
return answer(
|
|
267
|
+
{
|
|
268
|
+
status: 403,
|
|
269
|
+
code: "insufficient_scope",
|
|
270
|
+
detail: `needs ${scopes.length > 1 ? "one of " : ""}${scopes.join(", ")}`,
|
|
271
|
+
claims
|
|
272
|
+
},
|
|
273
|
+
request
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (requirements.require) {
|
|
278
|
+
const verdict = requirements.require(claims, request);
|
|
279
|
+
if (verdict !== true) {
|
|
280
|
+
return answer(
|
|
281
|
+
{
|
|
282
|
+
status: 403,
|
|
283
|
+
code: "forbidden",
|
|
284
|
+
detail: typeof verdict === "string" ? verdict : void 0,
|
|
285
|
+
claims
|
|
286
|
+
},
|
|
287
|
+
request
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return handler(request, claims);
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/verify.ts
|
|
296
|
+
var TokenError = class extends OAuthError {
|
|
297
|
+
constructor(message, code = "invalid_token") {
|
|
298
|
+
super(message, code);
|
|
299
|
+
this.name = "TokenError";
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
function bearerOf(request) {
|
|
303
|
+
const header = request.headers.get("authorization");
|
|
304
|
+
if (!header) return null;
|
|
305
|
+
const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
|
|
306
|
+
return match ? match[1].trim() : null;
|
|
307
|
+
}
|
|
308
|
+
async function loadJose2() {
|
|
309
|
+
try {
|
|
310
|
+
return await import("./browser-ONMHX3Z3.mjs");
|
|
311
|
+
} catch {
|
|
312
|
+
throw new OAuthError(
|
|
313
|
+
"Token verification needs `jose`. Install it: npm i jose. It is an optional peer so an app that does not verify does not carry it.",
|
|
314
|
+
"jose_missing"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function createTokenVerifier(options) {
|
|
319
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
320
|
+
if (!issuers.length) {
|
|
321
|
+
throw new Error("[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.");
|
|
322
|
+
}
|
|
323
|
+
const discoveryIssuer = issuers[0];
|
|
324
|
+
if (options.audience === void 0) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
"[fluixi/oauth2] createTokenVerifier needs an audience, or `audience: false` to say this service really does accept a token minted for any client of the issuer."
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (options.audience === false && options.authorizedParty === void 0) {
|
|
330
|
+
console.warn(
|
|
331
|
+
"[fluixi/oauth2] This verifier checks neither `aud` nor `azp`, so a token minted for any client of the issuer is accepted. Set `authorizedParty` if that is not what you meant."
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const algorithms = options.algorithms ?? ["RS256"];
|
|
335
|
+
if (algorithms.some((alg) => !alg || alg.toLowerCase() === "none")) {
|
|
336
|
+
throw new Error("[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.");
|
|
337
|
+
}
|
|
338
|
+
let keys = null;
|
|
339
|
+
async function keySet() {
|
|
340
|
+
if (keys) return keys;
|
|
341
|
+
const jose = await loadJose2();
|
|
342
|
+
if (options.jwks) {
|
|
343
|
+
keys = jose.createLocalJWKSet(options.jwks);
|
|
344
|
+
return keys;
|
|
345
|
+
}
|
|
346
|
+
let uri = options.jwksUri;
|
|
347
|
+
if (!uri) {
|
|
348
|
+
const endpoints = await discover(discoveryIssuer);
|
|
349
|
+
uri = endpoints.jwks;
|
|
350
|
+
}
|
|
351
|
+
if (!uri) {
|
|
352
|
+
throw new OAuthError(
|
|
353
|
+
`${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`,
|
|
354
|
+
"no_jwks"
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
keys = jose.createRemoteJWKSet(new URL(uri));
|
|
358
|
+
return keys;
|
|
359
|
+
}
|
|
360
|
+
const checkProof = async (claims, token, request) => {
|
|
361
|
+
const jose = await loadJose2();
|
|
362
|
+
const { boundKeyOf, thumbprintOf } = await import("./dpop-LVPWWADA.mjs");
|
|
363
|
+
const bound = boundKeyOf(claims);
|
|
364
|
+
if (!bound) {
|
|
365
|
+
throw new TokenError(
|
|
366
|
+
"This token is not bound to a key, and this service requires binding.",
|
|
367
|
+
"token_not_bound"
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const proof = request.headers.get("dpop");
|
|
371
|
+
if (!proof) throw new TokenError("The request carried no DPoP proof.", "missing_dpop_proof");
|
|
372
|
+
let payload;
|
|
373
|
+
let header;
|
|
374
|
+
try {
|
|
375
|
+
const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: "dpop+jwt" });
|
|
376
|
+
payload = result.payload;
|
|
377
|
+
header = result.protectedHeader;
|
|
378
|
+
} catch (cause) {
|
|
379
|
+
throw new TokenError(
|
|
380
|
+
`The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
381
|
+
"invalid_dpop_proof"
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const presented = await thumbprintOf(header.jwk);
|
|
385
|
+
if (presented !== bound) {
|
|
386
|
+
throw new TokenError(
|
|
387
|
+
"The DPoP proof was signed with a different key from the one this token is bound to.",
|
|
388
|
+
"dpop_key_mismatch"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const url = new URL(request.url);
|
|
392
|
+
url.search = "";
|
|
393
|
+
url.hash = "";
|
|
394
|
+
if (payload.htm !== request.method.toUpperCase()) {
|
|
395
|
+
throw new TokenError("The DPoP proof was made for a different method.", "invalid_dpop_proof");
|
|
396
|
+
}
|
|
397
|
+
if (payload.htu !== url.toString()) {
|
|
398
|
+
throw new TokenError("The DPoP proof was made for a different URL.", "invalid_dpop_proof");
|
|
399
|
+
}
|
|
400
|
+
if (typeof payload.ath === "string") {
|
|
401
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
402
|
+
let binary = "";
|
|
403
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
404
|
+
const expected = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
405
|
+
if (payload.ath !== expected) {
|
|
406
|
+
throw new TokenError("The DPoP proof was made for a different token.", "invalid_dpop_proof");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const age = Math.abs(Date.now() / 1e3 - payload.iat);
|
|
410
|
+
if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
|
|
411
|
+
throw new TokenError("The DPoP proof is too old to accept.", "invalid_dpop_proof");
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const verify = async (token, request) => {
|
|
415
|
+
if (!token) throw new TokenError("No token to verify.", "no_token");
|
|
416
|
+
const jose = await loadJose2();
|
|
417
|
+
let payload;
|
|
418
|
+
try {
|
|
419
|
+
({ payload } = await jose.jwtVerify(token, await keySet(), {
|
|
420
|
+
// jose takes a list and accepts any of them, which is what a provider naming
|
|
421
|
+
// itself two ways needs.
|
|
422
|
+
issuer: issuers,
|
|
423
|
+
...options.audience === false ? {} : { audience: options.audience },
|
|
424
|
+
algorithms,
|
|
425
|
+
clockTolerance: options.clockToleranceSec ?? 5
|
|
426
|
+
}));
|
|
427
|
+
} catch (cause) {
|
|
428
|
+
throw new TokenError(
|
|
429
|
+
`The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (options.authorizedParty !== void 0) {
|
|
433
|
+
const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
|
|
434
|
+
const issuedTo = payload.azp ?? payload.client_id;
|
|
435
|
+
if (typeof issuedTo !== "string" || !allowed.includes(issuedTo)) {
|
|
436
|
+
throw new TokenError("The token was issued to a different client.", "invalid_azp");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (options.dpop) {
|
|
440
|
+
if (!request) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
"[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token."
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
await checkProof(payload, token, request);
|
|
446
|
+
}
|
|
447
|
+
if (options.require) {
|
|
448
|
+
const verdict = options.require(payload);
|
|
449
|
+
if (verdict !== true) {
|
|
450
|
+
throw new TokenError(
|
|
451
|
+
typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
|
|
452
|
+
"insufficient_claims"
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return payload;
|
|
457
|
+
};
|
|
458
|
+
const verifier = verify;
|
|
459
|
+
verifier.bearerOf = bearerOf;
|
|
460
|
+
verifier.fromRequest = async (request) => {
|
|
461
|
+
const token = verifier.bearerOf(request);
|
|
462
|
+
if (!token) {
|
|
463
|
+
throw new TokenError("The request carried no bearer token.", "no_token");
|
|
464
|
+
}
|
|
465
|
+
return verify(token, request);
|
|
466
|
+
};
|
|
467
|
+
return verifier;
|
|
468
|
+
}
|
|
469
|
+
function createTokenIntrospector(options) {
|
|
470
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
471
|
+
if (!issuers.length) {
|
|
472
|
+
throw new Error("[fluixi/oauth2] createTokenIntrospector needs an issuer.");
|
|
473
|
+
}
|
|
474
|
+
const discoveryIssuer = issuers[0];
|
|
475
|
+
const auth = {
|
|
476
|
+
clientId: options.clientId,
|
|
477
|
+
method: options.clientAuthMethod,
|
|
478
|
+
clientSecret: options.clientSecret,
|
|
479
|
+
privateKey: options.privateKey,
|
|
480
|
+
keyId: options.keyId,
|
|
481
|
+
assertionAlgorithm: options.assertionAlgorithm
|
|
482
|
+
};
|
|
483
|
+
if (resolveMethod(auth) === "none") {
|
|
484
|
+
throw new Error(
|
|
485
|
+
"[fluixi/oauth2] createTokenIntrospector needs credentials: a secret, or private_key_jwt with a key."
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
assertUsable(auth, "createTokenIntrospector");
|
|
489
|
+
if (options.audience === void 0) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
"[fluixi/oauth2] createTokenIntrospector needs an audience, or `audience: false` to say this service accepts a token minted for any client of the issuer."
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const ttl = options.cacheMs ?? 0;
|
|
495
|
+
const cache2 = /* @__PURE__ */ new Map();
|
|
496
|
+
let endpoint = options.introspectionEndpoint ?? null;
|
|
497
|
+
async function resolveEndpoint() {
|
|
498
|
+
if (endpoint) return endpoint;
|
|
499
|
+
const endpoints = await discover(discoveryIssuer);
|
|
500
|
+
if (!endpoints.introspection) {
|
|
501
|
+
throw new OAuthError(
|
|
502
|
+
`${discoveryIssuer} published no introspection_endpoint. Give the introspector one directly, or verify JWTs with createTokenVerifier.`,
|
|
503
|
+
"no_introspection"
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
endpoint = endpoints.introspection;
|
|
507
|
+
return endpoint;
|
|
508
|
+
}
|
|
509
|
+
const introspect = async (token) => {
|
|
510
|
+
if (!token) throw new TokenError("No token to verify.", "no_token");
|
|
511
|
+
const cached = cache2.get(token);
|
|
512
|
+
if (cached && cached.until > Date.now()) return cached.claims;
|
|
513
|
+
const url = await resolveEndpoint();
|
|
514
|
+
const headers = {
|
|
515
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
516
|
+
accept: "application/json"
|
|
517
|
+
};
|
|
518
|
+
const form = new URLSearchParams({ token, token_type_hint: "access_token" });
|
|
519
|
+
await applyClientAuth(auth, url, headers, form);
|
|
520
|
+
const response = await fetch(url, { method: "POST", headers, body: form.toString() });
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
const method = resolveMethod(auth);
|
|
523
|
+
const check = method === "private_key_jwt" ? `Check clientId, and that the provider holds the public half of this key${auth.keyId ? ` under kid ${auth.keyId}` : ""}. A key registered to a different client than clientId is refused this way.` : "Check clientId and clientSecret.";
|
|
524
|
+
const said = await response.text().catch(() => "");
|
|
525
|
+
throw new OAuthError(
|
|
526
|
+
`The introspection endpoint refused this service: ${response.status}. ${check}${said ? ` It said: ${said}` : ""}`,
|
|
527
|
+
"introspection_failed"
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const body = await response.json();
|
|
531
|
+
if (body.active !== true) {
|
|
532
|
+
throw new TokenError(
|
|
533
|
+
"The provider reports this token is not active. If it was just issued, check that this client is in the token audience: some providers refuse to introspect a token minted for someone else and say only this.",
|
|
534
|
+
"inactive_token"
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (typeof body.iss === "string" && !issuers.includes(body.iss)) {
|
|
538
|
+
throw new TokenError("The token was issued by a different issuer.", "invalid_issuer");
|
|
539
|
+
}
|
|
540
|
+
if (options.audience !== false && body.aud !== void 0) {
|
|
541
|
+
const wanted = Array.isArray(options.audience) ? options.audience : [options.audience];
|
|
542
|
+
const got = Array.isArray(body.aud) ? body.aud : [body.aud];
|
|
543
|
+
if (!got.some((a) => wanted.includes(a))) {
|
|
544
|
+
throw new TokenError("The token was minted for a different audience.", "invalid_audience");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (options.authorizedParty !== void 0) {
|
|
548
|
+
const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
|
|
549
|
+
const issuedTo = body.client_id ?? body.azp;
|
|
550
|
+
if (!issuedTo || !allowed.includes(issuedTo)) {
|
|
551
|
+
throw new TokenError("The token was issued to a different client.", "invalid_azp");
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (options.require) {
|
|
555
|
+
const verdict = options.require(body);
|
|
556
|
+
if (verdict !== true) {
|
|
557
|
+
throw new TokenError(
|
|
558
|
+
typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
|
|
559
|
+
"insufficient_claims"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (ttl > 0) {
|
|
564
|
+
if (cache2.size > 500) {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
for (const [key, entry] of cache2) if (entry.until <= now) cache2.delete(key);
|
|
567
|
+
if (cache2.size > 500) cache2.delete(cache2.keys().next().value);
|
|
568
|
+
}
|
|
569
|
+
cache2.set(token, { claims: body, until: Date.now() + ttl });
|
|
570
|
+
}
|
|
571
|
+
return body;
|
|
572
|
+
};
|
|
573
|
+
const verifier = introspect;
|
|
574
|
+
verifier.bearerOf = bearerOf;
|
|
575
|
+
verifier.fromRequest = async (request) => {
|
|
576
|
+
const token = bearerOf(request);
|
|
577
|
+
if (!token) throw new TokenError("The request carried no bearer token.", "no_token");
|
|
578
|
+
return introspect(token);
|
|
579
|
+
};
|
|
580
|
+
return verifier;
|
|
581
|
+
}
|
|
582
|
+
export {
|
|
583
|
+
TokenError,
|
|
584
|
+
createApiGuard,
|
|
585
|
+
createTokenIntrospector,
|
|
586
|
+
createTokenVerifier,
|
|
587
|
+
credentialsFromEnv,
|
|
588
|
+
explainCredentials,
|
|
589
|
+
rolesFrom
|
|
590
|
+
};
|