@cloudflare/workers-oauth-provider 1.1.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.
@@ -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: options.allowImplicitFlow ? ["code", "token"] : ["code"],
78
+ responseTypes: ["code"],
39
79
  tokenEndpointAuthMethods: [
40
80
  "client_secret_basic",
41
81
  "client_secret_post",
42
82
  "none"
43
83
  ],
44
- codeChallengeMethods: options.allowPlainPKCE ? ["plain", "S256"] : ["S256"]
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
- * Validates that a redirect URI has a scheme and does not use a dangerous
249
- * pseudo-scheme or contain control characters.
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 validateRedirectUriScheme(redirectUri) {
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
- function requireValidRedirectUris(redirectUris) {
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) validateRedirectUriScheme(redirectUri);
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, {
@@ -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
1204
  /**
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
- /**
962
- * Fold only the URI components whose comparison is ASCII case-insensitive: the
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 foldResourceSchemeAndHost(resource) {
967
- const schemeSeparator = resource.indexOf("://");
968
- if (schemeSeparator <= 0) return void 0;
969
- const authorityStart = schemeSeparator + 3;
970
- const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
971
- const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
972
- const authority = resource.slice(authorityStart, authorityEnd);
973
- const hostStart = authority.lastIndexOf("@") + 1;
974
- let hostEnd;
975
- if (authority[hostStart] === "[") {
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
- const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
984
- return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
985
- }
986
- /** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
987
- function normalizeEmptyPath(resource) {
988
- const authorityStart = resource.indexOf("://") + 3;
989
- const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
990
- if (pathOffset === -1) return `${resource}/`;
991
- const pathStart = authorityStart + pathOffset;
992
- return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
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) => !isValidScopeToken(value))) throw new TypeError("insufficientScope requires at least one valid OAuth scope token");
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 addCorsHeaders(new Response(null, {
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 addCorsHeaders(new Response(null, { status: 404 }), request);
1384
- if (request.method !== "GET" && request.method !== "HEAD") return addCorsHeaders(new Response(null, {
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 addCorsHeaders(request.method === "HEAD" ? new Response(null, {
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 addCorsHeaders(new Response(null, {
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 addCorsHeaders(createBearerChallenge(url, validated, false), request);
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 addCorsHeaders(createValidationUnavailableResponse(), request);
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 addCorsHeaders(createBearerChallenge(url, validated, true), request);
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 addCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
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) => !isValidScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
1440
- const resourceScopes = [...new Set(configuredScopes)].filter((scope) => scope !== "offline_access");
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" || !isValidScopeToken(scope))) return false;
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,30 +1749,44 @@ function createBearerChallenge(requestUrl, validated, invalidToken) {
1524
1749
  }
1525
1750
  });
1526
1751
  }
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
+ }
1527
1784
  function createValidationUnavailableResponse() {
1528
1785
  return new Response(null, {
1529
1786
  status: 503,
1530
1787
  headers: NO_CACHE_HEADERS$1
1531
1788
  });
1532
1789
  }
1533
- function addCorsHeaders(response, request) {
1534
- const origin = request.headers.get("Origin");
1535
- if (!origin) return response;
1536
- const withCors = new Response(response.body, response);
1537
- withCors.headers.set("Access-Control-Allow-Origin", origin);
1538
- withCors.headers.set("Access-Control-Allow-Methods", "*");
1539
- withCors.headers.set("Access-Control-Allow-Headers", "Authorization, *");
1540
- appendHeaderValue$1(withCors.headers, "Vary", "Origin");
1541
- appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
1542
- appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "Retry-After");
1543
- withCors.headers.set("Access-Control-Max-Age", "86400");
1544
- return withCors;
1545
- }
1546
- function appendHeaderValue$1(headers, name, value) {
1547
- const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1548
- if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
1549
- headers.set(name, values.join(", "));
1550
- }
1551
1790
 
1552
1791
  //#endregion
1553
1792
  //#region src/oauth-consent.ts
@@ -1595,6 +1834,21 @@ function validateRememberConsentOptions(options) {
1595
1834
  if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
1596
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");
1597
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
+ }
1598
1852
  /** Start a consent transaction for an authorization request that must be shown to the user. */
1599
1853
  async function beginConsent(kv, cookies, request) {
1600
1854
  const { handle, hash } = await createTransaction(kv, {
@@ -1647,19 +1901,15 @@ async function denyConsent(kv, cookies, request, handle, options = {}) {
1647
1901
  const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1648
1902
  await transaction.consume();
1649
1903
  const record = transaction.record;
1650
- const redirect = new URL(record.request.redirectUri);
1651
- redirect.searchParams.set("error", "access_denied");
1652
- if (options.description) redirect.searchParams.set("error_description", options.description);
1653
- if (record.request.state) redirect.searchParams.set("state", record.request.state);
1654
- if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
1904
+ const redirectTo = authorizationErrorRedirect(record.request, "access_denied", options.description);
1655
1905
  const headers = new Headers({
1656
1906
  "Cache-Control": "no-store",
1657
- Location: redirect.href
1907
+ Location: redirectTo
1658
1908
  });
1659
1909
  headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1660
1910
  return {
1661
1911
  request: record.request,
1662
- redirectTo: redirect.href,
1912
+ redirectTo,
1663
1913
  headers
1664
1914
  };
1665
1915
  }
@@ -1887,6 +2137,9 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
1887
2137
  GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
1888
2138
  return GrantType$1;
1889
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";
1890
2143
  function toPublicClientInfo(client) {
1891
2144
  const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
1892
2145
  return publicClient;
@@ -1946,7 +2199,6 @@ var OAuthAuthorizationServer = class {
1946
2199
  const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
1947
2200
  this.#impl = new OAuthProviderImpl({
1948
2201
  ...commonOptions,
1949
- defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
1950
2202
  authorizationServer: {
1951
2203
  issuer,
1952
2204
  resources,
@@ -2018,7 +2270,7 @@ var OAuthProviderImpl = class {
2018
2270
  constructor(options) {
2019
2271
  this.errorResponses = /* @__PURE__ */ new WeakSet();
2020
2272
  this.typedApiHandlers = [];
2021
- this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
2273
+ this.typedDefaultHandler = "authorizationServer" in options ? void 0 : this.validateHandler(options.defaultHandler, "defaultHandler");
2022
2274
  const roleBased = "authorizationServer" in options;
2023
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.");
2024
2276
  let normalizedOptions;
@@ -2027,10 +2279,11 @@ var OAuthProviderImpl = class {
2027
2279
  const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
2028
2280
  this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
2029
2281
  this.explicitIssuer = authorizationServer$1.issuer;
2282
+ const issuerBase = authorizationServer$1.issuer.replace(/\/+$/, "");
2030
2283
  normalizedOptions = {
2031
2284
  ...commonOptions,
2032
- authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
2033
- tokenEndpoint: authorizationServer$1.tokenEndpoint,
2285
+ authorizeEndpoint: authorizationServer$1.authorizeEndpoint ?? `${issuerBase}${DEFAULT_AUTHORIZE_PATH}`,
2286
+ tokenEndpoint: authorizationServer$1.tokenEndpoint ?? `${issuerBase}${DEFAULT_TOKEN_PATH}`,
2034
2287
  clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
2035
2288
  };
2036
2289
  const declared = authorizationServer$1.resources;
@@ -2042,7 +2295,7 @@ var OAuthProviderImpl = class {
2042
2295
  this.explicitIssuer = void 0;
2043
2296
  normalizedOptions = options;
2044
2297
  configuredResourceServers = [{
2045
- resourceMetadata: options.resourceMetadata,
2298
+ resourceMetadata: withRequiredScopes(options.resourceMetadata, options.requiredScopes),
2046
2299
  resolveExternalToken: options.resolveExternalToken
2047
2300
  }];
2048
2301
  }
@@ -2106,11 +2359,14 @@ var OAuthProviderImpl = class {
2106
2359
  }
2107
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).`);
2108
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).`);
2109
2366
  this.serverCapabilities = buildOAuthServerCapabilities({
2110
- allowImplicitFlow: !!this.options.allowImplicitFlow,
2111
- allowPlainPKCE: this.options.allowPlainPKCE === true,
2112
2367
  allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
2113
- enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
2368
+ enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization,
2369
+ allowPrivateUseRedirectUris: this.options.allowPrivateUseRedirectUris === true
2114
2370
  });
2115
2371
  validateAuthorizationServerScopes(this.options.scopesSupported);
2116
2372
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
@@ -2149,16 +2405,24 @@ var OAuthProviderImpl = class {
2149
2405
  }
2150
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)");
2151
2407
  }
2152
- /** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
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
+ */
2153
2414
  validateAuthorizationServerRouteIsolation() {
2154
2415
  const issuerUrl = new URL(this.explicitIssuer);
2155
- const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
2156
2416
  const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
2157
- if (this.matchEndpoint(tokenUrl, discoveryEndpoint)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
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");
2158
2421
  if (!this.options.clientRegistrationEndpoint) return;
2422
+ if (this.isClientRegistrationEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with clientRegistrationEndpoint");
2159
2423
  const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
2160
- if (this.matchEndpoint(registrationUrl, this.options.tokenEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
2161
- if (this.matchEndpoint(registrationUrl, discoveryEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
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");
2162
2426
  }
2163
2427
  /**
2164
2428
  * A protected route must be the canonical resource path or a path-boundary descendant of
@@ -2259,7 +2523,7 @@ var OAuthProviderImpl = class {
2259
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)");
2260
2524
  }
2261
2525
  }
2262
- 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");
2263
2527
  if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
2264
2528
  }
2265
2529
  /**
@@ -2298,49 +2562,42 @@ var OAuthProviderImpl = class {
2298
2562
  const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
2299
2563
  const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
2300
2564
  if (request.method === "OPTIONS") {
2301
- if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
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, {
2302
2566
  status: 204,
2303
2567
  headers: { "Content-Length": "0" }
2304
2568
  }), request);
2305
2569
  }
2306
2570
  if (this.isAuthorizationServerMetadataRequest(url)) {
2307
- if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
2571
+ if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
2308
2572
  status: 405,
2309
2573
  headers: { Allow: "GET, HEAD, OPTIONS" }
2310
2574
  }), request);
2311
- const response = await this.handleMetadataDiscovery(url);
2312
- return this.addCorsHeaders(withoutBodyForHead(request, response), request);
2575
+ return withCorsHeaders(withoutBodyForHead(request, await this.handleMetadataDiscovery(url)), request);
2313
2576
  }
2314
2577
  if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
2315
- if (!metadataResourceServer) return this.addCorsHeaders(new Response(null, { status: 404 }), request);
2316
- if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
2578
+ if (!metadataResourceServer) return withCorsHeaders(new Response(null, { status: 404 }), request);
2579
+ if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
2317
2580
  status: 405,
2318
2581
  headers: { Allow: "GET, HEAD, OPTIONS" }
2319
2582
  }), request);
2320
- const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
2321
- return this.addCorsHeaders(withoutBodyForHead(request, response), request);
2583
+ return withCorsHeaders(withoutBodyForHead(request, this.handleProtectedResourceMetadata(url, metadataResourceServer)), request);
2322
2584
  }
2323
2585
  if (this.isTokenEndpoint(url)) {
2324
2586
  const parsed = await this.parseTokenEndpointRequest(request, env);
2325
- if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
2587
+ if (parsed instanceof Response) return withCorsHeaders(parsed, request);
2326
2588
  let response;
2327
- if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
2589
+ if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
2328
2590
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
2329
2591
  if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
2330
- return this.addCorsHeaders(response, request);
2331
- }
2332
- if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
2333
- const response = await this.handleClientRegistration(request, env);
2334
- return this.addCorsHeaders(response, request);
2592
+ return withCorsHeaders(response, request);
2335
2593
  }
2336
- if (apiRoute) {
2337
- const response = await this.handleApiRequest(request, env, ctx, apiRoute);
2338
- return this.addCorsHeaders(response, request);
2339
- }
2340
- 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 });
2341
2598
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
2342
- if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
2343
- return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
2599
+ if (defaultHandler.type === HandlerType.EXPORTED_HANDLER) return defaultHandler.handler.fetch(request, env, ctx);
2600
+ return new defaultHandler.handler(ctx, env).fetch(request);
2344
2601
  }
2345
2602
  /**
2346
2603
  * Decodes a token and returns token data with decrypted props
@@ -2576,7 +2833,7 @@ var OAuthProviderImpl = class {
2576
2833
  });
2577
2834
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
2578
2835
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
2579
- if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
2836
+ if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
2580
2837
  category: "client-authentication",
2581
2838
  reason: "token_endpoint_auth_method_mismatch",
2582
2839
  detail: {
@@ -2652,26 +2909,6 @@ var OAuthProviderImpl = class {
2652
2909
  return new URL(tokenEndpoint).origin;
2653
2910
  }
2654
2911
  /**
2655
- * Adds CORS headers to a response
2656
- * @param response - The response to add CORS headers to
2657
- * @param request - The original request
2658
- * @returns A new Response with CORS headers added
2659
- */
2660
- addCorsHeaders(response, request) {
2661
- const origin = request.headers.get("Origin");
2662
- if (!origin) return response;
2663
- const newResponse = new Response(response.body, response);
2664
- newResponse.headers.set("Access-Control-Allow-Origin", origin);
2665
- newResponse.headers.set("Access-Control-Allow-Methods", "*");
2666
- newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
2667
- appendHeaderValue(newResponse.headers, "Vary", "Origin");
2668
- const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
2669
- for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
2670
- newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
2671
- newResponse.headers.set("Access-Control-Max-Age", "86400");
2672
- return newResponse;
2673
- }
2674
- /**
2675
2912
  * Handles the OAuth metadata discovery endpoint
2676
2913
  * Implements RFC 8414 for OAuth Server Metadata
2677
2914
  * @param requestUrl - The URL of the incoming request
@@ -2693,7 +2930,7 @@ var OAuthProviderImpl = class {
2693
2930
  registration_endpoint: registrationEndpoint,
2694
2931
  scopes_supported: this.options.scopesSupported,
2695
2932
  response_types_supported: responseTypesSupported,
2696
- response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
2933
+ response_modes_supported: ["query"],
2697
2934
  grant_types_supported: grantTypesSupported,
2698
2935
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
2699
2936
  token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
@@ -2710,7 +2947,7 @@ var OAuthProviderImpl = class {
2710
2947
  }
2711
2948
  /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
2712
2949
  normalizeProtectedResourceScopes(scopes) {
2713
- return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
2950
+ return baselineResourceScopes(scopes);
2714
2951
  }
2715
2952
  /**
2716
2953
  * Handles the OAuth Protected Resource Metadata endpoint
@@ -2780,7 +3017,10 @@ var OAuthProviderImpl = class {
2780
3017
  */
2781
3018
  async callTokenExchangeCallback(options, env) {
2782
3019
  try {
2783
- return await Promise.resolve(this.options.tokenExchangeCallback(options));
3020
+ return await Promise.resolve(this.options.tokenExchangeCallback({
3021
+ ...options,
3022
+ env
3023
+ }));
2784
3024
  } catch (error) {
2785
3025
  if (error instanceof OAuthError && error.code === "invalid_grant") try {
2786
3026
  await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
@@ -2875,9 +3115,9 @@ var OAuthProviderImpl = class {
2875
3115
  reason: "code_replayed"
2876
3116
  });
2877
3117
  }
2878
- let codeChallengeMethod;
2879
3118
  try {
2880
- codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
3119
+ if (grantData.codeChallenge) validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod);
3120
+ else normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
2881
3121
  } catch (error) {
2882
3122
  return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
2883
3123
  category: "authorization-code-grant",
@@ -2907,14 +3147,8 @@ var OAuthProviderImpl = class {
2907
3147
  category: "authorization-code-grant",
2908
3148
  reason: "code_verifier_missing"
2909
3149
  });
2910
- let calculatedChallenge;
2911
- if (codeChallengeMethod === "S256") {
2912
- const data = new TextEncoder().encode(codeVerifier);
2913
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
2914
- const hashArray = Array.from(new Uint8Array(hashBuffer));
2915
- calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
2916
- } else calculatedChallenge = codeVerifier;
2917
- 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" }, {
2918
3152
  category: "authorization-code-grant",
2919
3153
  reason: "code_verifier_mismatch"
2920
3154
  });
@@ -2928,6 +3162,10 @@ var OAuthProviderImpl = class {
2928
3162
  let accessTokenEncryptionKey = encryptionKey;
2929
3163
  let encryptedAccessTokenProps = grantData.encryptedProps;
2930
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
+ });
2931
3169
  if (this.options.tokenExchangeCallback) {
2932
3170
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
2933
3171
  let grantProps = decryptedProps;
@@ -2951,11 +3189,13 @@ var OAuthProviderImpl = class {
2951
3189
  }
2952
3190
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2953
3191
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
2954
- if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
2955
- if (callbackResult.refreshTokenIdleTTL !== void 0) return this.createErrorResponse("invalid_request", { description: "refreshTokenIdleTTL is only honored during refresh token exchange" }, {
2956
- category: "authorization-code-grant",
2957
- reason: "refresh_token_idle_ttl_wrong_grant"
2958
- });
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
+ }
2959
3199
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
2960
3200
  }
2961
3201
  const grantResult = await encryptProps(grantProps);
@@ -2990,7 +3230,7 @@ var OAuthProviderImpl = class {
2990
3230
  grantData.previousRefreshTokenId = void 0;
2991
3231
  grantData.previousRefreshTokenWrappedKey = void 0;
2992
3232
  grantData.expiresAt = expiresAt;
2993
- }
3233
+ } else grantData.expiresAt = now + accessTokenTTL;
2994
3234
  if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
2995
3235
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
2996
3236
  const tokenResponse = {
@@ -3074,6 +3314,10 @@ var OAuthProviderImpl = class {
3074
3314
  let accessTokenEncryptionKey = encryptionKey;
3075
3315
  let encryptedAccessTokenProps = grantData.encryptedProps;
3076
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
+ });
3077
3321
  let grantPropsChanged = false;
3078
3322
  if (this.options.tokenExchangeCallback) {
3079
3323
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
@@ -3099,10 +3343,6 @@ var OAuthProviderImpl = class {
3099
3343
  }
3100
3344
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
3101
3345
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
3102
- if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
3103
- category: "refresh-token-grant",
3104
- reason: "refresh_token_ttl_immutable"
3105
- });
3106
3346
  if (callbackResult.refreshTokenIdleTTL !== void 0) {
3107
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` }, {
3108
3348
  category: "refresh-token-grant",
@@ -3231,6 +3471,13 @@ var OAuthProviderImpl = class {
3231
3471
  });
3232
3472
  if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
3233
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
+ });
3234
3481
  const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
3235
3482
  const now = Math.floor(Date.now() / 1e3);
3236
3483
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
@@ -3285,13 +3532,6 @@ var OAuthProviderImpl = class {
3285
3532
  }
3286
3533
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
3287
3534
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
3288
- if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
3289
- description: "refreshTokenIdleTTL is only honored during refresh token exchange",
3290
- internal: {
3291
- category: "token-exchange-grant",
3292
- reason: "refresh_token_idle_ttl_wrong_grant"
3293
- }
3294
- });
3295
3535
  if (accessTokenProps !== decryptedProps) {
3296
3536
  const tokenResult = await encryptProps(accessTokenProps);
3297
3537
  encryptedAccessTokenProps = tokenResult.encryptedData;
@@ -3311,7 +3551,7 @@ var OAuthProviderImpl = class {
3311
3551
  access_token: await this.createAccessToken({
3312
3552
  userId: tokenSummary.userId,
3313
3553
  grantId: tokenSummary.grantId,
3314
- clientId: tokenSummary.grant.clientId,
3554
+ clientId: clientInfo.clientId,
3315
3555
  scope: tokenScopes,
3316
3556
  encryptedProps: encryptedAccessTokenProps,
3317
3557
  encryptionKey: accessTokenEncryptionKey,
@@ -3595,15 +3835,7 @@ var OAuthProviderImpl = class {
3595
3835
  };
3596
3836
  }
3597
3837
  /**
3598
- * Handles OAuth 2.0 token revocation requests (RFC 7009)
3599
- * @param body - The parsed request body containing revocation parameters
3600
- * @param env - Cloudflare Worker environment variables
3601
- * @returns Response confirming revocation or error
3602
- */
3603
- async handleRevocationRequest(body, clientInfo, env) {
3604
- return this.revokeToken(body, clientInfo, env);
3605
- }
3606
- /**
3838
+ * Handles an RFC 7009 revocation request from an authenticated client.
3607
3839
  * - Access tokens: Revokes only the specific token
3608
3840
  * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
3609
3841
  * Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
@@ -3695,14 +3927,17 @@ var OAuthProviderImpl = class {
3695
3927
  const callbackRequest = request.clone();
3696
3928
  let parsedJson;
3697
3929
  try {
3698
- const text = await request.text();
3699
- if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
3700
- description: "Request payload too large, must be under 1 MiB",
3701
- statusCode: 413
3702
- }, {
3703
- category: "client-registration",
3704
- reason: "payload_too_large"
3705
- });
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
+ }
3706
3941
  parsedJson = JSON.parse(text);
3707
3942
  } catch {
3708
3943
  return this.createErrorResponse("invalid_request", {
@@ -3717,8 +3952,16 @@ var OAuthProviderImpl = class {
3717
3952
  let metadata;
3718
3953
  try {
3719
3954
  clientMetadata = requireJsonObject(parsedJson);
3720
- metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
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);
3721
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
+ });
3722
3965
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
3723
3966
  category: "client-registration",
3724
3967
  reason: "metadata_invalid",
@@ -3727,10 +3970,6 @@ var OAuthProviderImpl = class {
3727
3970
  }
3728
3971
  const authMethod = metadata.tokenEndpointAuthMethod;
3729
3972
  const isPublicClient = authMethod === "none";
3730
- if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
3731
- category: "client-registration",
3732
- reason: "public_client_forbidden"
3733
- });
3734
3973
  const clientId = generateRandomString(16);
3735
3974
  let clientSecret;
3736
3975
  let hashedSecret;
@@ -3765,7 +4004,7 @@ var OAuthProviderImpl = class {
3765
4004
  }));
3766
4005
  } catch (error) {
3767
4006
  return this.createErrorResponse("server_error", {
3768
- description: error instanceof Error ? error.message : "Client registration callback failed",
4007
+ description: "Client registration callback failed",
3769
4008
  statusCode: 500
3770
4009
  }, {
3771
4010
  category: "client-registration",
@@ -3803,7 +4042,7 @@ var OAuthProviderImpl = class {
3803
4042
  }
3804
4043
  if (clientSecret) {
3805
4044
  response.client_secret = clientSecret;
3806
- 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;
3807
4046
  response.client_secret_issued_at = clientInfo.registrationDate;
3808
4047
  }
3809
4048
  return new Response(JSON.stringify(response), {
@@ -3848,7 +4087,7 @@ var OAuthProviderImpl = class {
3848
4087
  const id = await generateTokenId(accessToken);
3849
4088
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
3850
4089
  }
3851
- if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
4090
+ if (!tokenData && (!externalTokenResolver || isIssuedTokenFormat(accessToken))) return this.createErrorResponse("invalid_token", {
3852
4091
  description: "Invalid access token",
3853
4092
  statusCode: 401,
3854
4093
  headers: { "WWW-Authenticate": challenge("invalid_token") }
@@ -4054,7 +4293,7 @@ var OAuthProviderImpl = class {
4054
4293
  }
4055
4294
  }
4056
4295
  async getClient(env, clientId) {
4057
- if (this.isClientMetadataUrl(clientId)) {
4296
+ if (isClientIdMetadataDocumentUrl(clientId)) {
4058
4297
  if (!this.options.clientIdMetadataDocumentEnabled) {
4059
4298
  const clientKey$1 = `client:${clientId}`;
4060
4299
  return env.OAUTH_KV.get(clientKey$1, { type: "json" });
@@ -4265,13 +4504,6 @@ var OAuthProviderImpl = class {
4265
4504
  return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
4266
4505
  }
4267
4506
  /**
4268
- * Checks if a client_id is a CIMD URL (HTTPS with non-root path).
4269
- * Not private because OAuthHelpersImpl needs access for purgeExpiredData.
4270
- */
4271
- isClientMetadataUrl(clientId) {
4272
- return isClientIdMetadataDocumentUrl(clientId);
4273
- }
4274
- /**
4275
4507
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
4276
4508
  */
4277
4509
  buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
@@ -4343,60 +4575,6 @@ var OAuthProviderImpl = class {
4343
4575
  }
4344
4576
  };
4345
4577
  /**
4346
- * Structured OAuth 2.0 token-endpoint error.
4347
- *
4348
- * Throw from a `tokenExchangeCallback` or any code it calls to surface a
4349
- * standard OAuth token response (`{ error, error_description }`) instead of a
4350
- * generic `500 Internal Server Error`.
4351
- *
4352
- * Anything thrown that is **not** an `OAuthError` continues to surface as
4353
- * a 500 so unexpected failures remain visible — the provider does not
4354
- * catch-everything-and-return-400.
4355
- *
4356
- * @example
4357
- * ```ts
4358
- * import { OAuthError } from '@cloudflare/workers-oauth-provider';
4359
- *
4360
- * tokenExchangeCallback: async (options) => {
4361
- * if (options.grantType === 'refresh_token') {
4362
- * // refreshUpstream() may throw OAuthError from any depth
4363
- * return { newProps: await refreshUpstream(options.props) };
4364
- * }
4365
- * }
4366
- *
4367
- * async function refreshUpstream(props) {
4368
- * const res = await fetch(...);
4369
- * if (res.status === 401) {
4370
- * // invalid_grant can never recover: the provider also revokes this grant and its tokens.
4371
- * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
4372
- * }
4373
- * if (res.status === 429) {
4374
- * // Mirror upstream's Retry-After if present, otherwise pick a default.
4375
- * throw new OAuthError('temporarily_unavailable', {
4376
- * description: 'upstream rate limited',
4377
- * statusCode: 429,
4378
- * headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
4379
- * });
4380
- * }
4381
- * return await res.json();
4382
- * }
4383
- * ```
4384
- */
4385
- var OAuthError = class extends Error {
4386
- constructor(code, options) {
4387
- super(options.description);
4388
- this.name = "OAuthError";
4389
- this.code = code;
4390
- this.options = {
4391
- ...options,
4392
- statusCode: options.statusCode ?? 400
4393
- };
4394
- this.description = this.options.description;
4395
- this.statusCode = this.options.statusCode;
4396
- this.headers = this.options.headers;
4397
- }
4398
- };
4399
- /**
4400
4578
  * Intentional public error from an external bearer-token validator.
4401
4579
  *
4402
4580
  * Throw only from `resolveExternalToken` when an expected validation outcome
@@ -4471,6 +4649,75 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
4471
4649
  * to clamp absolute expirations when writing grants back to KV.
4472
4650
  */
4473
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
+ }
4474
4721
  function isValidAccessTokenTTL(value) {
4475
4722
  return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
4476
4723
  }
@@ -4497,11 +4744,6 @@ const MAX_KV_LIST_LIMIT = 1e3;
4497
4744
  * matched from `list()` alone and never read.
4498
4745
  */
4499
4746
  const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
4500
- function getRevokeExistingGrantsBatchSize(batchSize) {
4501
- if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
4502
- if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
4503
- return Math.min(batchSize, MAX_KV_LIST_LIMIT);
4504
- }
4505
4747
  /** KV serialises key metadata as JSON and rejects a write carrying more than this. */
4506
4748
  const MAX_KV_METADATA_BYTES = 1024;
4507
4749
  /**
@@ -4524,6 +4766,16 @@ function grantPutOptions(grant, expiry = {}) {
4524
4766
  metadata
4525
4767
  } : expiry;
4526
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
+ }
4527
4779
  /** Whether a listed key carries metadata this version can match a grant on. */
4528
4780
  function isGrantKeyMetadata(value) {
4529
4781
  if (typeof value !== "object" || value === null) return false;
@@ -4534,6 +4786,8 @@ function isGrantKeyMetadata(value) {
4534
4786
  * Length of generated token strings
4535
4787
  */
4536
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}}$`);
4537
4791
  /**
4538
4792
  * Checks if a resource server matches an audience claim.
4539
4793
  * Uses origin comparison (case-insensitive hostname via URL normalization)
@@ -4555,11 +4809,6 @@ function audienceMatches(resourceServerUrl, audienceValue) {
4555
4809
  return false;
4556
4810
  }
4557
4811
  }
4558
- function appendHeaderValue(headers, name, value) {
4559
- const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
4560
- if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
4561
- headers.set(name, values.join(", "));
4562
- }
4563
4812
  /** Whether a request or audience names one canonical configured resource. */
4564
4813
  function isExactResource(value, configuredResource) {
4565
4814
  if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
@@ -4619,6 +4868,20 @@ function decodeFormUrlEncodedComponent(value) {
4619
4868
  return decodeURIComponent(value.replace(/\+/g, " "));
4620
4869
  }
4621
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
+ /**
4622
4885
  * Generates a cryptographically secure random string
4623
4886
  * @param length - The length of the string to generate
4624
4887
  * @returns A random string of the specified length
@@ -4679,56 +4942,6 @@ function base64UrlEncode(str) {
4679
4942
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
4680
4943
  }
4681
4944
  /**
4682
- * Decodes a base64url-encoded string to bytes.
4683
- */
4684
- function base64UrlToBytes(base64Url) {
4685
- const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
4686
- const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
4687
- const binaryString = atob(padded);
4688
- const bytes = new Uint8Array(binaryString.length);
4689
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
4690
- return bytes;
4691
- }
4692
- /**
4693
- * Parses a base64url-encoded JWT JSON part into an object.
4694
- */
4695
- function parseJwtJsonPart(encoded) {
4696
- try {
4697
- const json = new TextDecoder().decode(base64UrlToBytes(encoded));
4698
- const parsed = JSON.parse(json);
4699
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
4700
- return parsed;
4701
- } catch {
4702
- throw new Error("Malformed JWT part");
4703
- }
4704
- }
4705
- /**
4706
- * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
4707
- */
4708
- function getJwtCryptoAlgorithms(alg) {
4709
- if (alg === "RS256") {
4710
- const algorithm = {
4711
- name: "RSASSA-PKCS1-v1_5",
4712
- hash: "SHA-256"
4713
- };
4714
- return {
4715
- importAlgorithm: algorithm,
4716
- verifyAlgorithm: algorithm
4717
- };
4718
- }
4719
- if (alg === "ES256") return {
4720
- importAlgorithm: {
4721
- name: "ECDSA",
4722
- namedCurve: "P-256"
4723
- },
4724
- verifyAlgorithm: {
4725
- name: "ECDSA",
4726
- hash: "SHA-256"
4727
- }
4728
- };
4729
- throw new Error(`Unsupported JWT alg: ${alg}`);
4730
- }
4731
- /**
4732
4945
  * Encodes an ArrayBuffer as base64 string
4733
4946
  * @param buffer - The ArrayBuffer to encode
4734
4947
  * @returns The base64 encoded string
@@ -4893,7 +5106,7 @@ var OAuthHelpersImpl = class {
4893
5106
  const clientInfo = await this.lookupClient(clientId);
4894
5107
  if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
4895
5108
  try {
4896
- validateRedirectUriScheme(redirectUri);
5109
+ validateRedirectUri(redirectUri, this.provider.serverCapabilities);
4897
5110
  } catch {
4898
5111
  throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
4899
5112
  }
@@ -4947,9 +5160,7 @@ var OAuthHelpersImpl = class {
4947
5160
  return client ? toPublicClientInfo(client) : null;
4948
5161
  }
4949
5162
  /**
4950
- * Completes an authorization request by creating a grant and either:
4951
- * - For authorization code flow: generating an authorization code
4952
- * - For implicit flow: generating an access token directly
5163
+ * Completes an authorization request by creating a grant and an authorization code.
4953
5164
  * @param options - Options specifying the grant details
4954
5165
  * @returns A Promise resolving to an object containing the redirect URL
4955
5166
  * @throws Error when the request's response type is not permitted
@@ -4958,102 +5169,55 @@ var OAuthHelpersImpl = class {
4958
5169
  async completeAuthorization(options) {
4959
5170
  const { clientId, redirectUri } = options.request;
4960
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 \":\"");
4961
5173
  const clientInfo = await this.lookupClient(clientId);
4962
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
+ }
4963
5180
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
4964
5181
  const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
4965
5182
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
4966
5183
  let grantsToRevoke = [];
4967
5184
  if (options.revokeExistingGrants !== false) {
4968
- const isCimdClient = this.provider.isClientMetadataUrl(clientId);
4969
- const readConcurrency = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
4970
- 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);
4971
5188
  }
4972
5189
  const grantId = generateRandomString(16);
4973
5190
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
4974
5191
  const now = Math.floor(Date.now() / 1e3);
4975
- if (options.request.responseType === "token") {
4976
- const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
4977
- const accessToken = `${options.userId}:${grantId}:${accessTokenSecret}`;
4978
- const accessTokenId = await generateTokenId(accessToken);
4979
- const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
4980
- const accessTokenExpiresAt = now + accessTokenTTL;
4981
- const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
4982
- const audience = effectiveResource;
4983
- const grant = {
4984
- id: grantId,
4985
- clientId: options.request.clientId,
4986
- userId: options.userId,
4987
- scope: options.scope,
4988
- metadata: options.metadata,
4989
- encryptedProps: encryptedData,
4990
- createdAt: now,
4991
- resource: effectiveResource,
4992
- redirectUri: options.request.redirectUri
4993
- };
4994
- const grantKey = `grant:${options.userId}:${grantId}`;
4995
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
4996
- const accessTokenData = {
4997
- id: accessTokenId,
4998
- grantId,
4999
- userId: options.userId,
5000
- createdAt: now,
5001
- expiresAt: accessTokenExpiresAt,
5002
- audience,
5003
- scope: options.scope,
5004
- wrappedEncryptionKey: accessTokenWrappedKey,
5005
- grant: {
5006
- clientId: options.request.clientId,
5007
- scope: options.scope,
5008
- encryptedProps: encryptedData
5009
- }
5010
- };
5011
- await this.env.OAUTH_KV.put(`token:${options.userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
5012
- const redirectUrl = new URL(options.request.redirectUri);
5013
- const fragment = new URLSearchParams();
5014
- fragment.set("access_token", accessToken);
5015
- fragment.set("token_type", "bearer");
5016
- fragment.set("expires_in", accessTokenTTL.toString());
5017
- fragment.set("scope", options.scope.join(" "));
5018
- fragment.set("resource", effectiveResource);
5019
- if (options.request.state) fragment.set("state", options.request.state);
5020
- if (options.request.issuer) fragment.set("iss", options.request.issuer);
5021
- redirectUrl.hash = fragment.toString();
5022
- try {
5023
- await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
5024
- } catch {}
5025
- return { redirectTo: redirectUrl.toString() };
5026
- } else {
5027
- const authCodeSecret = generateRandomString(32);
5028
- const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
5029
- const authCodeId = await hashSecret(authCode);
5030
- const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
5031
- const grant = {
5032
- id: grantId,
5033
- clientId: options.request.clientId,
5034
- userId: options.userId,
5035
- scope: options.scope,
5036
- metadata: options.metadata,
5037
- encryptedProps: encryptedData,
5038
- createdAt: now,
5039
- authCodeId,
5040
- authCodeWrappedKey,
5041
- codeChallenge: options.request.codeChallenge,
5042
- codeChallengeMethod: options.request.codeChallengeMethod,
5043
- resource: effectiveResource,
5044
- redirectUri: options.request.redirectUri
5045
- };
5046
- const grantKey = `grant:${options.userId}:${grantId}`;
5047
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
5048
- const redirectUrl = new URL(options.request.redirectUri);
5049
- redirectUrl.searchParams.set("code", authCode);
5050
- if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
5051
- if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
5052
- try {
5053
- await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
5054
- } catch {}
5055
- return { redirectTo: redirectUrl.toString() };
5056
- }
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() };
5057
5221
  }
5058
5222
  /**
5059
5223
  * IDs of the user's grants that satisfy `matches`, judged from KV key metadata
@@ -5067,8 +5231,18 @@ var OAuthHelpersImpl = class {
5067
5231
  * share shrinks on its own.
5068
5232
  */
5069
5233
  async findGrantIds(userId, matches, readConcurrency) {
5070
- const prefix = `grant:${userId}:`;
5071
- const found = [];
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}:`;
5072
5246
  let cursor;
5073
5247
  do {
5074
5248
  const page = await this.env.OAUTH_KV.list({
@@ -5077,16 +5251,32 @@ var OAuthHelpersImpl = class {
5077
5251
  cursor
5078
5252
  });
5079
5253
  const toRead = [];
5080
- for (const key of page.keys) if (isGrantKeyMetadata(key.metadata)) {
5081
- if (matches(key.metadata)) found.push(key.name.slice(prefix.length));
5082
- } else toRead.push(key.name);
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;
5083
5266
  for (let start = 0; start < toRead.length; start += readConcurrency) {
5084
- const grants = await Promise.all(toRead.slice(start, start + readConcurrency).map((name) => this.env.OAUTH_KV.get(name, { type: "json" })));
5085
- for (const grant of grants) if (grant && matches(grant)) found.push(grant.id);
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
+ }));
5086
5277
  }
5087
5278
  cursor = page.list_complete ? void 0 : page.cursor;
5088
5279
  } while (cursor);
5089
- return found;
5090
5280
  }
5091
5281
  /**
5092
5282
  * Creates a new OAuth client
@@ -5119,7 +5309,12 @@ var OAuthHelpersImpl = class {
5119
5309
  tokenEndpointAuthMethod,
5120
5310
  ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
5121
5311
  };
5122
- for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
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
+ });
5123
5318
  let clientSecret;
5124
5319
  if (!isPublicClient) {
5125
5320
  clientSecret = generateRandomString(32);
@@ -5159,8 +5354,13 @@ var OAuthHelpersImpl = class {
5159
5354
  * @returns A Promise resolving to the updated client info, or null if not found
5160
5355
  */
5161
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");
5162
5358
  const client = await this.provider.getClient(this.env, clientId);
5163
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
+ }
5164
5364
  const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
5165
5365
  const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
5166
5366
  const isPublicClient = authMethod === "none";
@@ -5178,6 +5378,11 @@ var OAuthHelpersImpl = class {
5178
5378
  tokenEndpointAuthMethod: authMethod,
5179
5379
  authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
5180
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
+ });
5181
5386
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
5182
5387
  else delete updatedClient.clientSecret;
5183
5388
  if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
@@ -5192,20 +5397,9 @@ var OAuthHelpersImpl = class {
5192
5397
  * @returns A Promise resolving when the deletion is confirmed.
5193
5398
  */
5194
5399
  async deleteClient(clientId) {
5195
- let cursor;
5196
- let allProcessed = false;
5197
- while (!allProcessed) {
5198
- const listOptions = { prefix: "grant:" };
5199
- if (cursor) listOptions.cursor = cursor;
5200
- const result = await this.env.OAUTH_KV.list(listOptions);
5201
- for (const key of result.keys) {
5202
- const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
5203
- if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
5204
- }
5205
- if (result.list_complete) allProcessed = true;
5206
- else cursor = result.cursor;
5207
- }
5208
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);
5209
5403
  }
5210
5404
  /**
5211
5405
  * Lists all authorization grants for a specific user with pagination support
@@ -5218,9 +5412,18 @@ var OAuthHelpersImpl = class {
5218
5412
  const listOptions = { prefix: `grant:${userId}:` };
5219
5413
  if (options?.limit !== void 0) listOptions.limit = options.limit;
5220
5414
  if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
5221
- const response = await this.env.OAUTH_KV.list(listOptions);
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
+ }
5222
5425
  const grantSummaries = [];
5223
- const promises = response.keys.map(async (key) => {
5426
+ const promises = ownKeys.map(async (key) => {
5224
5427
  const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
5225
5428
  if (grantData) {
5226
5429
  const summary = {
@@ -5249,6 +5452,12 @@ var OAuthHelpersImpl = class {
5249
5452
  * @param userId - The ID of the user who owns the grant
5250
5453
  * @returns A Promise resolving when the revocation is confirmed.
5251
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
+ }
5252
5461
  /** {@inheritDoc OAuthHelpers.isConsentRemembered} */
5253
5462
  isConsentRemembered(request, authRequest, remember) {
5254
5463
  return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
@@ -5317,6 +5526,7 @@ var OAuthHelpersImpl = class {
5317
5526
  const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
5318
5527
  const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
5319
5528
  const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
5529
+ const start = parsePurgeCursor(options?.cursor);
5320
5530
  const now = Math.floor(Date.now() / 1e3);
5321
5531
  const result = {
5322
5532
  grantsChecked: 0,
@@ -5325,10 +5535,10 @@ var OAuthHelpersImpl = class {
5325
5535
  tokensPurged: 0,
5326
5536
  done: false
5327
5537
  };
5328
- if (purgeOrphanedGrants || purgeExpiredGrants) {
5538
+ if (start.phase === "grants" && (purgeOrphanedGrants || purgeExpiredGrants)) {
5329
5539
  const knownGoodClients = /* @__PURE__ */ new Set();
5330
5540
  const knownMissingClients = /* @__PURE__ */ new Set();
5331
- let grantCursor;
5541
+ let grantCursor = start.kvCursor;
5332
5542
  let grantsDone = false;
5333
5543
  while (!grantsDone && result.grantsChecked < batchSize) {
5334
5544
  const listOptions = {
@@ -5344,7 +5554,7 @@ var OAuthHelpersImpl = class {
5344
5554
  if (!grantData) continue;
5345
5555
  let shouldPurge = false;
5346
5556
  if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
5347
- if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
5557
+ if (!shouldPurge && purgeOrphanedGrants && !isClientIdMetadataDocumentUrl(grantData.clientId)) {
5348
5558
  if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
5349
5559
  else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
5350
5560
  else {
@@ -5360,12 +5570,15 @@ var OAuthHelpersImpl = class {
5360
5570
  if (page.list_complete) grantsDone = true;
5361
5571
  else grantCursor = page.cursor;
5362
5572
  }
5363
- if (!grantsDone) return result;
5573
+ if (!grantsDone) return {
5574
+ ...result,
5575
+ cursor: `grants:${grantCursor ?? ""}`
5576
+ };
5364
5577
  }
5365
5578
  if (purgeOrphanedTokens) {
5366
5579
  const knownGoodGrants = /* @__PURE__ */ new Set();
5367
5580
  const knownMissingGrants = /* @__PURE__ */ new Set();
5368
- let tokenCursor;
5581
+ let tokenCursor = start.phase === "tokens" ? start.kvCursor : void 0;
5369
5582
  let tokensDone = false;
5370
5583
  while (!tokensDone && result.tokensChecked < batchSize) {
5371
5584
  const listOptions = {
@@ -5393,7 +5606,10 @@ var OAuthHelpersImpl = class {
5393
5606
  if (page.list_complete) tokensDone = true;
5394
5607
  else tokenCursor = page.cursor;
5395
5608
  }
5396
- if (!tokensDone) return result;
5609
+ if (!tokensDone) return {
5610
+ ...result,
5611
+ cursor: `tokens:${tokenCursor ?? ""}`
5612
+ };
5397
5613
  }
5398
5614
  result.done = true;
5399
5615
  return result;
@@ -5406,4 +5622,4 @@ var OAuthHelpersImpl = class {
5406
5622
  var oauth_provider_default = OAuthProvider;
5407
5623
 
5408
5624
  //#endregion
5409
- export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
5625
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, authorizationErrorRedirect, oauth_provider_default as default, getOAuthApi, insufficientScope };