@cloudflare/workers-oauth-provider 1.0.0 → 1.2.0
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/README.md +54 -533
- package/dist/oauth-provider.d.ts +388 -175
- package/dist/oauth-provider.js +1076 -504
- package/docs/advanced-configuration.md +15 -8
- package/docs/authorization-server.md +232 -0
- package/docs/consent-page.md +133 -0
- package/docs/mcp-discovery.md +78 -0
- package/docs/migration-1.0.md +248 -43
- package/docs/resource-servers.md +27 -4
- package/docs/upstream-sign-in.md +88 -0
- package/package.json +3 -1
- package/skills/migrate-to-1.0/SKILL.md +38 -24
package/dist/oauth-provider.js
CHANGED
|
@@ -3,6 +3,34 @@ import { WorkerEntrypoint } from "cloudflare:workers";
|
|
|
3
3
|
//#region src/oauth-capabilities.ts
|
|
4
4
|
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
5
5
|
/**
|
|
6
|
+
* The OAuth error redirect back to a client (RFC 6749 §4.1.2.1): `error`, an optional
|
|
7
|
+
* `error_description`, the client's `state`, and `iss` (RFC 9207). Pass only a request the
|
|
8
|
+
* library validated, from `parseAuthRequest()`, `finishUpstream()` or `approveConsent()`,
|
|
9
|
+
* never one rebuilt from user input.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* if (new URL(req.url).searchParams.get('error')) {
|
|
13
|
+
* return Response.redirect(authorizationErrorRedirect(original, 'access_denied'), 302);
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
function authorizationErrorRedirect(request, code, description) {
|
|
18
|
+
const redirect = new URL(request.redirectUri);
|
|
19
|
+
for (const key of ERROR_RESPONSE_PARAMETERS) redirect.searchParams.delete(key);
|
|
20
|
+
redirect.searchParams.set("error", code);
|
|
21
|
+
if (description) redirect.searchParams.set("error_description", description);
|
|
22
|
+
if (request.state) redirect.searchParams.set("state", request.state);
|
|
23
|
+
if (request.issuer) redirect.searchParams.set("iss", request.issuer);
|
|
24
|
+
return redirect.href;
|
|
25
|
+
}
|
|
26
|
+
const ERROR_RESPONSE_PARAMETERS = [
|
|
27
|
+
"error",
|
|
28
|
+
"error_description",
|
|
29
|
+
"error_uri",
|
|
30
|
+
"state",
|
|
31
|
+
"iss"
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
6
34
|
* Expected authorization-request validation failure. Absence of `redirectUri`
|
|
7
35
|
* means a caller MUST render locally and MUST NOT redirect.
|
|
8
36
|
*/
|
|
@@ -15,8 +43,21 @@ var AuthorizationError = class extends Error {
|
|
|
15
43
|
this.redirectUri = options.redirectUri;
|
|
16
44
|
this.state = options.state;
|
|
17
45
|
this.issuer = options.issuer;
|
|
46
|
+
this.redirectTo = options.redirectUri ? safeErrorRedirect(code, options) : void 0;
|
|
18
47
|
}
|
|
19
48
|
};
|
|
49
|
+
/** The error redirect, or undefined when the registered URI can't be parsed: render locally then. */
|
|
50
|
+
function safeErrorRedirect(code, options) {
|
|
51
|
+
try {
|
|
52
|
+
return authorizationErrorRedirect({
|
|
53
|
+
redirectUri: options.redirectUri,
|
|
54
|
+
state: options.state,
|
|
55
|
+
issuer: options.issuer
|
|
56
|
+
}, code, options.description);
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
20
61
|
/** @internal Attach context after exact client redirect validation succeeds. */
|
|
21
62
|
function withAuthorizationRedirect(error, redirectUri, state, issuer) {
|
|
22
63
|
return new AuthorizationError(error.code, {
|
|
@@ -31,17 +72,17 @@ function buildOAuthServerCapabilities(options) {
|
|
|
31
72
|
grantTypes: [
|
|
32
73
|
"authorization_code",
|
|
33
74
|
"refresh_token",
|
|
34
|
-
...options.allowImplicitFlow ? ["implicit"] : [],
|
|
35
75
|
...options.allowTokenExchangeGrant ? ["urn:ietf:params:oauth:grant-type:token-exchange"] : [],
|
|
36
76
|
...options.enterpriseManagedAuthorization ? ["urn:ietf:params:oauth:grant-type:jwt-bearer"] : []
|
|
37
77
|
],
|
|
38
|
-
responseTypes:
|
|
78
|
+
responseTypes: ["code"],
|
|
39
79
|
tokenEndpointAuthMethods: [
|
|
40
80
|
"client_secret_basic",
|
|
41
81
|
"client_secret_post",
|
|
42
82
|
"none"
|
|
43
83
|
],
|
|
44
|
-
codeChallengeMethods:
|
|
84
|
+
codeChallengeMethods: ["S256"],
|
|
85
|
+
allowPrivateUseRedirectUris: options.allowPrivateUseRedirectUris
|
|
45
86
|
};
|
|
46
87
|
}
|
|
47
88
|
function validateClientCapabilities(server, client) {
|
|
@@ -51,7 +92,6 @@ function validateClientCapabilities(server, client) {
|
|
|
51
92
|
const unsupportedResponse = client.responseTypes.find((responseType) => !server.responseTypes.includes(responseType));
|
|
52
93
|
if (unsupportedResponse) throw new Error(`Unsupported response_type: ${unsupportedResponse}`);
|
|
53
94
|
if (client.grantTypes.includes("authorization_code") !== client.responseTypes.includes("code")) throw new Error("grant_types authorization_code and response_types code must be registered together");
|
|
54
|
-
if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
|
|
55
95
|
}
|
|
56
96
|
const SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = new Set([
|
|
57
97
|
"client_secret_basic",
|
|
@@ -150,6 +190,212 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
150
190
|
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
151
191
|
}
|
|
152
192
|
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/oauth-http.ts
|
|
195
|
+
/**
|
|
196
|
+
* HTTP details shared by the authorization-server host (`OAuthProvider`, `OAuthAuthorizationServer`)
|
|
197
|
+
* and the resource-server host (`OAuthResourceServer`), so the two can't drift apart.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* Adds CORS headers for a browser client. The request's `Origin` is reflected: bearer tokens are
|
|
201
|
+
* not ambient credentials, so any origin may present one it holds. Browser OAuth and MCP clients
|
|
202
|
+
* need `WWW-Authenticate` (discovery, step-up) and `Retry-After` (backoff) exposed. Headers the
|
|
203
|
+
* response already carries are kept, so an API handler can narrow who may read its responses.
|
|
204
|
+
* Preflights (`OPTIONS`) are answered by the hosts before any handler runs and stay permissive, so
|
|
205
|
+
* an MCP client's preflight always succeeds; the browser enforces the handler's policy on the actual
|
|
206
|
+
* response, and sending the request carries no ambient credential to abuse.
|
|
207
|
+
*/
|
|
208
|
+
function withCorsHeaders(response, request) {
|
|
209
|
+
const origin = request.headers.get("Origin");
|
|
210
|
+
if (!origin) return response;
|
|
211
|
+
const cors = new Response(response.body, response);
|
|
212
|
+
const setUnlessPresent = (name, value) => {
|
|
213
|
+
if (!cors.headers.has(name)) cors.headers.set(name, value);
|
|
214
|
+
};
|
|
215
|
+
setUnlessPresent("Access-Control-Allow-Origin", origin);
|
|
216
|
+
setUnlessPresent("Access-Control-Allow-Methods", "*");
|
|
217
|
+
setUnlessPresent("Access-Control-Allow-Headers", "Authorization, *");
|
|
218
|
+
setUnlessPresent("Access-Control-Max-Age", "86400");
|
|
219
|
+
appendHeaderValue(cors.headers, "Vary", "Origin");
|
|
220
|
+
appendHeaderValue(cors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
|
|
221
|
+
appendHeaderValue(cors.headers, "Access-Control-Expose-Headers", "Retry-After");
|
|
222
|
+
return cors;
|
|
223
|
+
}
|
|
224
|
+
/** Adds `value` to a comma-separated header unless it's already there (case-insensitively). */
|
|
225
|
+
function appendHeaderValue(headers, name, value) {
|
|
226
|
+
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
227
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
228
|
+
headers.set(name, values.join(", "));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A resource's required scopes: `requiredScopes`, or the deprecated `resourceMetadata.scopes_supported`
|
|
232
|
+
* it replaces. Both at once is ambiguous and refused.
|
|
233
|
+
*/
|
|
234
|
+
function resolveRequiredScopes(requiredScopes, deprecatedScopesSupported) {
|
|
235
|
+
if (requiredScopes !== void 0 && deprecatedScopesSupported !== void 0) throw new TypeError("Set requiredScopes only: resourceMetadata.scopes_supported is deprecated in its favour");
|
|
236
|
+
const scopes = requiredScopes ?? deprecatedScopesSupported;
|
|
237
|
+
return scopes === void 0 ? void 0 : [...scopes];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The scopes a protected resource advertises and challenges with: deduplicated, and without
|
|
241
|
+
* `offline_access`, which is an authorization-server capability rather than a resource requirement.
|
|
242
|
+
*/
|
|
243
|
+
function baselineResourceScopes(scopes) {
|
|
244
|
+
return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/oauth-error.ts
|
|
249
|
+
/**
|
|
250
|
+
* The OAuth error your code throws to the library, to answer with a standard OAuth error instead
|
|
251
|
+
* of a generic failure. It is honoured in two places:
|
|
252
|
+
*
|
|
253
|
+
* - **`tokenExchangeCallback`** (token endpoint): the response is `{ error, error_description }`
|
|
254
|
+
* with `statusCode` (default `400`) and `headers`. `invalid_grant` also revokes the grant.
|
|
255
|
+
* Anything else thrown is a `500`.
|
|
256
|
+
* - **`OAuthResourceServer`'s `validateToken`**: `invalid_token` is a `401` and
|
|
257
|
+
* `insufficient_scope` a `403`, each with a Bearer challenge (the latter naming
|
|
258
|
+
* `requiredScopes`); any other code keeps `statusCode` and `headers`, such as `429` with
|
|
259
|
+
* `Retry-After`. Anything else thrown is a `503`.
|
|
260
|
+
*
|
|
261
|
+
* Unexpected failures stay visible that way: the library doesn't catch everything and return 400.
|
|
262
|
+
* (`OAuthProvider`'s `resolveExternalToken` uses `ExternalTokenError` instead.)
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```ts
|
|
266
|
+
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
267
|
+
*
|
|
268
|
+
* tokenExchangeCallback: async (options) => {
|
|
269
|
+
* if (options.grantType === 'refresh_token') {
|
|
270
|
+
* // refreshUpstream() may throw OAuthError from any depth
|
|
271
|
+
* return { newProps: await refreshUpstream(options.props) };
|
|
272
|
+
* }
|
|
273
|
+
* }
|
|
274
|
+
*
|
|
275
|
+
* async function refreshUpstream(props) {
|
|
276
|
+
* const res = await fetch(...);
|
|
277
|
+
* if (res.status === 401) {
|
|
278
|
+
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
279
|
+
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
280
|
+
* }
|
|
281
|
+
* if (res.status === 429) {
|
|
282
|
+
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
283
|
+
* throw new OAuthError('temporarily_unavailable', {
|
|
284
|
+
* description: 'upstream rate limited',
|
|
285
|
+
* statusCode: 429,
|
|
286
|
+
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
287
|
+
* });
|
|
288
|
+
* }
|
|
289
|
+
* return await res.json();
|
|
290
|
+
* }
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
var OAuthError = class extends Error {
|
|
294
|
+
constructor(code, options) {
|
|
295
|
+
super(options.description);
|
|
296
|
+
this.name = "OAuthError";
|
|
297
|
+
this.code = code;
|
|
298
|
+
this.options = {
|
|
299
|
+
...options,
|
|
300
|
+
statusCode: options.statusCode ?? 400
|
|
301
|
+
};
|
|
302
|
+
this.description = this.options.description;
|
|
303
|
+
this.statusCode = this.options.statusCode;
|
|
304
|
+
this.headers = this.options.headers;
|
|
305
|
+
this.requiredScopes = this.options.requiredScopes;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/oauth-resource.ts
|
|
311
|
+
/**
|
|
312
|
+
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
313
|
+
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
314
|
+
*/
|
|
315
|
+
function isLoopbackHostname(hostname) {
|
|
316
|
+
const host = hostname.toLowerCase();
|
|
317
|
+
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
318
|
+
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
319
|
+
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
323
|
+
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
324
|
+
* http://localhost. Workers are always served over `https`, so there is no
|
|
325
|
+
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
326
|
+
* and no option exists to allow it.
|
|
327
|
+
*/
|
|
328
|
+
function hasAcceptedCanonicalScheme(url) {
|
|
329
|
+
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
330
|
+
}
|
|
331
|
+
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
332
|
+
function validateResourceUri(uri) {
|
|
333
|
+
if (!uri || typeof uri !== "string") return false;
|
|
334
|
+
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
335
|
+
try {
|
|
336
|
+
const parsed = new URL(uri);
|
|
337
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Whether a requested resource identifies a granted or configured resource.
|
|
344
|
+
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
345
|
+
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
346
|
+
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
347
|
+
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
348
|
+
* information, and every other byte remain significant.
|
|
349
|
+
*/
|
|
350
|
+
function resourceMatches(requested, granted) {
|
|
351
|
+
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
352
|
+
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
353
|
+
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
354
|
+
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Fold only the URI components whose comparison is ASCII case-insensitive: the
|
|
358
|
+
* scheme and the host. A configured identifier is canonical when folding it is
|
|
359
|
+
* the identity.
|
|
360
|
+
*/
|
|
361
|
+
function foldResourceSchemeAndHost(resource) {
|
|
362
|
+
const schemeSeparator = resource.indexOf("://");
|
|
363
|
+
if (schemeSeparator <= 0) return void 0;
|
|
364
|
+
const authorityStart = schemeSeparator + 3;
|
|
365
|
+
const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
366
|
+
const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
|
|
367
|
+
const authority = resource.slice(authorityStart, authorityEnd);
|
|
368
|
+
const hostStart = authority.lastIndexOf("@") + 1;
|
|
369
|
+
let hostEnd;
|
|
370
|
+
if (authority[hostStart] === "[") {
|
|
371
|
+
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
372
|
+
if (closingBracket === -1) return void 0;
|
|
373
|
+
hostEnd = closingBracket + 1;
|
|
374
|
+
} else {
|
|
375
|
+
const portSeparator = authority.indexOf(":", hostStart);
|
|
376
|
+
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
377
|
+
}
|
|
378
|
+
const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
379
|
+
return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
|
|
380
|
+
}
|
|
381
|
+
/** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
|
|
382
|
+
function normalizeEmptyPath(resource) {
|
|
383
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
384
|
+
const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
385
|
+
if (pathOffset === -1) return `${resource}/`;
|
|
386
|
+
const pathStart = authorityStart + pathOffset;
|
|
387
|
+
return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Whether a request carries every query parameter of a canonical resource. A
|
|
391
|
+
* query-bearing resource identifier names a more specific resource: a request
|
|
392
|
+
* may add parameters of its own but must preserve the resource's.
|
|
393
|
+
*/
|
|
394
|
+
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
395
|
+
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
153
399
|
//#endregion
|
|
154
400
|
//#region src/oauth-client-metadata.ts
|
|
155
401
|
const CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
@@ -245,10 +491,12 @@ function pickDisplayMetadata(metadata) {
|
|
|
245
491
|
};
|
|
246
492
|
}
|
|
247
493
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
494
|
+
* The checks every listed redirect URI passes, whatever the policy: no control characters, a
|
|
495
|
+
* scheme, and not a dangerous one (`javascript:`, `data:` and the like). A Client ID Metadata
|
|
496
|
+
* Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
|
|
497
|
+
* @throws Error when the redirect URI is not safe to store
|
|
250
498
|
*/
|
|
251
|
-
function
|
|
499
|
+
function validateRedirectUriSafety(redirectUri) {
|
|
252
500
|
const dangerousSchemes = [
|
|
253
501
|
"javascript:",
|
|
254
502
|
"data:",
|
|
@@ -267,9 +515,34 @@ function validateRedirectUriScheme(redirectUri) {
|
|
|
267
515
|
const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
|
|
268
516
|
if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
|
|
269
517
|
}
|
|
270
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
|
|
520
|
+
* loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
|
|
521
|
+
* empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
|
|
522
|
+
* `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
|
|
523
|
+
* checks always apply.
|
|
524
|
+
* @throws Error describing why the redirect URI is not acceptable
|
|
525
|
+
*/
|
|
526
|
+
function validateRedirectUri(redirectUri, server) {
|
|
527
|
+
validateRedirectUriSafety(redirectUri);
|
|
528
|
+
const normalized = redirectUri.trim();
|
|
529
|
+
let url;
|
|
530
|
+
try {
|
|
531
|
+
url = new URL(normalized);
|
|
532
|
+
} catch {
|
|
533
|
+
throw new Error("Invalid redirect URI");
|
|
534
|
+
}
|
|
535
|
+
if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
|
|
536
|
+
if (url.protocol === "https:") return;
|
|
537
|
+
if (url.protocol === "http:") {
|
|
538
|
+
if (isLoopbackHostname(url.hostname)) return;
|
|
539
|
+
throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
540
|
+
}
|
|
541
|
+
if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
542
|
+
}
|
|
543
|
+
function requireValidRedirectUris(redirectUris, validate) {
|
|
271
544
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
272
|
-
for (const redirectUri of redirectUris)
|
|
545
|
+
for (const redirectUri of redirectUris) validate(redirectUri);
|
|
273
546
|
return redirectUris;
|
|
274
547
|
}
|
|
275
548
|
/**
|
|
@@ -287,7 +560,7 @@ function resolveDynamicClientRegistrationMetadata(raw, server) {
|
|
|
287
560
|
});
|
|
288
561
|
return {
|
|
289
562
|
...pickDisplayMetadata(metadata),
|
|
290
|
-
redirectUris: requireValidRedirectUris(metadata.redirectUris),
|
|
563
|
+
redirectUris: requireValidRedirectUris(metadata.redirectUris, (uri) => validateRedirectUri(uri, server)),
|
|
291
564
|
...capabilities,
|
|
292
565
|
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
293
566
|
};
|
|
@@ -366,7 +639,7 @@ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
|
|
|
366
639
|
const metadata = parseOAuthClientMetadata(raw);
|
|
367
640
|
if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
368
641
|
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
369
|
-
const redirectUris = requireValidRedirectUris(metadata.redirectUris);
|
|
642
|
+
const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
|
|
370
643
|
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
371
644
|
if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
|
|
372
645
|
const capabilities = negotiateCimdClientCapabilities(server, {
|
|
@@ -678,7 +951,7 @@ function emaErrorToWire(e) {
|
|
|
678
951
|
* only through the adapters' public interfaces.
|
|
679
952
|
*/
|
|
680
953
|
/** SHA-256 a string and return its hex digest. */
|
|
681
|
-
async function sha256Hex(input) {
|
|
954
|
+
async function sha256Hex$1(input) {
|
|
682
955
|
const data = new TextEncoder().encode(input);
|
|
683
956
|
const buffer = await crypto.subtle.digest("SHA-256", data);
|
|
684
957
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -701,7 +974,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
|
|
|
701
974
|
function createKvJtiStore() {
|
|
702
975
|
return { async markUsed({ issuer, jti, exp, now, env }) {
|
|
703
976
|
const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
|
|
704
|
-
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
|
|
977
|
+
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
|
|
705
978
|
if (await env.OAUTH_KV.get(key)) return err({
|
|
706
979
|
reason: "replayed",
|
|
707
980
|
jti
|
|
@@ -850,16 +1123,34 @@ function parseIdJag(assertion, maxBytes) {
|
|
|
850
1123
|
signature
|
|
851
1124
|
});
|
|
852
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Decodes a base64url-encoded string to bytes.
|
|
1128
|
+
*/
|
|
1129
|
+
function base64UrlToBytes(base64Url) {
|
|
1130
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
1131
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
1132
|
+
const binaryString = atob(padded);
|
|
1133
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1134
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
1135
|
+
return bytes;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Parses a base64url-encoded JWT JSON part into an object.
|
|
1139
|
+
*/
|
|
1140
|
+
function parseJwtJsonPart(encoded) {
|
|
1141
|
+
try {
|
|
1142
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
1143
|
+
const parsed = JSON.parse(json);
|
|
1144
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
1145
|
+
return parsed;
|
|
1146
|
+
} catch {
|
|
1147
|
+
throw new Error("Malformed JWT part");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
853
1150
|
|
|
854
1151
|
//#endregion
|
|
855
1152
|
//#region src/ema/signature.ts
|
|
856
1153
|
/**
|
|
857
|
-
* JWK selection and ID-JAG signature verification.
|
|
858
|
-
*
|
|
859
|
-
* `selectJwk` is a pure picker; `verifyIdJagSignature` is the I/O-bearing
|
|
860
|
-
* WebCrypto call. Both operate over an already-fetched JWKS.
|
|
861
|
-
*/
|
|
862
|
-
/**
|
|
863
1154
|
* Pick a signing key from a JWKS that matches the assertion header.
|
|
864
1155
|
*
|
|
865
1156
|
* Filters by:
|
|
@@ -910,95 +1201,31 @@ async function verifyIdJagSignature(input) {
|
|
|
910
1201
|
return false;
|
|
911
1202
|
}
|
|
912
1203
|
}
|
|
913
|
-
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/oauth-resource.ts
|
|
916
|
-
/**
|
|
917
|
-
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
918
|
-
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
919
|
-
*/
|
|
920
|
-
function isLoopbackHostname(hostname) {
|
|
921
|
-
const host = hostname.toLowerCase();
|
|
922
|
-
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
923
|
-
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
924
|
-
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
925
|
-
}
|
|
926
|
-
/**
|
|
927
|
-
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
928
|
-
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
929
|
-
* http://localhost. Workers are always served over `https`, so there is no
|
|
930
|
-
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
931
|
-
* and no option exists to allow it.
|
|
932
|
-
*/
|
|
933
|
-
function hasAcceptedCanonicalScheme(url) {
|
|
934
|
-
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
935
|
-
}
|
|
936
|
-
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
937
|
-
function validateResourceUri(uri) {
|
|
938
|
-
if (!uri || typeof uri !== "string") return false;
|
|
939
|
-
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
940
|
-
try {
|
|
941
|
-
const parsed = new URL(uri);
|
|
942
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
943
|
-
} catch {
|
|
944
|
-
return false;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
/**
|
|
948
|
-
* Whether a requested resource identifies a granted or configured resource.
|
|
949
|
-
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
950
|
-
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
951
|
-
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
952
|
-
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
953
|
-
* information, and every other byte remain significant.
|
|
954
|
-
*/
|
|
955
|
-
function resourceMatches(requested, granted) {
|
|
956
|
-
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
957
|
-
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
958
|
-
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
959
|
-
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
960
|
-
}
|
|
961
1204
|
/**
|
|
962
|
-
*
|
|
963
|
-
* scheme and the host. A configured identifier is canonical when folding it is
|
|
964
|
-
* the identity.
|
|
1205
|
+
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
965
1206
|
*/
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
977
|
-
if (closingBracket === -1) return void 0;
|
|
978
|
-
hostEnd = closingBracket + 1;
|
|
979
|
-
} else {
|
|
980
|
-
const portSeparator = authority.indexOf(":", hostStart);
|
|
981
|
-
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
1207
|
+
function getJwtCryptoAlgorithms(alg) {
|
|
1208
|
+
if (alg === "RS256") {
|
|
1209
|
+
const algorithm = {
|
|
1210
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
1211
|
+
hash: "SHA-256"
|
|
1212
|
+
};
|
|
1213
|
+
return {
|
|
1214
|
+
importAlgorithm: algorithm,
|
|
1215
|
+
verifyAlgorithm: algorithm
|
|
1216
|
+
};
|
|
982
1217
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
/**
|
|
995
|
-
* Whether a request carries every query parameter of a canonical resource. A
|
|
996
|
-
* query-bearing resource identifier names a more specific resource: a request
|
|
997
|
-
* may add parameters of its own but must preserve the resource's.
|
|
998
|
-
*/
|
|
999
|
-
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
1000
|
-
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
1001
|
-
return true;
|
|
1218
|
+
if (alg === "ES256") return {
|
|
1219
|
+
importAlgorithm: {
|
|
1220
|
+
name: "ECDSA",
|
|
1221
|
+
namedCurve: "P-256"
|
|
1222
|
+
},
|
|
1223
|
+
verifyAlgorithm: {
|
|
1224
|
+
name: "ECDSA",
|
|
1225
|
+
hash: "SHA-256"
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
1002
1229
|
}
|
|
1003
1230
|
|
|
1004
1231
|
//#endregion
|
|
@@ -1336,7 +1563,7 @@ const NO_CACHE_HEADERS$1 = {
|
|
|
1336
1563
|
* ```
|
|
1337
1564
|
*/
|
|
1338
1565
|
function insufficientScope(auth, scope, description) {
|
|
1339
|
-
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => !
|
|
1566
|
+
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => typeof value !== "string" || !isValidOAuthScopeToken(value))) throw new TypeError("insufficientScope requires at least one valid OAuth scope token");
|
|
1340
1567
|
let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
|
|
1341
1568
|
challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
|
|
1342
1569
|
if (description !== void 0) {
|
|
@@ -1376,35 +1603,36 @@ var OAuthResourceServer = class {
|
|
|
1376
1603
|
const validated = this.#validated;
|
|
1377
1604
|
const url = new URL(request.url);
|
|
1378
1605
|
if (isProtectedResourceMetadataPath(url)) {
|
|
1379
|
-
if (request.method === "OPTIONS") return
|
|
1606
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1380
1607
|
status: 204,
|
|
1381
1608
|
headers: { "Content-Length": "0" }
|
|
1382
1609
|
}), request);
|
|
1383
|
-
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return
|
|
1384
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
1610
|
+
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
1611
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
1385
1612
|
status: 405,
|
|
1386
1613
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1387
1614
|
}), request);
|
|
1388
1615
|
const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
|
|
1389
|
-
return
|
|
1616
|
+
return withCorsHeaders(request.method === "HEAD" ? new Response(null, {
|
|
1390
1617
|
status: 200,
|
|
1391
1618
|
headers: metadata.headers
|
|
1392
1619
|
}) : metadata, request);
|
|
1393
1620
|
}
|
|
1394
1621
|
if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
|
|
1395
|
-
if (request.method === "OPTIONS") return
|
|
1622
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1396
1623
|
status: 204,
|
|
1397
1624
|
headers: { "Content-Length": "0" }
|
|
1398
1625
|
}), request);
|
|
1399
1626
|
const token = parseBearerToken(request.headers.get("Authorization"));
|
|
1400
|
-
if (!token) return
|
|
1627
|
+
if (!token) return withCorsHeaders(createBearerChallenge(url, validated, false), request);
|
|
1401
1628
|
let validation;
|
|
1402
1629
|
try {
|
|
1403
1630
|
validation = await options.validateToken(env, request)(validated.resource, token);
|
|
1404
|
-
} catch {
|
|
1405
|
-
return
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
if (error instanceof OAuthError) return withCorsHeaders(createOAuthErrorResponse(error, url, validated), request);
|
|
1633
|
+
return withCorsHeaders(createValidationUnavailableResponse(), request);
|
|
1406
1634
|
}
|
|
1407
|
-
if (!isValidTokenValidation(validation, validated.resource)) return
|
|
1635
|
+
if (!isValidTokenValidation(validation, validated.resource)) return withCorsHeaders(createBearerChallenge(url, validated, true), request);
|
|
1408
1636
|
const context = ctx;
|
|
1409
1637
|
context.props = validation.props;
|
|
1410
1638
|
context.auth = {
|
|
@@ -1416,7 +1644,7 @@ var OAuthResourceServer = class {
|
|
|
1416
1644
|
...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
|
|
1417
1645
|
};
|
|
1418
1646
|
const handler = options.handler;
|
|
1419
|
-
return
|
|
1647
|
+
return withCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
|
|
1420
1648
|
}
|
|
1421
1649
|
};
|
|
1422
1650
|
function validateOptions(options) {
|
|
@@ -1435,9 +1663,9 @@ function validateOptions(options) {
|
|
|
1435
1663
|
}
|
|
1436
1664
|
const bearerMethods = options.resourceMetadata.bearer_methods_supported;
|
|
1437
1665
|
if (bearerMethods !== void 0 && (bearerMethods.length !== 1 || bearerMethods[0] !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
1438
|
-
const configuredScopes = options.resourceMetadata.scopes_supported ?? [];
|
|
1439
|
-
if (configuredScopes.some((scope) => !
|
|
1440
|
-
const resourceScopes =
|
|
1666
|
+
const configuredScopes = resolveRequiredScopes(options.requiredScopes, options.resourceMetadata.scopes_supported) ?? [];
|
|
1667
|
+
if (configuredScopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) throw new TypeError("requiredScopes (or the deprecated resourceMetadata.scopes_supported) must contain valid OAuth scope tokens");
|
|
1668
|
+
const resourceScopes = baselineResourceScopes(configuredScopes);
|
|
1441
1669
|
return {
|
|
1442
1670
|
resource,
|
|
1443
1671
|
resourceUrl,
|
|
@@ -1463,9 +1691,6 @@ function parseCanonicalUrl(value) {
|
|
|
1463
1691
|
if (parsed.href !== value && parsed.origin !== value) return null;
|
|
1464
1692
|
return parsed;
|
|
1465
1693
|
}
|
|
1466
|
-
function isValidScopeToken(scope) {
|
|
1467
|
-
return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
|
|
1468
|
-
}
|
|
1469
1694
|
function getResourceMetadataUrl(resource) {
|
|
1470
1695
|
const parsed = new URL(resource);
|
|
1471
1696
|
const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
@@ -1504,7 +1729,7 @@ function isValidTokenValidation(validation, canonicalResource) {
|
|
|
1504
1729
|
}
|
|
1505
1730
|
if (validation.scope !== void 0) {
|
|
1506
1731
|
if (!Array.isArray(validation.scope)) return false;
|
|
1507
|
-
if (validation.scope.some((scope) => typeof scope !== "string" || !
|
|
1732
|
+
if (validation.scope.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return false;
|
|
1508
1733
|
}
|
|
1509
1734
|
if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
|
|
1510
1735
|
if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
|
|
@@ -1524,29 +1749,365 @@ function createBearerChallenge(requestUrl, validated, invalidToken) {
|
|
|
1524
1749
|
}
|
|
1525
1750
|
});
|
|
1526
1751
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1752
|
+
/**
|
|
1753
|
+
* A validator's `OAuthError` as the response. RFC 6750 fixes `invalid_token` at `401` and
|
|
1754
|
+
* `insufficient_scope` at `403`, each with a Bearer challenge (unless the error brings its own);
|
|
1755
|
+
* any other code keeps the error's status and headers, such as a `429` with `Retry-After`.
|
|
1756
|
+
*/
|
|
1757
|
+
function createOAuthErrorResponse(error, requestUrl, validated) {
|
|
1758
|
+
const headers = {
|
|
1759
|
+
...NO_CACHE_HEADERS$1,
|
|
1760
|
+
"Content-Type": "application/json",
|
|
1761
|
+
...error.headers
|
|
1762
|
+
};
|
|
1763
|
+
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
1764
|
+
let status = error.statusCode;
|
|
1765
|
+
if (error.code === "invalid_token") {
|
|
1766
|
+
status = 401;
|
|
1767
|
+
if (!hasChallenge) headers["WWW-Authenticate"] = createBearerChallenge(requestUrl, validated, true).headers.get("WWW-Authenticate");
|
|
1768
|
+
} else if (error.code === "insufficient_scope") {
|
|
1769
|
+
status = 403;
|
|
1770
|
+
if (!hasChallenge) {
|
|
1771
|
+
const scopes = [...new Set(error.requiredScopes ?? validated.metadata.scopes_supported ?? [])];
|
|
1772
|
+
if (scopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return createValidationUnavailableResponse();
|
|
1773
|
+
headers["WWW-Authenticate"] = "Bearer realm=\"OAuth\", error=\"insufficient_scope\"" + (scopes.length > 0 ? `, scope="${scopes.join(" ")}"` : "") + `, resource_metadata="${validated.metadataUrl.href}"`;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return new Response(JSON.stringify({
|
|
1777
|
+
error: error.code,
|
|
1778
|
+
error_description: error.description
|
|
1779
|
+
}), {
|
|
1780
|
+
status,
|
|
1781
|
+
headers
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
function createValidationUnavailableResponse() {
|
|
1785
|
+
return new Response(null, {
|
|
1786
|
+
status: 503,
|
|
1787
|
+
headers: NO_CACHE_HEADERS$1
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/oauth-consent.ts
|
|
1793
|
+
/**
|
|
1794
|
+
* Consent and upstream-state primitives for authorization servers that sign users in through a
|
|
1795
|
+
* third-party OAuth provider (an MCP "proxy" server). They implement the protections the MCP
|
|
1796
|
+
* 2026-07-28 security best practices require under Confused Deputy → Mitigation:
|
|
1797
|
+
*
|
|
1798
|
+
* - consent per client before any redirect to the third party, with CSRF protection and anti-framing
|
|
1799
|
+
* headers on the consent page;
|
|
1800
|
+
* - remembered consent, chosen per call, in a signed `__Host-` cookie bound to the client, its redirect
|
|
1801
|
+
* URI and resource (and the user, when the caller knows them), reused only for a subset of the
|
|
1802
|
+
* approved scopes;
|
|
1803
|
+
* - the approved scopes chosen on the consent page, from any the server supports;
|
|
1804
|
+
* - a random `state` stored server-side only after consent, bound to the browser by a `__Host-` cookie,
|
|
1805
|
+
* single-use and short-lived.
|
|
1806
|
+
*
|
|
1807
|
+
* A transaction handle is 256 random bits. KV stores the record under the handle's SHA-256, encrypted
|
|
1808
|
+
* with a key derived from the handle, so KV alone reveals neither the request nor deployer data such as
|
|
1809
|
+
* a PKCE verifier. Each transaction has its own binding cookie, named after its hash and holding the
|
|
1810
|
+
* full hash, so several authorizations can be in flight in one browser (two tabs) without replacing each
|
|
1811
|
+
* other. Single use is `get` then `delete`, which KV cannot make atomic: two concurrent requests from the
|
|
1812
|
+
* same browser with the same handle could both pass; the cookie binding confines that to the browser
|
|
1813
|
+
* that started the transaction.
|
|
1814
|
+
*/
|
|
1815
|
+
const TRANSACTION_TTL_SECONDS = 600;
|
|
1816
|
+
/** Default for the provider's `cookiePrefix` option. */
|
|
1817
|
+
const DEFAULT_COOKIE_PREFIX = "__Host-oauth-";
|
|
1818
|
+
const DEFAULT_REMEMBER_SECONDS = 720 * 60 * 60;
|
|
1819
|
+
const MIN_SECRET_LENGTH = 32;
|
|
1820
|
+
const MAX_APPROVALS_COOKIE_BYTES = 3800;
|
|
1821
|
+
/** Throws a `TypeError` unless the prefix keeps the `__Host-` guarantees the MCP best practices require. */
|
|
1822
|
+
function consentCookies(prefix = DEFAULT_COOKIE_PREFIX) {
|
|
1823
|
+
if (typeof prefix !== "string" || !prefix.startsWith("__Host-") || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(prefix)) throw new TypeError("cookiePrefix must start with \"__Host-\" and contain only cookie-name characters");
|
|
1824
|
+
return {
|
|
1825
|
+
consent: `${prefix}consent`,
|
|
1826
|
+
upstream: `${prefix}upstream`,
|
|
1827
|
+
approvals: `${prefix}approvals`
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
function validateRememberConsentOptions(options) {
|
|
1831
|
+
if (typeof options !== "object" || options === null) throw new TypeError("remember must be an object with a secret");
|
|
1832
|
+
if (typeof options.secret !== "string" || options.secret.length < MIN_SECRET_LENGTH) throw new TypeError(`remember.secret must be a string of at least ${MIN_SECRET_LENGTH} characters`);
|
|
1833
|
+
const maxAge = options.maxAgeSeconds;
|
|
1834
|
+
if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
|
|
1835
|
+
if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
|
|
1836
|
+
}
|
|
1837
|
+
/** The facts a consent page must show for a request, given the client `lookupClient()` resolved. */
|
|
1838
|
+
function describeConsent(client, request, isClientIdMetadataDocument) {
|
|
1839
|
+
const redirectHost = new URL(request.redirectUri).hostname || request.redirectUri;
|
|
1840
|
+
return {
|
|
1841
|
+
clientId: request.clientId,
|
|
1842
|
+
clientName: client?.clientName?.trim() ? client.clientName : request.clientId,
|
|
1843
|
+
...isClientIdMetadataDocument ? { clientDomain: new URL(request.clientId).hostname } : {},
|
|
1844
|
+
...client?.clientUri ? { clientUri: client.clientUri } : {},
|
|
1845
|
+
...client?.logoUri ? { logoUri: client.logoUri } : {},
|
|
1846
|
+
redirectUri: request.redirectUri,
|
|
1847
|
+
redirectHost,
|
|
1848
|
+
redirectIsLoopback: isLoopbackHostname(redirectHost),
|
|
1849
|
+
scope: [...request.scope]
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
/** Start a consent transaction for an authorization request that must be shown to the user. */
|
|
1853
|
+
async function beginConsent(kv, cookies, request) {
|
|
1854
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1855
|
+
kind: "consent",
|
|
1856
|
+
request
|
|
1857
|
+
});
|
|
1858
|
+
return {
|
|
1859
|
+
handle,
|
|
1860
|
+
headers: new Headers({
|
|
1861
|
+
"Set-Cookie": bindingCookie(transactionCookieName(cookies.consent, hash), hash, TRANSACTION_TTL_SECONDS),
|
|
1862
|
+
"Cache-Control": "no-store",
|
|
1863
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
1864
|
+
"X-Frame-Options": "DENY"
|
|
1865
|
+
})
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Consume a consent transaction the user approved. `scope`, when given, replaces the requested
|
|
1870
|
+
* scopes: the page may narrow them or offer more, but each must be one the server supports
|
|
1871
|
+
* (`supportedScopes`, from `scopesSupported`). `remember` stores the approval in a signed cookie.
|
|
1872
|
+
*/
|
|
1873
|
+
async function approveConsent(kv, cookies, supportedScopes, request, handle, options = {}) {
|
|
1874
|
+
if (options.remember !== void 0) validateRememberConsentOptions(options.remember);
|
|
1875
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1876
|
+
let approved = transaction.record.request;
|
|
1877
|
+
if (options.scope !== void 0) {
|
|
1878
|
+
const supported = supportedScopes ? new Set(supportedScopes) : void 0;
|
|
1879
|
+
const valid = (scope) => typeof scope === "string" && isValidOAuthScopeToken(scope) && (!supported || supported.has(scope));
|
|
1880
|
+
if (!Array.isArray(options.scope) || !options.scope.every(valid)) throw new AuthorizationError("invalid_scope", { description: "Approved scopes must be ones this server supports" });
|
|
1881
|
+
approved = {
|
|
1882
|
+
...approved,
|
|
1883
|
+
scope: [...new Set(options.scope)]
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
await transaction.consume();
|
|
1887
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1888
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1889
|
+
if (options.remember) headers.append("Set-Cookie", await rememberApproval(cookies, request, approved, options.remember));
|
|
1890
|
+
return {
|
|
1891
|
+
request: approved,
|
|
1892
|
+
headers
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Consume a consent transaction the user declined, and build the OAuth error redirect back to the
|
|
1897
|
+
* client: `error=access_denied`, the client's `state`, and `iss`. The redirect URI comes from the
|
|
1898
|
+
* stored request, which `parseAuthRequest()` validated, never from the form.
|
|
1899
|
+
*/
|
|
1900
|
+
async function denyConsent(kv, cookies, request, handle, options = {}) {
|
|
1901
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1902
|
+
await transaction.consume();
|
|
1903
|
+
const record = transaction.record;
|
|
1904
|
+
const redirectTo = authorizationErrorRedirect(record.request, "access_denied", options.description);
|
|
1905
|
+
const headers = new Headers({
|
|
1906
|
+
"Cache-Control": "no-store",
|
|
1907
|
+
Location: redirectTo
|
|
1908
|
+
});
|
|
1909
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1910
|
+
return {
|
|
1911
|
+
request: record.request,
|
|
1912
|
+
redirectTo,
|
|
1913
|
+
headers
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
/** Whether a remembered approval covers this request: same client, redirect URI and resource, subset of scopes. */
|
|
1917
|
+
async function isConsentRemembered(cookies, request, authRequest, remember) {
|
|
1918
|
+
validateRememberConsentOptions(remember);
|
|
1919
|
+
const approvals = await readApprovals(cookies, request, remember.secret);
|
|
1920
|
+
const key = await approvalKey(authRequest, remember.subject);
|
|
1921
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1922
|
+
const approval = approvals.find((entry) => entry.k === key && entry.e > now);
|
|
1923
|
+
if (!approval) return false;
|
|
1924
|
+
const approvedScopes = new Set(approval.s);
|
|
1925
|
+
return authRequest.scope.every((scope) => approvedScopes.has(scope));
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Save an approved authorization request before redirecting to the third-party provider, and get
|
|
1929
|
+
* the `state` to send it. Call only after consent. Pass `headers` to add the binding cookie to
|
|
1930
|
+
* headers you are already sending, such as `approveConsent()`'s.
|
|
1931
|
+
*/
|
|
1932
|
+
async function beginUpstream(kv, cookies, request, options = {}) {
|
|
1933
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1934
|
+
kind: "upstream",
|
|
1935
|
+
request,
|
|
1936
|
+
data: options.data
|
|
1937
|
+
});
|
|
1938
|
+
const headers = options.headers ?? new Headers();
|
|
1939
|
+
headers.append("Set-Cookie", bindingCookie(transactionCookieName(cookies.upstream, hash), hash, TRANSACTION_TTL_SECONDS));
|
|
1940
|
+
headers.set("Cache-Control", "no-store");
|
|
1941
|
+
return {
|
|
1942
|
+
state: handle,
|
|
1943
|
+
headers
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
/** Recover the authorization request at the third-party provider's callback, from its `state` parameter. */
|
|
1947
|
+
async function finishUpstream(kv, cookies, request) {
|
|
1948
|
+
const state = new URL(request.url).searchParams.get("state");
|
|
1949
|
+
if (!state) throw new AuthorizationError("invalid_request", { description: "Missing state parameter" });
|
|
1950
|
+
const transaction = await openTransaction(kv, request, cookies.upstream, state, "upstream");
|
|
1951
|
+
await transaction.consume();
|
|
1952
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1953
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1954
|
+
return {
|
|
1955
|
+
request: transaction.record.request,
|
|
1956
|
+
data: transaction.record.data,
|
|
1957
|
+
headers
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
async function createTransaction(kv, record) {
|
|
1961
|
+
const handle = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
1962
|
+
const hash = await sha256Hex(handle);
|
|
1963
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1964
|
+
const sealed = await crypto.subtle.encrypt({
|
|
1965
|
+
name: "AES-GCM",
|
|
1966
|
+
iv
|
|
1967
|
+
}, await transactionKey(handle), new TextEncoder().encode(JSON.stringify(record)));
|
|
1968
|
+
await kv.put(`transaction:${hash}`, `${base64url(iv)}.${base64url(new Uint8Array(sealed))}`, { expirationTtl: TRANSACTION_TTL_SECONDS });
|
|
1969
|
+
return {
|
|
1970
|
+
handle,
|
|
1971
|
+
hash
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
async function openTransaction(kv, request, cookieBase, handle, kind) {
|
|
1975
|
+
if (typeof handle !== "string" || handle.length === 0) throw new AuthorizationError("invalid_request", { description: "Missing transaction handle" });
|
|
1976
|
+
const hash = await sha256Hex(handle);
|
|
1977
|
+
const cookieName = transactionCookieName(cookieBase, hash);
|
|
1978
|
+
const bound = readCookie(request, cookieName);
|
|
1979
|
+
if (!bound) throw new AuthorizationError("invalid_request", { description: "This authorization was not started in this browser; start again" });
|
|
1980
|
+
if (!timingSafeEqual(hash, bound)) throw new AuthorizationError("invalid_request", { description: "This authorization belongs to a different browser session; start again" });
|
|
1981
|
+
const key = `transaction:${hash}`;
|
|
1982
|
+
const expired = new AuthorizationError("invalid_request", { description: "This authorization expired or was already used; start again" });
|
|
1983
|
+
const stored = await kv.get(key);
|
|
1984
|
+
if (!stored) throw expired;
|
|
1985
|
+
let record;
|
|
1986
|
+
try {
|
|
1987
|
+
const [iv, sealed] = stored.split(".");
|
|
1988
|
+
const plain = await crypto.subtle.decrypt({
|
|
1989
|
+
name: "AES-GCM",
|
|
1990
|
+
iv: fromBase64url(iv)
|
|
1991
|
+
}, await transactionKey(handle), fromBase64url(sealed));
|
|
1992
|
+
record = JSON.parse(new TextDecoder().decode(plain));
|
|
1993
|
+
} catch {
|
|
1994
|
+
throw expired;
|
|
1995
|
+
}
|
|
1996
|
+
if (record.kind !== kind) throw expired;
|
|
1997
|
+
return {
|
|
1998
|
+
record,
|
|
1999
|
+
cookieName,
|
|
2000
|
+
consume: () => kv.delete(key)
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
/** Only the holder of the handle can decrypt its record; KV keeps the hash, not the handle. */
|
|
2004
|
+
async function transactionKey(handle) {
|
|
2005
|
+
const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`oauth-transaction-key:${handle}`));
|
|
2006
|
+
return crypto.subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
2007
|
+
}
|
|
2008
|
+
/** One binding cookie per transaction, so concurrent authorizations in one browser don't collide. */
|
|
2009
|
+
function transactionCookieName(base, hash) {
|
|
2010
|
+
return `${base}-${hash.slice(0, 16)}`;
|
|
2011
|
+
}
|
|
2012
|
+
async function rememberApproval(cookies, request, approved, remember) {
|
|
2013
|
+
const maxAge = remember.maxAgeSeconds ?? DEFAULT_REMEMBER_SECONDS;
|
|
2014
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2015
|
+
const key = await approvalKey(approved, remember.subject);
|
|
2016
|
+
const approvals = (await readApprovals(cookies, request, remember.secret)).filter((entry) => entry.e > now && entry.k !== key);
|
|
2017
|
+
approvals.push({
|
|
2018
|
+
k: key,
|
|
2019
|
+
s: approved.scope,
|
|
2020
|
+
e: now + maxAge
|
|
2021
|
+
});
|
|
2022
|
+
let value = await signApprovals(approvals, remember.secret);
|
|
2023
|
+
while (value.length > MAX_APPROVALS_COOKIE_BYTES && approvals.length > 1) {
|
|
2024
|
+
approvals.shift();
|
|
2025
|
+
value = await signApprovals(approvals, remember.secret);
|
|
2026
|
+
}
|
|
2027
|
+
const cookieMaxAge = Math.max(...approvals.map((entry) => entry.e)) - now;
|
|
2028
|
+
return `${cookies.approvals}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${cookieMaxAge}`;
|
|
2029
|
+
}
|
|
2030
|
+
async function readApprovals(cookies, request, secret) {
|
|
2031
|
+
const value = readCookie(request, cookies.approvals);
|
|
2032
|
+
if (!value) return [];
|
|
2033
|
+
const [payload, signature] = value.split(".");
|
|
2034
|
+
if (!payload || !signature) return [];
|
|
2035
|
+
const key = await hmacKey(secret);
|
|
2036
|
+
let valid = false;
|
|
2037
|
+
try {
|
|
2038
|
+
valid = await crypto.subtle.verify("HMAC", key, fromBase64url(signature), new TextEncoder().encode(payload));
|
|
2039
|
+
} catch {
|
|
2040
|
+
return [];
|
|
2041
|
+
}
|
|
2042
|
+
if (!valid) return [];
|
|
2043
|
+
try {
|
|
2044
|
+
const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
2045
|
+
return Array.isArray(parsed) ? parsed.filter(isApproval) : [];
|
|
2046
|
+
} catch {
|
|
2047
|
+
return [];
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
async function signApprovals(approvals, secret) {
|
|
2051
|
+
const payload = base64url(new TextEncoder().encode(JSON.stringify(approvals)));
|
|
2052
|
+
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(payload));
|
|
2053
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`;
|
|
2054
|
+
}
|
|
2055
|
+
function isApproval(value) {
|
|
2056
|
+
if (!value || typeof value !== "object") return false;
|
|
2057
|
+
const entry = value;
|
|
2058
|
+
return typeof entry.k === "string" && typeof entry.e === "number" && Array.isArray(entry.s) && entry.s.every((scope) => typeof scope === "string");
|
|
2059
|
+
}
|
|
2060
|
+
/** Approval is bound to the client, where its tokens go, which resource they are for, and the user if known. */
|
|
2061
|
+
async function approvalKey(request, subject) {
|
|
2062
|
+
const material = JSON.stringify([
|
|
2063
|
+
request.clientId,
|
|
2064
|
+
request.redirectUri,
|
|
2065
|
+
request.resource ?? null,
|
|
2066
|
+
subject ?? null
|
|
2067
|
+
]);
|
|
2068
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
2069
|
+
return base64url(new Uint8Array(digest));
|
|
2070
|
+
}
|
|
2071
|
+
function hmacKey(secret) {
|
|
2072
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
2073
|
+
name: "HMAC",
|
|
2074
|
+
hash: "SHA-256"
|
|
2075
|
+
}, false, ["sign", "verify"]);
|
|
2076
|
+
}
|
|
2077
|
+
function bindingCookie(name, value, maxAge) {
|
|
2078
|
+
return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
2079
|
+
}
|
|
2080
|
+
function clearCookie(name) {
|
|
2081
|
+
return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
2082
|
+
}
|
|
2083
|
+
function readCookie(request, name) {
|
|
2084
|
+
const header = request.headers.get("Cookie");
|
|
2085
|
+
if (!header) return void 0;
|
|
2086
|
+
for (const part of header.split(";")) {
|
|
2087
|
+
const separator = part.indexOf("=");
|
|
2088
|
+
if (separator === -1) continue;
|
|
2089
|
+
if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
|
|
2090
|
+
}
|
|
1532
2091
|
}
|
|
1533
|
-
function
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
2092
|
+
async function sha256Hex(value) {
|
|
2093
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
2094
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2095
|
+
}
|
|
2096
|
+
function timingSafeEqual(a, b) {
|
|
2097
|
+
if (a.length !== b.length) return false;
|
|
2098
|
+
let difference = 0;
|
|
2099
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
2100
|
+
return difference === 0;
|
|
2101
|
+
}
|
|
2102
|
+
function base64url(bytes) {
|
|
2103
|
+
let binary = "";
|
|
2104
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2105
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2106
|
+
}
|
|
2107
|
+
function fromBase64url(value) {
|
|
2108
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
2109
|
+
const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
|
|
2110
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1550
2111
|
}
|
|
1551
2112
|
|
|
1552
2113
|
//#endregion
|
|
@@ -1576,6 +2137,9 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1576
2137
|
GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
1577
2138
|
return GrantType$1;
|
|
1578
2139
|
}({});
|
|
2140
|
+
/** Where the split server's endpoints are unless configured otherwise, under the issuer. */
|
|
2141
|
+
const DEFAULT_AUTHORIZE_PATH = "/authorize";
|
|
2142
|
+
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
1579
2143
|
function toPublicClientInfo(client) {
|
|
1580
2144
|
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1581
2145
|
return publicClient;
|
|
@@ -1635,7 +2199,6 @@ var OAuthAuthorizationServer = class {
|
|
|
1635
2199
|
const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
|
|
1636
2200
|
this.#impl = new OAuthProviderImpl({
|
|
1637
2201
|
...commonOptions,
|
|
1638
|
-
defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
|
|
1639
2202
|
authorizationServer: {
|
|
1640
2203
|
issuer,
|
|
1641
2204
|
resources,
|
|
@@ -1707,7 +2270,7 @@ var OAuthProviderImpl = class {
|
|
|
1707
2270
|
constructor(options) {
|
|
1708
2271
|
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
1709
2272
|
this.typedApiHandlers = [];
|
|
1710
|
-
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
2273
|
+
this.typedDefaultHandler = "authorizationServer" in options ? void 0 : this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
1711
2274
|
const roleBased = "authorizationServer" in options;
|
|
1712
2275
|
if (!roleBased && "resourceMatchOriginOnly" in options) throw new TypeError("resourceMatchOriginOnly was removed in 1.0. Configure resourceMetadata.resource; audiences are compared exactly against that canonical resource. See the migration guide.");
|
|
1713
2276
|
let normalizedOptions;
|
|
@@ -1716,10 +2279,11 @@ var OAuthProviderImpl = class {
|
|
|
1716
2279
|
const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
|
|
1717
2280
|
this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
|
|
1718
2281
|
this.explicitIssuer = authorizationServer$1.issuer;
|
|
2282
|
+
const issuerBase = authorizationServer$1.issuer.replace(/\/+$/, "");
|
|
1719
2283
|
normalizedOptions = {
|
|
1720
2284
|
...commonOptions,
|
|
1721
|
-
authorizeEndpoint: authorizationServer$1.authorizeEndpoint
|
|
1722
|
-
tokenEndpoint: authorizationServer$1.tokenEndpoint
|
|
2285
|
+
authorizeEndpoint: authorizationServer$1.authorizeEndpoint ?? `${issuerBase}${DEFAULT_AUTHORIZE_PATH}`,
|
|
2286
|
+
tokenEndpoint: authorizationServer$1.tokenEndpoint ?? `${issuerBase}${DEFAULT_TOKEN_PATH}`,
|
|
1723
2287
|
clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
|
|
1724
2288
|
};
|
|
1725
2289
|
const declared = authorizationServer$1.resources;
|
|
@@ -1731,7 +2295,7 @@ var OAuthProviderImpl = class {
|
|
|
1731
2295
|
this.explicitIssuer = void 0;
|
|
1732
2296
|
normalizedOptions = options;
|
|
1733
2297
|
configuredResourceServers = [{
|
|
1734
|
-
resourceMetadata: options.resourceMetadata,
|
|
2298
|
+
resourceMetadata: withRequiredScopes(options.resourceMetadata, options.requiredScopes),
|
|
1735
2299
|
resolveExternalToken: options.resolveExternalToken
|
|
1736
2300
|
}];
|
|
1737
2301
|
}
|
|
@@ -1744,6 +2308,7 @@ var OAuthProviderImpl = class {
|
|
|
1744
2308
|
};
|
|
1745
2309
|
this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
|
|
1746
2310
|
this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
|
|
2311
|
+
this.consentCookies = consentCookies(this.options.cookiePrefix);
|
|
1747
2312
|
if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1748
2313
|
if (roleBased) this.validateAuthorizationServerRouteIsolation();
|
|
1749
2314
|
this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
|
|
@@ -1794,11 +2359,14 @@ var OAuthProviderImpl = class {
|
|
|
1794
2359
|
}
|
|
1795
2360
|
if (!isValidAccessTokenTTL(this.options.accessTokenTTL)) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
1796
2361
|
if (this.options.refreshTokenIdleTTL !== void 0 && !isValidAccessTokenTTL(this.options.refreshTokenIdleTTL)) throw new TypeError(`refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2362
|
+
if (this.options.allowPlainPKCE) throw new TypeError("allowPlainPKCE has been removed: MCP and OAuth 2.1 clients use S256 PKCE.");
|
|
2363
|
+
if (this.options.allowImplicitFlow) throw new TypeError("allowImplicitFlow has been removed: OAuth 2.1 and MCP use the authorization code flow with PKCE.");
|
|
2364
|
+
if (this.options.refreshTokenTTL !== void 0 && !isValidRefreshTokenTTL(this.options.refreshTokenTTL)) throw new TypeError(`refreshTokenTTL must be 0 (no refresh tokens), undefined (no expiry), or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2365
|
+
if (this.options.clientRegistrationTTL !== void 0 && !isValidAccessTokenTTL(this.options.clientRegistrationTTL)) throw new TypeError(`clientRegistrationTTL must be undefined (no expiry) or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
1797
2366
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
1798
|
-
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
1799
|
-
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
1800
2367
|
allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
|
|
1801
|
-
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
2368
|
+
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization,
|
|
2369
|
+
allowPrivateUseRedirectUris: this.options.allowPrivateUseRedirectUris === true
|
|
1802
2370
|
});
|
|
1803
2371
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
1804
2372
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
@@ -1837,16 +2405,24 @@ var OAuthProviderImpl = class {
|
|
|
1837
2405
|
}
|
|
1838
2406
|
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.search || parsed.hash || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer) throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL (http is accepted only on a loopback host)");
|
|
1839
2407
|
}
|
|
1840
|
-
/**
|
|
2408
|
+
/**
|
|
2409
|
+
* Reject an endpoint another would claim, so it could never be reached. fetch() tries metadata,
|
|
2410
|
+
* then token, then registration; the application routes the authorization endpoint. Each check
|
|
2411
|
+
* asks the predicate fetch() dispatches on, so it agrees with routing (metadata ignores the
|
|
2412
|
+
* query, for one).
|
|
2413
|
+
*/
|
|
1841
2414
|
validateAuthorizationServerRouteIsolation() {
|
|
1842
2415
|
const issuerUrl = new URL(this.explicitIssuer);
|
|
1843
|
-
const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
|
|
1844
2416
|
const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
|
|
1845
|
-
if (this.
|
|
2417
|
+
if (this.isAuthorizationServerMetadataRequest(tokenUrl)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
|
|
2418
|
+
const authorizeUrl = new URL(this.getFullEndpointUrl(this.options.authorizeEndpoint, issuerUrl));
|
|
2419
|
+
if (this.isAuthorizationServerMetadataRequest(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with the authorization server metadata endpoint");
|
|
2420
|
+
if (this.isTokenEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with tokenEndpoint");
|
|
1846
2421
|
if (!this.options.clientRegistrationEndpoint) return;
|
|
2422
|
+
if (this.isClientRegistrationEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with clientRegistrationEndpoint");
|
|
1847
2423
|
const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
|
|
1848
|
-
if (this.
|
|
1849
|
-
if (this.
|
|
2424
|
+
if (this.isTokenEndpoint(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
|
|
2425
|
+
if (this.isAuthorizationServerMetadataRequest(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
|
|
1850
2426
|
}
|
|
1851
2427
|
/**
|
|
1852
2428
|
* A protected route must be the canonical resource path or a path-boundary descendant of
|
|
@@ -1947,7 +2523,7 @@ var OAuthProviderImpl = class {
|
|
|
1947
2523
|
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer || issuer.includes("?") || issuer.includes("#")) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs (http is accepted only on a loopback host)");
|
|
1948
2524
|
}
|
|
1949
2525
|
}
|
|
1950
|
-
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
2526
|
+
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("requiredScopes (or the deprecated resourceMetadata.scopes_supported) must contain valid OAuth scope tokens");
|
|
1951
2527
|
if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
1952
2528
|
}
|
|
1953
2529
|
/**
|
|
@@ -1986,49 +2562,42 @@ var OAuthProviderImpl = class {
|
|
|
1986
2562
|
const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
|
|
1987
2563
|
const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
|
|
1988
2564
|
if (request.method === "OPTIONS") {
|
|
1989
|
-
if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return
|
|
2565
|
+
if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(new Response(null, {
|
|
1990
2566
|
status: 204,
|
|
1991
2567
|
headers: { "Content-Length": "0" }
|
|
1992
2568
|
}), request);
|
|
1993
2569
|
}
|
|
1994
2570
|
if (this.isAuthorizationServerMetadataRequest(url)) {
|
|
1995
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2571
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
1996
2572
|
status: 405,
|
|
1997
2573
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1998
2574
|
}), request);
|
|
1999
|
-
|
|
2000
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2575
|
+
return withCorsHeaders(withoutBodyForHead(request, await this.handleMetadataDiscovery(url)), request);
|
|
2001
2576
|
}
|
|
2002
2577
|
if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
|
|
2003
|
-
if (!metadataResourceServer) return
|
|
2004
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2578
|
+
if (!metadataResourceServer) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
2579
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
2005
2580
|
status: 405,
|
|
2006
2581
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2007
2582
|
}), request);
|
|
2008
|
-
|
|
2009
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2583
|
+
return withCorsHeaders(withoutBodyForHead(request, this.handleProtectedResourceMetadata(url, metadataResourceServer)), request);
|
|
2010
2584
|
}
|
|
2011
2585
|
if (this.isTokenEndpoint(url)) {
|
|
2012
2586
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
2013
|
-
if (parsed instanceof Response) return
|
|
2587
|
+
if (parsed instanceof Response) return withCorsHeaders(parsed, request);
|
|
2014
2588
|
let response;
|
|
2015
|
-
if (parsed.isRevocationRequest) response = await this.
|
|
2589
|
+
if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
|
|
2016
2590
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2017
2591
|
if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
|
|
2018
|
-
return
|
|
2592
|
+
return withCorsHeaders(response, request);
|
|
2019
2593
|
}
|
|
2020
|
-
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url))
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
}
|
|
2024
|
-
if (apiRoute) {
|
|
2025
|
-
const response = await this.handleApiRequest(request, env, ctx, apiRoute);
|
|
2026
|
-
return this.addCorsHeaders(response, request);
|
|
2027
|
-
}
|
|
2028
|
-
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
2594
|
+
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(await this.handleClientRegistration(request, env), request);
|
|
2595
|
+
if (apiRoute) return withCorsHeaders(await this.handleApiRequest(request, env, ctx, apiRoute), request);
|
|
2596
|
+
const defaultHandler = this.typedDefaultHandler;
|
|
2597
|
+
if (!defaultHandler) return new Response(null, { status: 404 });
|
|
2029
2598
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
2030
|
-
if (
|
|
2031
|
-
return new
|
|
2599
|
+
if (defaultHandler.type === HandlerType.EXPORTED_HANDLER) return defaultHandler.handler.fetch(request, env, ctx);
|
|
2600
|
+
return new defaultHandler.handler(ctx, env).fetch(request);
|
|
2032
2601
|
}
|
|
2033
2602
|
/**
|
|
2034
2603
|
* Decodes a token and returns token data with decrypted props
|
|
@@ -2264,7 +2833,7 @@ var OAuthProviderImpl = class {
|
|
|
2264
2833
|
});
|
|
2265
2834
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
2266
2835
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
2267
|
-
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled &&
|
|
2836
|
+
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
2268
2837
|
category: "client-authentication",
|
|
2269
2838
|
reason: "token_endpoint_auth_method_mismatch",
|
|
2270
2839
|
detail: {
|
|
@@ -2340,26 +2909,6 @@ var OAuthProviderImpl = class {
|
|
|
2340
2909
|
return new URL(tokenEndpoint).origin;
|
|
2341
2910
|
}
|
|
2342
2911
|
/**
|
|
2343
|
-
* Adds CORS headers to a response
|
|
2344
|
-
* @param response - The response to add CORS headers to
|
|
2345
|
-
* @param request - The original request
|
|
2346
|
-
* @returns A new Response with CORS headers added
|
|
2347
|
-
*/
|
|
2348
|
-
addCorsHeaders(response, request) {
|
|
2349
|
-
const origin = request.headers.get("Origin");
|
|
2350
|
-
if (!origin) return response;
|
|
2351
|
-
const newResponse = new Response(response.body, response);
|
|
2352
|
-
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
2353
|
-
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
2354
|
-
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
2355
|
-
appendHeaderValue(newResponse.headers, "Vary", "Origin");
|
|
2356
|
-
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
2357
|
-
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
2358
|
-
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
2359
|
-
newResponse.headers.set("Access-Control-Max-Age", "86400");
|
|
2360
|
-
return newResponse;
|
|
2361
|
-
}
|
|
2362
|
-
/**
|
|
2363
2912
|
* Handles the OAuth metadata discovery endpoint
|
|
2364
2913
|
* Implements RFC 8414 for OAuth Server Metadata
|
|
2365
2914
|
* @param requestUrl - The URL of the incoming request
|
|
@@ -2381,7 +2930,7 @@ var OAuthProviderImpl = class {
|
|
|
2381
2930
|
registration_endpoint: registrationEndpoint,
|
|
2382
2931
|
scopes_supported: this.options.scopesSupported,
|
|
2383
2932
|
response_types_supported: responseTypesSupported,
|
|
2384
|
-
response_modes_supported:
|
|
2933
|
+
response_modes_supported: ["query"],
|
|
2385
2934
|
grant_types_supported: grantTypesSupported,
|
|
2386
2935
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
2387
2936
|
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
@@ -2398,7 +2947,7 @@ var OAuthProviderImpl = class {
|
|
|
2398
2947
|
}
|
|
2399
2948
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
2400
2949
|
normalizeProtectedResourceScopes(scopes) {
|
|
2401
|
-
return
|
|
2950
|
+
return baselineResourceScopes(scopes);
|
|
2402
2951
|
}
|
|
2403
2952
|
/**
|
|
2404
2953
|
* Handles the OAuth Protected Resource Metadata endpoint
|
|
@@ -2460,6 +3009,27 @@ var OAuthProviderImpl = class {
|
|
|
2460
3009
|
* token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
|
|
2461
3010
|
* unexpected failures still surface as 500s.
|
|
2462
3011
|
*/
|
|
3012
|
+
/**
|
|
3013
|
+
* Run `tokenExchangeCallback`. An `invalid_grant` it throws means the grant can never work again
|
|
3014
|
+
* (RFC 6749 §5.2: invalid, expired, or revoked; transient failures are `temporarily_unavailable`),
|
|
3015
|
+
* so the grant it ran for is revoked, with its tokens, before the error answers. A failed
|
|
3016
|
+
* revocation is logged and the callback's error still answers the request.
|
|
3017
|
+
*/
|
|
3018
|
+
async callTokenExchangeCallback(options, env) {
|
|
3019
|
+
try {
|
|
3020
|
+
return await Promise.resolve(this.options.tokenExchangeCallback({
|
|
3021
|
+
...options,
|
|
3022
|
+
env
|
|
3023
|
+
}));
|
|
3024
|
+
} catch (error) {
|
|
3025
|
+
if (error instanceof OAuthError && error.code === "invalid_grant") try {
|
|
3026
|
+
await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
|
|
3027
|
+
} catch (revokeError) {
|
|
3028
|
+
console.warn(`Failed to revoke grant ${options.grantId} after tokenExchangeCallback answered invalid_grant:`, revokeError);
|
|
3029
|
+
}
|
|
3030
|
+
throw error;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
2463
3033
|
createOAuthErrorResponse(error) {
|
|
2464
3034
|
if (!(error instanceof OAuthError)) return void 0;
|
|
2465
3035
|
return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
|
|
@@ -2545,9 +3115,9 @@ var OAuthProviderImpl = class {
|
|
|
2545
3115
|
reason: "code_replayed"
|
|
2546
3116
|
});
|
|
2547
3117
|
}
|
|
2548
|
-
let codeChallengeMethod;
|
|
2549
3118
|
try {
|
|
2550
|
-
|
|
3119
|
+
if (grantData.codeChallenge) validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod);
|
|
3120
|
+
else normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
2551
3121
|
} catch (error) {
|
|
2552
3122
|
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
|
|
2553
3123
|
category: "authorization-code-grant",
|
|
@@ -2577,14 +3147,8 @@ var OAuthProviderImpl = class {
|
|
|
2577
3147
|
category: "authorization-code-grant",
|
|
2578
3148
|
reason: "code_verifier_missing"
|
|
2579
3149
|
});
|
|
2580
|
-
|
|
2581
|
-
if (
|
|
2582
|
-
const data = new TextEncoder().encode(codeVerifier);
|
|
2583
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
2584
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
2585
|
-
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
2586
|
-
} else calculatedChallenge = codeVerifier;
|
|
2587
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
3150
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
3151
|
+
if (base64UrlEncode(String.fromCharCode(...new Uint8Array(hashBuffer))) !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
2588
3152
|
category: "authorization-code-grant",
|
|
2589
3153
|
reason: "code_verifier_mismatch"
|
|
2590
3154
|
});
|
|
@@ -2598,6 +3162,10 @@ var OAuthProviderImpl = class {
|
|
|
2598
3162
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2599
3163
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
2600
3164
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3165
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3166
|
+
category: "authorization-code-grant",
|
|
3167
|
+
reason: "scope_not_granted"
|
|
3168
|
+
});
|
|
2601
3169
|
if (this.options.tokenExchangeCallback) {
|
|
2602
3170
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
2603
3171
|
let grantProps = decryptedProps;
|
|
@@ -2613,7 +3181,7 @@ var OAuthProviderImpl = class {
|
|
|
2613
3181
|
resource: audience,
|
|
2614
3182
|
props: decryptedProps
|
|
2615
3183
|
};
|
|
2616
|
-
const callbackResult = await
|
|
3184
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2617
3185
|
if (callbackResult) {
|
|
2618
3186
|
if (callbackResult.newProps) {
|
|
2619
3187
|
grantProps = callbackResult.newProps;
|
|
@@ -2621,11 +3189,13 @@ var OAuthProviderImpl = class {
|
|
|
2621
3189
|
}
|
|
2622
3190
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2623
3191
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2624
|
-
if (
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
3192
|
+
if (callbackResult.refreshTokenTTL !== void 0) {
|
|
3193
|
+
if (!isValidRefreshTokenTTL(callbackResult.refreshTokenTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenTTL must be 0 (no refresh token) or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
3194
|
+
category: "authorization-code-grant",
|
|
3195
|
+
reason: "refresh_token_ttl_invalid"
|
|
3196
|
+
});
|
|
3197
|
+
refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
3198
|
+
}
|
|
2629
3199
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2630
3200
|
}
|
|
2631
3201
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -2660,7 +3230,7 @@ var OAuthProviderImpl = class {
|
|
|
2660
3230
|
grantData.previousRefreshTokenId = void 0;
|
|
2661
3231
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
2662
3232
|
grantData.expiresAt = expiresAt;
|
|
2663
|
-
}
|
|
3233
|
+
} else grantData.expiresAt = now + accessTokenTTL;
|
|
2664
3234
|
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2665
3235
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2666
3236
|
const tokenResponse = {
|
|
@@ -2744,6 +3314,10 @@ var OAuthProviderImpl = class {
|
|
|
2744
3314
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2745
3315
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
2746
3316
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3317
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3318
|
+
category: "refresh-token-grant",
|
|
3319
|
+
reason: "scope_not_granted"
|
|
3320
|
+
});
|
|
2747
3321
|
let grantPropsChanged = false;
|
|
2748
3322
|
if (this.options.tokenExchangeCallback) {
|
|
2749
3323
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
@@ -2760,7 +3334,7 @@ var OAuthProviderImpl = class {
|
|
|
2760
3334
|
resource: audience,
|
|
2761
3335
|
props: decryptedProps
|
|
2762
3336
|
};
|
|
2763
|
-
const callbackResult = await
|
|
3337
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2764
3338
|
if (callbackResult) {
|
|
2765
3339
|
if (callbackResult.newProps) {
|
|
2766
3340
|
grantProps = callbackResult.newProps;
|
|
@@ -2769,10 +3343,6 @@ var OAuthProviderImpl = class {
|
|
|
2769
3343
|
}
|
|
2770
3344
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2771
3345
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2772
|
-
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
|
|
2773
|
-
category: "refresh-token-grant",
|
|
2774
|
-
reason: "refresh_token_ttl_immutable"
|
|
2775
|
-
});
|
|
2776
3346
|
if (callbackResult.refreshTokenIdleTTL !== void 0) {
|
|
2777
3347
|
if (!isValidAccessTokenTTL(callbackResult.refreshTokenIdleTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
2778
3348
|
category: "refresh-token-grant",
|
|
@@ -2901,6 +3471,13 @@ var OAuthProviderImpl = class {
|
|
|
2901
3471
|
});
|
|
2902
3472
|
if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
|
|
2903
3473
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
3474
|
+
if (grantsNoneOfTheRequestedScopes(requestedScopes, tokenScopes)) throw new OAuthError("invalid_scope", {
|
|
3475
|
+
description: "None of the requested scopes were granted",
|
|
3476
|
+
internal: {
|
|
3477
|
+
category: "token-exchange-grant",
|
|
3478
|
+
reason: "scope_not_granted"
|
|
3479
|
+
}
|
|
3480
|
+
});
|
|
2904
3481
|
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
2905
3482
|
const now = Math.floor(Date.now() / 1e3);
|
|
2906
3483
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
@@ -2946,7 +3523,7 @@ var OAuthProviderImpl = class {
|
|
|
2946
3523
|
resource: newAudience,
|
|
2947
3524
|
props: decryptedProps
|
|
2948
3525
|
};
|
|
2949
|
-
const callbackResult = await
|
|
3526
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2950
3527
|
if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
|
|
2951
3528
|
if (callbackResult) {
|
|
2952
3529
|
let accessTokenProps = decryptedProps;
|
|
@@ -2955,13 +3532,6 @@ var OAuthProviderImpl = class {
|
|
|
2955
3532
|
}
|
|
2956
3533
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2957
3534
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
2958
|
-
if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
|
|
2959
|
-
description: "refreshTokenIdleTTL is only honored during refresh token exchange",
|
|
2960
|
-
internal: {
|
|
2961
|
-
category: "token-exchange-grant",
|
|
2962
|
-
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
2963
|
-
}
|
|
2964
|
-
});
|
|
2965
3535
|
if (accessTokenProps !== decryptedProps) {
|
|
2966
3536
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
2967
3537
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -2981,7 +3551,7 @@ var OAuthProviderImpl = class {
|
|
|
2981
3551
|
access_token: await this.createAccessToken({
|
|
2982
3552
|
userId: tokenSummary.userId,
|
|
2983
3553
|
grantId: tokenSummary.grantId,
|
|
2984
|
-
clientId:
|
|
3554
|
+
clientId: clientInfo.clientId,
|
|
2985
3555
|
scope: tokenScopes,
|
|
2986
3556
|
encryptedProps: encryptedAccessTokenProps,
|
|
2987
3557
|
encryptionKey: accessTokenEncryptionKey,
|
|
@@ -3265,15 +3835,7 @@ var OAuthProviderImpl = class {
|
|
|
3265
3835
|
};
|
|
3266
3836
|
}
|
|
3267
3837
|
/**
|
|
3268
|
-
* Handles
|
|
3269
|
-
* @param body - The parsed request body containing revocation parameters
|
|
3270
|
-
* @param env - Cloudflare Worker environment variables
|
|
3271
|
-
* @returns Response confirming revocation or error
|
|
3272
|
-
*/
|
|
3273
|
-
async handleRevocationRequest(body, clientInfo, env) {
|
|
3274
|
-
return this.revokeToken(body, clientInfo, env);
|
|
3275
|
-
}
|
|
3276
|
-
/**
|
|
3838
|
+
* Handles an RFC 7009 revocation request from an authenticated client.
|
|
3277
3839
|
* - Access tokens: Revokes only the specific token
|
|
3278
3840
|
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
3279
3841
|
* Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
|
|
@@ -3365,14 +3927,17 @@ var OAuthProviderImpl = class {
|
|
|
3365
3927
|
const callbackRequest = request.clone();
|
|
3366
3928
|
let parsedJson;
|
|
3367
3929
|
try {
|
|
3368
|
-
const text = await request
|
|
3369
|
-
if (text
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3930
|
+
const text = await readTextWithLimit(request, 1048576);
|
|
3931
|
+
if (text === void 0) {
|
|
3932
|
+
callbackRequest.body?.cancel().catch(() => void 0);
|
|
3933
|
+
return this.createErrorResponse("invalid_request", {
|
|
3934
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
3935
|
+
statusCode: 413
|
|
3936
|
+
}, {
|
|
3937
|
+
category: "client-registration",
|
|
3938
|
+
reason: "payload_too_large"
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3376
3941
|
parsedJson = JSON.parse(text);
|
|
3377
3942
|
} catch {
|
|
3378
3943
|
return this.createErrorResponse("invalid_request", {
|
|
@@ -3387,8 +3952,16 @@ var OAuthProviderImpl = class {
|
|
|
3387
3952
|
let metadata;
|
|
3388
3953
|
try {
|
|
3389
3954
|
clientMetadata = requireJsonObject(parsedJson);
|
|
3390
|
-
|
|
3955
|
+
const capabilities = this.options.disallowPublicClientRegistration ? {
|
|
3956
|
+
...this.serverCapabilities,
|
|
3957
|
+
tokenEndpointAuthMethods: this.serverCapabilities.tokenEndpointAuthMethods.filter((method) => method !== "none")
|
|
3958
|
+
} : this.serverCapabilities;
|
|
3959
|
+
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, capabilities);
|
|
3391
3960
|
} catch (error) {
|
|
3961
|
+
if (this.options.disallowPublicClientRegistration && wouldRegisterPublicClient(clientMetadata, this.serverCapabilities)) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3962
|
+
category: "client-registration",
|
|
3963
|
+
reason: "public_client_forbidden"
|
|
3964
|
+
});
|
|
3392
3965
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
|
|
3393
3966
|
category: "client-registration",
|
|
3394
3967
|
reason: "metadata_invalid",
|
|
@@ -3397,10 +3970,6 @@ var OAuthProviderImpl = class {
|
|
|
3397
3970
|
}
|
|
3398
3971
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
3399
3972
|
const isPublicClient = authMethod === "none";
|
|
3400
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3401
|
-
category: "client-registration",
|
|
3402
|
-
reason: "public_client_forbidden"
|
|
3403
|
-
});
|
|
3404
3973
|
const clientId = generateRandomString(16);
|
|
3405
3974
|
let clientSecret;
|
|
3406
3975
|
let hashedSecret;
|
|
@@ -3435,7 +4004,7 @@ var OAuthProviderImpl = class {
|
|
|
3435
4004
|
}));
|
|
3436
4005
|
} catch (error) {
|
|
3437
4006
|
return this.createErrorResponse("server_error", {
|
|
3438
|
-
description:
|
|
4007
|
+
description: "Client registration callback failed",
|
|
3439
4008
|
statusCode: 500
|
|
3440
4009
|
}, {
|
|
3441
4010
|
category: "client-registration",
|
|
@@ -3473,7 +4042,7 @@ var OAuthProviderImpl = class {
|
|
|
3473
4042
|
}
|
|
3474
4043
|
if (clientSecret) {
|
|
3475
4044
|
response.client_secret = clientSecret;
|
|
3476
|
-
response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
4045
|
+
response.client_secret_expires_at = this.options.clientRegistrationTTL !== void 0 && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
3477
4046
|
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
3478
4047
|
}
|
|
3479
4048
|
return new Response(JSON.stringify(response), {
|
|
@@ -3518,7 +4087,7 @@ var OAuthProviderImpl = class {
|
|
|
3518
4087
|
const id = await generateTokenId(accessToken);
|
|
3519
4088
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
3520
4089
|
}
|
|
3521
|
-
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
4090
|
+
if (!tokenData && (!externalTokenResolver || isIssuedTokenFormat(accessToken))) return this.createErrorResponse("invalid_token", {
|
|
3522
4091
|
description: "Invalid access token",
|
|
3523
4092
|
statusCode: 401,
|
|
3524
4093
|
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
@@ -3724,7 +4293,7 @@ var OAuthProviderImpl = class {
|
|
|
3724
4293
|
}
|
|
3725
4294
|
}
|
|
3726
4295
|
async getClient(env, clientId) {
|
|
3727
|
-
if (
|
|
4296
|
+
if (isClientIdMetadataDocumentUrl(clientId)) {
|
|
3728
4297
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
3729
4298
|
const clientKey$1 = `client:${clientId}`;
|
|
3730
4299
|
return env.OAUTH_KV.get(clientKey$1, { type: "json" });
|
|
@@ -3935,13 +4504,6 @@ var OAuthProviderImpl = class {
|
|
|
3935
4504
|
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
3936
4505
|
}
|
|
3937
4506
|
/**
|
|
3938
|
-
* Checks if a client_id is a CIMD URL (HTTPS with non-root path).
|
|
3939
|
-
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
3940
|
-
*/
|
|
3941
|
-
isClientMetadataUrl(clientId) {
|
|
3942
|
-
return isClientIdMetadataDocumentUrl(clientId);
|
|
3943
|
-
}
|
|
3944
|
-
/**
|
|
3945
4507
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
3946
4508
|
*/
|
|
3947
4509
|
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
|
|
@@ -4013,59 +4575,6 @@ var OAuthProviderImpl = class {
|
|
|
4013
4575
|
}
|
|
4014
4576
|
};
|
|
4015
4577
|
/**
|
|
4016
|
-
* Structured OAuth 2.0 token-endpoint error.
|
|
4017
|
-
*
|
|
4018
|
-
* Throw from a `tokenExchangeCallback` or any code it calls to surface a
|
|
4019
|
-
* standard OAuth token response (`{ error, error_description }`) instead of a
|
|
4020
|
-
* generic `500 Internal Server Error`.
|
|
4021
|
-
*
|
|
4022
|
-
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
4023
|
-
* a 500 so unexpected failures remain visible — the provider does not
|
|
4024
|
-
* catch-everything-and-return-400.
|
|
4025
|
-
*
|
|
4026
|
-
* @example
|
|
4027
|
-
* ```ts
|
|
4028
|
-
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
4029
|
-
*
|
|
4030
|
-
* tokenExchangeCallback: async (options) => {
|
|
4031
|
-
* if (options.grantType === 'refresh_token') {
|
|
4032
|
-
* // refreshUpstream() may throw OAuthError from any depth
|
|
4033
|
-
* return { newProps: await refreshUpstream(options.props) };
|
|
4034
|
-
* }
|
|
4035
|
-
* }
|
|
4036
|
-
*
|
|
4037
|
-
* async function refreshUpstream(props) {
|
|
4038
|
-
* const res = await fetch(...);
|
|
4039
|
-
* if (res.status === 401) {
|
|
4040
|
-
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
4041
|
-
* }
|
|
4042
|
-
* if (res.status === 429) {
|
|
4043
|
-
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
4044
|
-
* throw new OAuthError('temporarily_unavailable', {
|
|
4045
|
-
* description: 'upstream rate limited',
|
|
4046
|
-
* statusCode: 429,
|
|
4047
|
-
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
4048
|
-
* });
|
|
4049
|
-
* }
|
|
4050
|
-
* return await res.json();
|
|
4051
|
-
* }
|
|
4052
|
-
* ```
|
|
4053
|
-
*/
|
|
4054
|
-
var OAuthError = class extends Error {
|
|
4055
|
-
constructor(code, options) {
|
|
4056
|
-
super(options.description);
|
|
4057
|
-
this.name = "OAuthError";
|
|
4058
|
-
this.code = code;
|
|
4059
|
-
this.options = {
|
|
4060
|
-
...options,
|
|
4061
|
-
statusCode: options.statusCode ?? 400
|
|
4062
|
-
};
|
|
4063
|
-
this.description = this.options.description;
|
|
4064
|
-
this.statusCode = this.options.statusCode;
|
|
4065
|
-
this.headers = this.options.headers;
|
|
4066
|
-
}
|
|
4067
|
-
};
|
|
4068
|
-
/**
|
|
4069
4578
|
* Intentional public error from an external bearer-token validator.
|
|
4070
4579
|
*
|
|
4071
4580
|
* Throw only from `resolveExternalToken` when an expected validation outcome
|
|
@@ -4140,6 +4649,75 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
4140
4649
|
* to clamp absolute expirations when writing grants back to KV.
|
|
4141
4650
|
*/
|
|
4142
4651
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4652
|
+
/** Whether a registration refused without `none` on offer would otherwise have been a public client. */
|
|
4653
|
+
function wouldRegisterPublicClient(clientMetadata, server) {
|
|
4654
|
+
if (!clientMetadata) return false;
|
|
4655
|
+
try {
|
|
4656
|
+
return resolveDynamicClientRegistrationMetadata(clientMetadata, server).tokenEndpointAuthMethod === "none";
|
|
4657
|
+
} catch {
|
|
4658
|
+
return false;
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
/** The combined provider's resource metadata with its required scopes resolved into `scopes_supported`. */
|
|
4662
|
+
function withRequiredScopes(metadata, requiredScopes) {
|
|
4663
|
+
if (!metadata || typeof metadata !== "object") return metadata;
|
|
4664
|
+
const { scopes_supported: deprecated, ...rest } = metadata;
|
|
4665
|
+
const scopes = resolveRequiredScopes(requiredScopes, deprecated);
|
|
4666
|
+
return scopes === void 0 ? rest : {
|
|
4667
|
+
...rest,
|
|
4668
|
+
scopes_supported: scopes
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
/** A refresh-token lifetime: 0 for "issue no refresh token", else a lifetime KV can store. */
|
|
4672
|
+
function isValidRefreshTokenTTL(ttl) {
|
|
4673
|
+
return ttl === 0 || isValidAccessTokenTTL(ttl);
|
|
4674
|
+
}
|
|
4675
|
+
/**
|
|
4676
|
+
* Reads a `purgeExpiredData` cursor: the phase to resume and KV's opaque cursor within it
|
|
4677
|
+
* (empty for the start of that phase). No cursor starts a new sweep with the grants.
|
|
4678
|
+
*/
|
|
4679
|
+
function parsePurgeCursor(cursor) {
|
|
4680
|
+
if (cursor === void 0) return { phase: "grants" };
|
|
4681
|
+
const separator = cursor.indexOf(":");
|
|
4682
|
+
const phase = cursor.slice(0, separator);
|
|
4683
|
+
if (separator < 0 || phase !== "grants" && phase !== "tokens") throw new TypeError("Invalid purgeExpiredData cursor");
|
|
4684
|
+
const kvCursor = cursor.slice(separator + 1);
|
|
4685
|
+
return kvCursor ? {
|
|
4686
|
+
phase,
|
|
4687
|
+
kvCursor
|
|
4688
|
+
} : { phase };
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* RFC 6749 §6: a token request may narrow the grant's scopes. One that names scopes but none the
|
|
4692
|
+
* grant holds would otherwise get a token with no scope at all; it is `invalid_scope` instead.
|
|
4693
|
+
*/
|
|
4694
|
+
function grantsNoneOfTheRequestedScopes(requested, granted) {
|
|
4695
|
+
return (typeof requested === "string" ? requested.split(" ").filter(Boolean) : requested ?? []).length > 0 && granted.length === 0;
|
|
4696
|
+
}
|
|
4697
|
+
/** Reads a request body as text, or returns undefined once it passes `maxBytes`. */
|
|
4698
|
+
async function readTextWithLimit(request, maxBytes) {
|
|
4699
|
+
const reader = request.body?.getReader();
|
|
4700
|
+
if (!reader) return "";
|
|
4701
|
+
const chunks = [];
|
|
4702
|
+
let total = 0;
|
|
4703
|
+
for (;;) {
|
|
4704
|
+
const { done, value } = await reader.read();
|
|
4705
|
+
if (done) break;
|
|
4706
|
+
total += value.byteLength;
|
|
4707
|
+
if (total > maxBytes) {
|
|
4708
|
+
reader.cancel().catch(() => void 0);
|
|
4709
|
+
return;
|
|
4710
|
+
}
|
|
4711
|
+
chunks.push(value);
|
|
4712
|
+
}
|
|
4713
|
+
const bytes = new Uint8Array(total);
|
|
4714
|
+
let offset = 0;
|
|
4715
|
+
for (const chunk of chunks) {
|
|
4716
|
+
bytes.set(chunk, offset);
|
|
4717
|
+
offset += chunk.byteLength;
|
|
4718
|
+
}
|
|
4719
|
+
return new TextDecoder().decode(bytes);
|
|
4720
|
+
}
|
|
4143
4721
|
function isValidAccessTokenTTL(value) {
|
|
4144
4722
|
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4145
4723
|
}
|
|
@@ -4166,11 +4744,6 @@ const MAX_KV_LIST_LIMIT = 1e3;
|
|
|
4166
4744
|
* matched from `list()` alone and never read.
|
|
4167
4745
|
*/
|
|
4168
4746
|
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
4169
|
-
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
4170
|
-
if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
4171
|
-
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
4172
|
-
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
4173
|
-
}
|
|
4174
4747
|
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4175
4748
|
const MAX_KV_METADATA_BYTES = 1024;
|
|
4176
4749
|
/**
|
|
@@ -4193,6 +4766,16 @@ function grantPutOptions(grant, expiry = {}) {
|
|
|
4193
4766
|
metadata
|
|
4194
4767
|
} : expiry;
|
|
4195
4768
|
}
|
|
4769
|
+
/** Splits `grant:{userId}:{grantId}`. Grant IDs never contain `:`, so the last one separates them. */
|
|
4770
|
+
function parseGrantKey(name) {
|
|
4771
|
+
const rest = name.slice(6);
|
|
4772
|
+
const separator = rest.lastIndexOf(":");
|
|
4773
|
+
if (!name.startsWith("grant:") || separator <= 0 || separator === rest.length - 1) return void 0;
|
|
4774
|
+
return {
|
|
4775
|
+
userId: rest.slice(0, separator),
|
|
4776
|
+
grantId: rest.slice(separator + 1)
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4196
4779
|
/** Whether a listed key carries metadata this version can match a grant on. */
|
|
4197
4780
|
function isGrantKeyMetadata(value) {
|
|
4198
4781
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -4203,6 +4786,8 @@ function isGrantKeyMetadata(value) {
|
|
|
4203
4786
|
* Length of generated token strings
|
|
4204
4787
|
*/
|
|
4205
4788
|
const TOKEN_LENGTH = 32;
|
|
4789
|
+
const ISSUED_GRANT_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/;
|
|
4790
|
+
const ISSUED_TOKEN_SECRET_PATTERN = /* @__PURE__ */ new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
|
|
4206
4791
|
/**
|
|
4207
4792
|
* Checks if a resource server matches an audience claim.
|
|
4208
4793
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -4224,11 +4809,6 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
4224
4809
|
return false;
|
|
4225
4810
|
}
|
|
4226
4811
|
}
|
|
4227
|
-
function appendHeaderValue(headers, name, value) {
|
|
4228
|
-
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
4229
|
-
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
4230
|
-
headers.set(name, values.join(", "));
|
|
4231
|
-
}
|
|
4232
4812
|
/** Whether a request or audience names one canonical configured resource. */
|
|
4233
4813
|
function isExactResource(value, configuredResource) {
|
|
4234
4814
|
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
@@ -4288,6 +4868,20 @@ function decodeFormUrlEncodedComponent(value) {
|
|
|
4288
4868
|
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
4289
4869
|
}
|
|
4290
4870
|
/**
|
|
4871
|
+
* Whether a bearer credential has the exact shape of a token this provider issues:
|
|
4872
|
+
* `{userId}:{grantId}:{secret}`, with a 16-character grant ID and a TOKEN_LENGTH secret from
|
|
4873
|
+
* generateRandomString's alphabet. Parsed from the right, because a deployer's user ID may itself
|
|
4874
|
+
* contain `:`. An external credential with exactly this shape would be treated as ours; opaque API
|
|
4875
|
+
* keys, PATs and JWTs don't have it.
|
|
4876
|
+
*/
|
|
4877
|
+
function isIssuedTokenFormat(token) {
|
|
4878
|
+
const parts = token.split(":");
|
|
4879
|
+
if (parts.length < 3) return false;
|
|
4880
|
+
const secret = parts[parts.length - 1];
|
|
4881
|
+
const grantId = parts[parts.length - 2];
|
|
4882
|
+
return parts.slice(0, -2).join(":").length > 0 && ISSUED_GRANT_ID_PATTERN.test(grantId) && ISSUED_TOKEN_SECRET_PATTERN.test(secret);
|
|
4883
|
+
}
|
|
4884
|
+
/**
|
|
4291
4885
|
* Generates a cryptographically secure random string
|
|
4292
4886
|
* @param length - The length of the string to generate
|
|
4293
4887
|
* @returns A random string of the specified length
|
|
@@ -4348,56 +4942,6 @@ function base64UrlEncode(str) {
|
|
|
4348
4942
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
4349
4943
|
}
|
|
4350
4944
|
/**
|
|
4351
|
-
* Decodes a base64url-encoded string to bytes.
|
|
4352
|
-
*/
|
|
4353
|
-
function base64UrlToBytes(base64Url) {
|
|
4354
|
-
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
4355
|
-
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
4356
|
-
const binaryString = atob(padded);
|
|
4357
|
-
const bytes = new Uint8Array(binaryString.length);
|
|
4358
|
-
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
4359
|
-
return bytes;
|
|
4360
|
-
}
|
|
4361
|
-
/**
|
|
4362
|
-
* Parses a base64url-encoded JWT JSON part into an object.
|
|
4363
|
-
*/
|
|
4364
|
-
function parseJwtJsonPart(encoded) {
|
|
4365
|
-
try {
|
|
4366
|
-
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
4367
|
-
const parsed = JSON.parse(json);
|
|
4368
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
4369
|
-
return parsed;
|
|
4370
|
-
} catch {
|
|
4371
|
-
throw new Error("Malformed JWT part");
|
|
4372
|
-
}
|
|
4373
|
-
}
|
|
4374
|
-
/**
|
|
4375
|
-
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
4376
|
-
*/
|
|
4377
|
-
function getJwtCryptoAlgorithms(alg) {
|
|
4378
|
-
if (alg === "RS256") {
|
|
4379
|
-
const algorithm = {
|
|
4380
|
-
name: "RSASSA-PKCS1-v1_5",
|
|
4381
|
-
hash: "SHA-256"
|
|
4382
|
-
};
|
|
4383
|
-
return {
|
|
4384
|
-
importAlgorithm: algorithm,
|
|
4385
|
-
verifyAlgorithm: algorithm
|
|
4386
|
-
};
|
|
4387
|
-
}
|
|
4388
|
-
if (alg === "ES256") return {
|
|
4389
|
-
importAlgorithm: {
|
|
4390
|
-
name: "ECDSA",
|
|
4391
|
-
namedCurve: "P-256"
|
|
4392
|
-
},
|
|
4393
|
-
verifyAlgorithm: {
|
|
4394
|
-
name: "ECDSA",
|
|
4395
|
-
hash: "SHA-256"
|
|
4396
|
-
}
|
|
4397
|
-
};
|
|
4398
|
-
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
4399
|
-
}
|
|
4400
|
-
/**
|
|
4401
4945
|
* Encodes an ArrayBuffer as base64 string
|
|
4402
4946
|
* @param buffer - The ArrayBuffer to encode
|
|
4403
4947
|
* @returns The base64 encoded string
|
|
@@ -4496,11 +5040,11 @@ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
|
|
|
4496
5040
|
*/
|
|
4497
5041
|
async function deriveKeyFromToken(tokenStr) {
|
|
4498
5042
|
const encoder = new TextEncoder();
|
|
4499
|
-
const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
5043
|
+
const hmacKey$1 = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
4500
5044
|
name: "HMAC",
|
|
4501
5045
|
hash: "SHA-256"
|
|
4502
5046
|
}, false, ["sign"]);
|
|
4503
|
-
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
|
|
5047
|
+
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey$1, encoder.encode(tokenStr));
|
|
4504
5048
|
return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
|
|
4505
5049
|
}
|
|
4506
5050
|
/**
|
|
@@ -4562,7 +5106,7 @@ var OAuthHelpersImpl = class {
|
|
|
4562
5106
|
const clientInfo = await this.lookupClient(clientId);
|
|
4563
5107
|
if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
|
|
4564
5108
|
try {
|
|
4565
|
-
|
|
5109
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
4566
5110
|
} catch {
|
|
4567
5111
|
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
4568
5112
|
}
|
|
@@ -4616,9 +5160,7 @@ var OAuthHelpersImpl = class {
|
|
|
4616
5160
|
return client ? toPublicClientInfo(client) : null;
|
|
4617
5161
|
}
|
|
4618
5162
|
/**
|
|
4619
|
-
* Completes an authorization request by creating a grant and
|
|
4620
|
-
* - For authorization code flow: generating an authorization code
|
|
4621
|
-
* - For implicit flow: generating an access token directly
|
|
5163
|
+
* Completes an authorization request by creating a grant and an authorization code.
|
|
4622
5164
|
* @param options - Options specifying the grant details
|
|
4623
5165
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
4624
5166
|
* @throws Error when the request's response type is not permitted
|
|
@@ -4627,102 +5169,55 @@ var OAuthHelpersImpl = class {
|
|
|
4627
5169
|
async completeAuthorization(options) {
|
|
4628
5170
|
const { clientId, redirectUri } = options.request;
|
|
4629
5171
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
5172
|
+
if (typeof options.userId !== "string" || options.userId.length === 0 || options.userId.includes(":")) throw new TypeError("userId must be a non-empty string without \":\"");
|
|
4630
5173
|
const clientInfo = await this.lookupClient(clientId);
|
|
4631
5174
|
if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
5175
|
+
try {
|
|
5176
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
5177
|
+
} catch {
|
|
5178
|
+
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
5179
|
+
}
|
|
4632
5180
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
4633
5181
|
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
4634
5182
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
4635
5183
|
let grantsToRevoke = [];
|
|
4636
5184
|
if (options.revokeExistingGrants !== false) {
|
|
4637
|
-
const isCimdClient =
|
|
4638
|
-
const readConcurrency =
|
|
4639
|
-
grantsToRevoke = await this.findGrantIds(options.userId, (grant) => grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant.resource, effectiveResource), readConcurrency);
|
|
5185
|
+
const isCimdClient = isClientIdMetadataDocumentUrl(clientId);
|
|
5186
|
+
const readConcurrency = DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
5187
|
+
grantsToRevoke = await this.findGrantIds(options.userId, (grant$1) => grant$1.clientId === clientId && (!isCimdClient || grant$1.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant$1.resource, effectiveResource), readConcurrency);
|
|
4640
5188
|
}
|
|
4641
5189
|
const grantId = generateRandomString(16);
|
|
4642
5190
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
4643
5191
|
const now = Math.floor(Date.now() / 1e3);
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
4674
|
-
grant: {
|
|
4675
|
-
clientId: options.request.clientId,
|
|
4676
|
-
scope: options.scope,
|
|
4677
|
-
encryptedProps: encryptedData
|
|
4678
|
-
}
|
|
4679
|
-
};
|
|
4680
|
-
await this.env.OAUTH_KV.put(`token:${options.userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
4681
|
-
const redirectUrl = new URL(options.request.redirectUri);
|
|
4682
|
-
const fragment = new URLSearchParams();
|
|
4683
|
-
fragment.set("access_token", accessToken);
|
|
4684
|
-
fragment.set("token_type", "bearer");
|
|
4685
|
-
fragment.set("expires_in", accessTokenTTL.toString());
|
|
4686
|
-
fragment.set("scope", options.scope.join(" "));
|
|
4687
|
-
fragment.set("resource", effectiveResource);
|
|
4688
|
-
if (options.request.state) fragment.set("state", options.request.state);
|
|
4689
|
-
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
4690
|
-
redirectUrl.hash = fragment.toString();
|
|
4691
|
-
try {
|
|
4692
|
-
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
4693
|
-
} catch {}
|
|
4694
|
-
return { redirectTo: redirectUrl.toString() };
|
|
4695
|
-
} else {
|
|
4696
|
-
const authCodeSecret = generateRandomString(32);
|
|
4697
|
-
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
4698
|
-
const authCodeId = await hashSecret(authCode);
|
|
4699
|
-
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
4700
|
-
const grant = {
|
|
4701
|
-
id: grantId,
|
|
4702
|
-
clientId: options.request.clientId,
|
|
4703
|
-
userId: options.userId,
|
|
4704
|
-
scope: options.scope,
|
|
4705
|
-
metadata: options.metadata,
|
|
4706
|
-
encryptedProps: encryptedData,
|
|
4707
|
-
createdAt: now,
|
|
4708
|
-
authCodeId,
|
|
4709
|
-
authCodeWrappedKey,
|
|
4710
|
-
codeChallenge: options.request.codeChallenge,
|
|
4711
|
-
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
4712
|
-
resource: effectiveResource,
|
|
4713
|
-
redirectUri: options.request.redirectUri
|
|
4714
|
-
};
|
|
4715
|
-
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
4716
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
4717
|
-
const redirectUrl = new URL(options.request.redirectUri);
|
|
4718
|
-
redirectUrl.searchParams.set("code", authCode);
|
|
4719
|
-
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
4720
|
-
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
4721
|
-
try {
|
|
4722
|
-
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
4723
|
-
} catch {}
|
|
4724
|
-
return { redirectTo: redirectUrl.toString() };
|
|
4725
|
-
}
|
|
5192
|
+
const authCodeSecret = generateRandomString(32);
|
|
5193
|
+
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
5194
|
+
const authCodeId = await hashSecret(authCode);
|
|
5195
|
+
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
5196
|
+
const grant = {
|
|
5197
|
+
id: grantId,
|
|
5198
|
+
clientId: options.request.clientId,
|
|
5199
|
+
userId: options.userId,
|
|
5200
|
+
scope: options.scope,
|
|
5201
|
+
metadata: options.metadata,
|
|
5202
|
+
encryptedProps: encryptedData,
|
|
5203
|
+
createdAt: now,
|
|
5204
|
+
authCodeId,
|
|
5205
|
+
authCodeWrappedKey,
|
|
5206
|
+
codeChallenge: options.request.codeChallenge,
|
|
5207
|
+
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
5208
|
+
resource: effectiveResource,
|
|
5209
|
+
redirectUri: options.request.redirectUri
|
|
5210
|
+
};
|
|
5211
|
+
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
5212
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
5213
|
+
const redirectUrl = new URL(options.request.redirectUri);
|
|
5214
|
+
redirectUrl.searchParams.set("code", authCode);
|
|
5215
|
+
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
5216
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
5217
|
+
try {
|
|
5218
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
5219
|
+
} catch {}
|
|
5220
|
+
return { redirectTo: redirectUrl.toString() };
|
|
4726
5221
|
}
|
|
4727
5222
|
/**
|
|
4728
5223
|
* IDs of the user's grants that satisfy `matches`, judged from KV key metadata
|
|
@@ -4736,8 +5231,18 @@ var OAuthHelpersImpl = class {
|
|
|
4736
5231
|
* share shrinks on its own.
|
|
4737
5232
|
*/
|
|
4738
5233
|
async findGrantIds(userId, matches, readConcurrency) {
|
|
4739
|
-
const
|
|
4740
|
-
const
|
|
5234
|
+
const grantIds = [];
|
|
5235
|
+
for await (const grants of this.findGrants(userId, matches, readConcurrency)) grantIds.push(...grants.map((grant) => grant.grantId));
|
|
5236
|
+
return grantIds;
|
|
5237
|
+
}
|
|
5238
|
+
/**
|
|
5239
|
+
* Finds grants matching `matches`, for one user or, with `userId` undefined, for every user.
|
|
5240
|
+
* Metadata-carrying keys are matched without a read; legacy grants are read,
|
|
5241
|
+
* `readConcurrency` at a time. Matches are yielded as they are found (each page's metadata
|
|
5242
|
+
* matches, then each batch of reads), so a caller can act on them before the scan goes on.
|
|
5243
|
+
*/
|
|
5244
|
+
async *findGrants(userId, matches, readConcurrency) {
|
|
5245
|
+
const prefix = userId === void 0 ? "grant:" : `grant:${userId}:`;
|
|
4741
5246
|
let cursor;
|
|
4742
5247
|
do {
|
|
4743
5248
|
const page = await this.env.OAUTH_KV.list({
|
|
@@ -4746,16 +5251,32 @@ var OAuthHelpersImpl = class {
|
|
|
4746
5251
|
cursor
|
|
4747
5252
|
});
|
|
4748
5253
|
const toRead = [];
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
5254
|
+
const found = [];
|
|
5255
|
+
for (const key of page.keys) {
|
|
5256
|
+
const owner = parseGrantKey(key.name);
|
|
5257
|
+
if (!owner || userId !== void 0 && owner.userId !== userId) continue;
|
|
5258
|
+
if (isGrantKeyMetadata(key.metadata)) {
|
|
5259
|
+
if (matches(key.metadata)) found.push(owner);
|
|
5260
|
+
} else toRead.push({
|
|
5261
|
+
name: key.name,
|
|
5262
|
+
...owner
|
|
5263
|
+
});
|
|
5264
|
+
}
|
|
5265
|
+
if (found.length > 0) yield found;
|
|
4752
5266
|
for (let start = 0; start < toRead.length; start += readConcurrency) {
|
|
4753
|
-
const
|
|
4754
|
-
|
|
5267
|
+
const batch = toRead.slice(start, start + readConcurrency);
|
|
5268
|
+
const grants = await Promise.all(batch.map(({ name }) => this.env.OAUTH_KV.get(name, { type: "json" })));
|
|
5269
|
+
const read = batch.filter((_, index) => {
|
|
5270
|
+
const grant = grants[index];
|
|
5271
|
+
return grant !== null && matches(grant);
|
|
5272
|
+
});
|
|
5273
|
+
if (read.length > 0) yield read.map(({ userId: userId$1, grantId }) => ({
|
|
5274
|
+
userId: userId$1,
|
|
5275
|
+
grantId
|
|
5276
|
+
}));
|
|
4755
5277
|
}
|
|
4756
5278
|
cursor = page.list_complete ? void 0 : page.cursor;
|
|
4757
5279
|
} while (cursor);
|
|
4758
|
-
return found;
|
|
4759
5280
|
}
|
|
4760
5281
|
/**
|
|
4761
5282
|
* Creates a new OAuth client
|
|
@@ -4788,7 +5309,12 @@ var OAuthHelpersImpl = class {
|
|
|
4788
5309
|
tokenEndpointAuthMethod,
|
|
4789
5310
|
...authMethodWasExplicit ? { authMethodExplicit: true } : {}
|
|
4790
5311
|
};
|
|
4791
|
-
for (const uri of newClient.redirectUris)
|
|
5312
|
+
for (const uri of newClient.redirectUris) validateRedirectUri(uri, this.provider.serverCapabilities);
|
|
5313
|
+
validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5314
|
+
grantTypes: newClient.grantTypes,
|
|
5315
|
+
responseTypes: newClient.responseTypes,
|
|
5316
|
+
tokenEndpointAuthMethod
|
|
5317
|
+
});
|
|
4792
5318
|
let clientSecret;
|
|
4793
5319
|
if (!isPublicClient) {
|
|
4794
5320
|
clientSecret = generateRandomString(32);
|
|
@@ -4828,8 +5354,13 @@ var OAuthHelpersImpl = class {
|
|
|
4828
5354
|
* @returns A Promise resolving to the updated client info, or null if not found
|
|
4829
5355
|
*/
|
|
4830
5356
|
async updateClient(clientId, updates) {
|
|
5357
|
+
if (this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientId)) throw new TypeError("Client ID Metadata Document clients are updated by changing their document");
|
|
4831
5358
|
const client = await this.provider.getClient(this.env, clientId);
|
|
4832
5359
|
if (!client) return null;
|
|
5360
|
+
if (updates.redirectUris !== void 0) {
|
|
5361
|
+
if (!Array.isArray(updates.redirectUris) || updates.redirectUris.length === 0) throw new Error("redirectUris must not be empty");
|
|
5362
|
+
for (const uri of updates.redirectUris) validateRedirectUri(uri, this.provider.serverCapabilities);
|
|
5363
|
+
}
|
|
4833
5364
|
const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
|
|
4834
5365
|
const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
|
|
4835
5366
|
const isPublicClient = authMethod === "none";
|
|
@@ -4847,9 +5378,15 @@ var OAuthHelpersImpl = class {
|
|
|
4847
5378
|
tokenEndpointAuthMethod: authMethod,
|
|
4848
5379
|
authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
|
|
4849
5380
|
};
|
|
5381
|
+
if (updates.grantTypes !== void 0 || updates.responseTypes !== void 0 || updates.tokenEndpointAuthMethod !== void 0) validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5382
|
+
grantTypes: updatedClient.grantTypes ?? [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN],
|
|
5383
|
+
responseTypes: updatedClient.responseTypes ?? ["code"],
|
|
5384
|
+
tokenEndpointAuthMethod: authMethod
|
|
5385
|
+
});
|
|
4850
5386
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
4851
5387
|
else delete updatedClient.clientSecret;
|
|
4852
|
-
await this.
|
|
5388
|
+
if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
|
|
5389
|
+
else await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
|
|
4853
5390
|
const response = toPublicClientInfo(updatedClient);
|
|
4854
5391
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
4855
5392
|
return response;
|
|
@@ -4860,20 +5397,9 @@ var OAuthHelpersImpl = class {
|
|
|
4860
5397
|
* @returns A Promise resolving when the deletion is confirmed.
|
|
4861
5398
|
*/
|
|
4862
5399
|
async deleteClient(clientId) {
|
|
4863
|
-
let cursor;
|
|
4864
|
-
let allProcessed = false;
|
|
4865
|
-
while (!allProcessed) {
|
|
4866
|
-
const listOptions = { prefix: "grant:" };
|
|
4867
|
-
if (cursor) listOptions.cursor = cursor;
|
|
4868
|
-
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
4869
|
-
for (const key of result.keys) {
|
|
4870
|
-
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
4871
|
-
if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
|
|
4872
|
-
}
|
|
4873
|
-
if (result.list_complete) allProcessed = true;
|
|
4874
|
-
else cursor = result.cursor;
|
|
4875
|
-
}
|
|
4876
5400
|
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
5401
|
+
const grants = this.findGrants(void 0, (grant) => grant.clientId === clientId, DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE);
|
|
5402
|
+
for await (const found of grants) for (const { userId, grantId } of found) await this.revokeGrant(grantId, userId);
|
|
4877
5403
|
}
|
|
4878
5404
|
/**
|
|
4879
5405
|
* Lists all authorization grants for a specific user with pagination support
|
|
@@ -4886,9 +5412,18 @@ var OAuthHelpersImpl = class {
|
|
|
4886
5412
|
const listOptions = { prefix: `grant:${userId}:` };
|
|
4887
5413
|
if (options?.limit !== void 0) listOptions.limit = options.limit;
|
|
4888
5414
|
if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
|
|
4889
|
-
const
|
|
5415
|
+
const isOwn = (key) => !key.name.slice(listOptions.prefix.length).includes(":");
|
|
5416
|
+
let response = await this.env.OAUTH_KV.list(listOptions);
|
|
5417
|
+
let ownKeys = response.keys.filter(isOwn);
|
|
5418
|
+
while (ownKeys.length === 0 && !response.list_complete) {
|
|
5419
|
+
response = await this.env.OAUTH_KV.list({
|
|
5420
|
+
...listOptions,
|
|
5421
|
+
cursor: response.cursor
|
|
5422
|
+
});
|
|
5423
|
+
ownKeys = response.keys.filter(isOwn);
|
|
5424
|
+
}
|
|
4890
5425
|
const grantSummaries = [];
|
|
4891
|
-
const promises =
|
|
5426
|
+
const promises = ownKeys.map(async (key) => {
|
|
4892
5427
|
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
4893
5428
|
if (grantData) {
|
|
4894
5429
|
const summary = {
|
|
@@ -4917,6 +5452,36 @@ var OAuthHelpersImpl = class {
|
|
|
4917
5452
|
* @param userId - The ID of the user who owns the grant
|
|
4918
5453
|
* @returns A Promise resolving when the revocation is confirmed.
|
|
4919
5454
|
*/
|
|
5455
|
+
/** {@inheritDoc OAuthHelpers.describeConsent} */
|
|
5456
|
+
async describeConsent(authRequest) {
|
|
5457
|
+
const client = await this.lookupClient(authRequest.clientId);
|
|
5458
|
+
const isCimd = !!this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(authRequest.clientId);
|
|
5459
|
+
return describeConsent(client, authRequest, isCimd);
|
|
5460
|
+
}
|
|
5461
|
+
/** {@inheritDoc OAuthHelpers.isConsentRemembered} */
|
|
5462
|
+
isConsentRemembered(request, authRequest, remember) {
|
|
5463
|
+
return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
|
|
5464
|
+
}
|
|
5465
|
+
/** {@inheritDoc OAuthHelpers.beginConsent} */
|
|
5466
|
+
beginConsent(authRequest) {
|
|
5467
|
+
return beginConsent(this.env.OAUTH_KV, this.provider.consentCookies, authRequest);
|
|
5468
|
+
}
|
|
5469
|
+
/** {@inheritDoc OAuthHelpers.approveConsent} */
|
|
5470
|
+
approveConsent(request, handle, options) {
|
|
5471
|
+
return approveConsent(this.env.OAUTH_KV, this.provider.consentCookies, this.provider.options.scopesSupported, request, handle, options);
|
|
5472
|
+
}
|
|
5473
|
+
/** {@inheritDoc OAuthHelpers.denyConsent} */
|
|
5474
|
+
denyConsent(request, handle, options) {
|
|
5475
|
+
return denyConsent(this.env.OAUTH_KV, this.provider.consentCookies, request, handle, options);
|
|
5476
|
+
}
|
|
5477
|
+
/** {@inheritDoc OAuthHelpers.beginUpstream} */
|
|
5478
|
+
beginUpstream(authRequest, options) {
|
|
5479
|
+
return beginUpstream(this.env.OAUTH_KV, this.provider.consentCookies, authRequest, options);
|
|
5480
|
+
}
|
|
5481
|
+
/** {@inheritDoc OAuthHelpers.finishUpstream} */
|
|
5482
|
+
finishUpstream(request) {
|
|
5483
|
+
return finishUpstream(this.env.OAUTH_KV, this.provider.consentCookies, request);
|
|
5484
|
+
}
|
|
4920
5485
|
async revokeGrant(grantId, userId) {
|
|
4921
5486
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
4922
5487
|
const tokenPrefix = `token:${userId}:${grantId}:`;
|
|
@@ -4961,6 +5526,7 @@ var OAuthHelpersImpl = class {
|
|
|
4961
5526
|
const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
|
|
4962
5527
|
const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
|
|
4963
5528
|
const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
|
|
5529
|
+
const start = parsePurgeCursor(options?.cursor);
|
|
4964
5530
|
const now = Math.floor(Date.now() / 1e3);
|
|
4965
5531
|
const result = {
|
|
4966
5532
|
grantsChecked: 0,
|
|
@@ -4969,10 +5535,10 @@ var OAuthHelpersImpl = class {
|
|
|
4969
5535
|
tokensPurged: 0,
|
|
4970
5536
|
done: false
|
|
4971
5537
|
};
|
|
4972
|
-
if (purgeOrphanedGrants || purgeExpiredGrants) {
|
|
5538
|
+
if (start.phase === "grants" && (purgeOrphanedGrants || purgeExpiredGrants)) {
|
|
4973
5539
|
const knownGoodClients = /* @__PURE__ */ new Set();
|
|
4974
5540
|
const knownMissingClients = /* @__PURE__ */ new Set();
|
|
4975
|
-
let grantCursor;
|
|
5541
|
+
let grantCursor = start.kvCursor;
|
|
4976
5542
|
let grantsDone = false;
|
|
4977
5543
|
while (!grantsDone && result.grantsChecked < batchSize) {
|
|
4978
5544
|
const listOptions = {
|
|
@@ -4988,7 +5554,7 @@ var OAuthHelpersImpl = class {
|
|
|
4988
5554
|
if (!grantData) continue;
|
|
4989
5555
|
let shouldPurge = false;
|
|
4990
5556
|
if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
|
|
4991
|
-
if (!shouldPurge && purgeOrphanedGrants && !
|
|
5557
|
+
if (!shouldPurge && purgeOrphanedGrants && !isClientIdMetadataDocumentUrl(grantData.clientId)) {
|
|
4992
5558
|
if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
|
|
4993
5559
|
else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
|
|
4994
5560
|
else {
|
|
@@ -5004,12 +5570,15 @@ var OAuthHelpersImpl = class {
|
|
|
5004
5570
|
if (page.list_complete) grantsDone = true;
|
|
5005
5571
|
else grantCursor = page.cursor;
|
|
5006
5572
|
}
|
|
5007
|
-
if (!grantsDone) return
|
|
5573
|
+
if (!grantsDone) return {
|
|
5574
|
+
...result,
|
|
5575
|
+
cursor: `grants:${grantCursor ?? ""}`
|
|
5576
|
+
};
|
|
5008
5577
|
}
|
|
5009
5578
|
if (purgeOrphanedTokens) {
|
|
5010
5579
|
const knownGoodGrants = /* @__PURE__ */ new Set();
|
|
5011
5580
|
const knownMissingGrants = /* @__PURE__ */ new Set();
|
|
5012
|
-
let tokenCursor;
|
|
5581
|
+
let tokenCursor = start.phase === "tokens" ? start.kvCursor : void 0;
|
|
5013
5582
|
let tokensDone = false;
|
|
5014
5583
|
while (!tokensDone && result.tokensChecked < batchSize) {
|
|
5015
5584
|
const listOptions = {
|
|
@@ -5037,7 +5606,10 @@ var OAuthHelpersImpl = class {
|
|
|
5037
5606
|
if (page.list_complete) tokensDone = true;
|
|
5038
5607
|
else tokenCursor = page.cursor;
|
|
5039
5608
|
}
|
|
5040
|
-
if (!tokensDone) return
|
|
5609
|
+
if (!tokensDone) return {
|
|
5610
|
+
...result,
|
|
5611
|
+
cursor: `tokens:${tokenCursor ?? ""}`
|
|
5612
|
+
};
|
|
5041
5613
|
}
|
|
5042
5614
|
result.done = true;
|
|
5043
5615
|
return result;
|
|
@@ -5050,4 +5622,4 @@ var OAuthHelpersImpl = class {
|
|
|
5050
5622
|
var oauth_provider_default = OAuthProvider;
|
|
5051
5623
|
|
|
5052
5624
|
//#endregion
|
|
5053
|
-
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer,
|
|
5625
|
+
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, authorizationErrorRedirect, oauth_provider_default as default, getOAuthApi, insufficientScope };
|