@cloudflare/workers-oauth-provider 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,65 @@ 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
+ * The checks every redirect URI a client registers passes, whether or not this server would send a
520
+ * code to it: the {@link validateRedirectUriSafety} checks, a parseable URI, and no fragment (not
521
+ * even an empty `#`) or userinfo.
522
+ * @throws Error when the redirect URI can't be registered at all
523
+ */
524
+ function validateListedRedirectUri(redirectUri) {
525
+ validateRedirectUriSafety(redirectUri);
526
+ const normalized = redirectUri.trim();
527
+ let url;
528
+ try {
529
+ url = new URL(normalized);
530
+ } catch {
531
+ throw new Error("Invalid redirect URI");
532
+ }
533
+ if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
534
+ return url;
535
+ }
536
+ /**
537
+ * Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
538
+ * loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
539
+ * empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
540
+ * `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
541
+ * checks always apply.
542
+ * @throws Error describing why the redirect URI is not acceptable
543
+ */
544
+ function validateRedirectUri(redirectUri, server) {
545
+ const url = validateListedRedirectUri(redirectUri);
546
+ if (url.protocol === "https:") return;
547
+ if (url.protocol === "http:") {
548
+ if (isLoopbackHostname(url.hostname)) return;
549
+ throw new Error("Redirect URI must use https, or http on a loopback host");
550
+ }
551
+ if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
552
+ }
553
+ /**
554
+ * Validates the redirect URIs a client registers (dynamic registration, `createClient()`,
555
+ * `updateClient()`). A client may list callbacks for surfaces this server won't send codes to:
556
+ * Cursor lists `cursor://anysphere.cursor-mcp/oauth/callback` next to its https and loopback
557
+ * callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
558
+ * {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
559
+ * policy by `parseAuthRequest()` and `completeAuthorization()`.
560
+ * @throws Error when a URI can't be registered, or none of them could ever receive a code
561
+ */
562
+ function validateRegisteredRedirectUris(redirectUris, server) {
563
+ if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
564
+ for (const redirectUri of redirectUris) validateListedRedirectUri(redirectUri);
565
+ let firstRefusal;
566
+ for (const redirectUri of redirectUris) try {
567
+ validateRedirectUri(redirectUri, server);
568
+ return redirectUris;
569
+ } catch (error) {
570
+ firstRefusal ??= error;
571
+ }
572
+ throw firstRefusal instanceof Error ? firstRefusal : /* @__PURE__ */ new Error("Invalid redirect URI");
573
+ }
574
+ function requireValidRedirectUris(redirectUris, validate) {
271
575
  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);
576
+ for (const redirectUri of redirectUris) validate(redirectUri);
273
577
  return redirectUris;
274
578
  }
275
579
  /**
@@ -287,7 +591,7 @@ function resolveDynamicClientRegistrationMetadata(raw, server) {
287
591
  });
288
592
  return {
289
593
  ...pickDisplayMetadata(metadata),
290
- redirectUris: requireValidRedirectUris(metadata.redirectUris),
594
+ redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
291
595
  ...capabilities,
292
596
  authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
293
597
  };
@@ -366,7 +670,7 @@ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
366
670
  const metadata = parseOAuthClientMetadata(raw);
367
671
  if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
368
672
  if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
369
- const redirectUris = requireValidRedirectUris(metadata.redirectUris);
673
+ const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
370
674
  if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
371
675
  if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
372
676
  const capabilities = negotiateCimdClientCapabilities(server, {
@@ -850,16 +1154,34 @@ function parseIdJag(assertion, maxBytes) {
850
1154
  signature
851
1155
  });
852
1156
  }
1157
+ /**
1158
+ * Decodes a base64url-encoded string to bytes.
1159
+ */
1160
+ function base64UrlToBytes(base64Url) {
1161
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
1162
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
1163
+ const binaryString = atob(padded);
1164
+ const bytes = new Uint8Array(binaryString.length);
1165
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1166
+ return bytes;
1167
+ }
1168
+ /**
1169
+ * Parses a base64url-encoded JWT JSON part into an object.
1170
+ */
1171
+ function parseJwtJsonPart(encoded) {
1172
+ try {
1173
+ const json = new TextDecoder().decode(base64UrlToBytes(encoded));
1174
+ const parsed = JSON.parse(json);
1175
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
1176
+ return parsed;
1177
+ } catch {
1178
+ throw new Error("Malformed JWT part");
1179
+ }
1180
+ }
853
1181
 
854
1182
  //#endregion
855
1183
  //#region src/ema/signature.ts
856
1184
  /**
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
1185
  * Pick a signing key from a JWKS that matches the assertion header.
864
1186
  *
865
1187
  * Filters by:
@@ -910,95 +1232,31 @@ async function verifyIdJagSignature(input) {
910
1232
  return false;
911
1233
  }
912
1234
  }
913
-
914
- //#endregion
915
- //#region src/oauth-resource.ts
916
- /**
917
- * Loopback hosts where a plain `http` scheme is accepted for local development.
918
- * Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
919
- */
920
- function isLoopbackHostname(hostname) {
921
- const host = hostname.toLowerCase();
922
- if (host === "localhost" || host === "::1" || host === "[::1]") return true;
923
- const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
924
- return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
925
- }
926
- /**
927
- * Whether a canonical resource, issuer, or endpoint URL uses an accepted
928
- * scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
929
- * http://localhost. Workers are always served over `https`, so there is no
930
- * deployment in which cleartext OAuth traffic to a remote host is correct,
931
- * and no option exists to allow it.
932
- */
933
- function hasAcceptedCanonicalScheme(url) {
934
- return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
935
- }
936
- /** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
937
- function validateResourceUri(uri) {
938
- if (!uri || typeof uri !== "string") return false;
939
- if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
940
- try {
941
- const parsed = new URL(uri);
942
- return parsed.protocol === "http:" || parsed.protocol === "https:";
943
- } catch {
944
- return false;
945
- }
946
- }
947
- /**
948
- * Whether a requested resource identifies a granted or configured resource.
949
- * Only ASCII case in the URI scheme and host is ignored, and an empty path is
950
- * equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
951
- * `https://example.com` through a URL parser as `https://example.com/` still
952
- * names it. Port, path, query, a trailing slash after a path segment, user
953
- * information, and every other byte remain significant.
954
- */
955
- function resourceMatches(requested, granted) {
956
- const foldedRequested = foldResourceSchemeAndHost(requested);
957
- const foldedGranted = foldResourceSchemeAndHost(granted);
958
- if (foldedRequested === void 0 || foldedGranted === void 0) return false;
959
- return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
960
- }
961
1235
  /**
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.
1236
+ * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
965
1237
  */
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;
1238
+ function getJwtCryptoAlgorithms(alg) {
1239
+ if (alg === "RS256") {
1240
+ const algorithm = {
1241
+ name: "RSASSA-PKCS1-v1_5",
1242
+ hash: "SHA-256"
1243
+ };
1244
+ return {
1245
+ importAlgorithm: algorithm,
1246
+ verifyAlgorithm: algorithm
1247
+ };
982
1248
  }
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;
1249
+ if (alg === "ES256") return {
1250
+ importAlgorithm: {
1251
+ name: "ECDSA",
1252
+ namedCurve: "P-256"
1253
+ },
1254
+ verifyAlgorithm: {
1255
+ name: "ECDSA",
1256
+ hash: "SHA-256"
1257
+ }
1258
+ };
1259
+ throw new Error(`Unsupported JWT alg: ${alg}`);
1002
1260
  }
1003
1261
 
1004
1262
  //#endregion
@@ -1336,7 +1594,7 @@ const NO_CACHE_HEADERS$1 = {
1336
1594
  * ```
1337
1595
  */
1338
1596
  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");
1597
+ 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
1598
  let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
1341
1599
  challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
1342
1600
  if (description !== void 0) {
@@ -1376,35 +1634,36 @@ var OAuthResourceServer = class {
1376
1634
  const validated = this.#validated;
1377
1635
  const url = new URL(request.url);
1378
1636
  if (isProtectedResourceMetadataPath(url)) {
1379
- if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1637
+ if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
1380
1638
  status: 204,
1381
1639
  headers: { "Content-Length": "0" }
1382
1640
  }), 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, {
1641
+ if (!isMetadataUrlRequest(url, validated.metadataUrl)) return withCorsHeaders(new Response(null, { status: 404 }), request);
1642
+ if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
1385
1643
  status: 405,
1386
1644
  headers: { Allow: "GET, HEAD, OPTIONS" }
1387
1645
  }), request);
1388
1646
  const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
1389
- return addCorsHeaders(request.method === "HEAD" ? new Response(null, {
1647
+ return withCorsHeaders(request.method === "HEAD" ? new Response(null, {
1390
1648
  status: 200,
1391
1649
  headers: metadata.headers
1392
1650
  }) : metadata, request);
1393
1651
  }
1394
1652
  if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
1395
- if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1653
+ if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
1396
1654
  status: 204,
1397
1655
  headers: { "Content-Length": "0" }
1398
1656
  }), request);
1399
1657
  const token = parseBearerToken(request.headers.get("Authorization"));
1400
- if (!token) return addCorsHeaders(createBearerChallenge(url, validated, false), request);
1658
+ if (!token) return withCorsHeaders(createBearerChallenge(url, validated, false), request);
1401
1659
  let validation;
1402
1660
  try {
1403
1661
  validation = await options.validateToken(env, request)(validated.resource, token);
1404
- } catch {
1405
- return addCorsHeaders(createValidationUnavailableResponse(), request);
1662
+ } catch (error) {
1663
+ if (error instanceof OAuthError) return withCorsHeaders(createOAuthErrorResponse(error, url, validated), request);
1664
+ return withCorsHeaders(createValidationUnavailableResponse(), request);
1406
1665
  }
1407
- if (!isValidTokenValidation(validation, validated.resource)) return addCorsHeaders(createBearerChallenge(url, validated, true), request);
1666
+ if (!isValidTokenValidation(validation, validated.resource)) return withCorsHeaders(createBearerChallenge(url, validated, true), request);
1408
1667
  const context = ctx;
1409
1668
  context.props = validation.props;
1410
1669
  context.auth = {
@@ -1416,7 +1675,7 @@ var OAuthResourceServer = class {
1416
1675
  ...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
1417
1676
  };
1418
1677
  const handler = options.handler;
1419
- return addCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
1678
+ return withCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
1420
1679
  }
1421
1680
  };
1422
1681
  function validateOptions(options) {
@@ -1435,9 +1694,9 @@ function validateOptions(options) {
1435
1694
  }
1436
1695
  const bearerMethods = options.resourceMetadata.bearer_methods_supported;
1437
1696
  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");
1697
+ const configuredScopes = resolveRequiredScopes(options.requiredScopes, options.resourceMetadata.scopes_supported) ?? [];
1698
+ 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");
1699
+ const resourceScopes = baselineResourceScopes(configuredScopes);
1441
1700
  return {
1442
1701
  resource,
1443
1702
  resourceUrl,
@@ -1463,9 +1722,6 @@ function parseCanonicalUrl(value) {
1463
1722
  if (parsed.href !== value && parsed.origin !== value) return null;
1464
1723
  return parsed;
1465
1724
  }
1466
- function isValidScopeToken(scope) {
1467
- return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
1468
- }
1469
1725
  function getResourceMetadataUrl(resource) {
1470
1726
  const parsed = new URL(resource);
1471
1727
  const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
@@ -1504,7 +1760,7 @@ function isValidTokenValidation(validation, canonicalResource) {
1504
1760
  }
1505
1761
  if (validation.scope !== void 0) {
1506
1762
  if (!Array.isArray(validation.scope)) return false;
1507
- if (validation.scope.some((scope) => typeof scope !== "string" || !isValidScopeToken(scope))) return false;
1763
+ if (validation.scope.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return false;
1508
1764
  }
1509
1765
  if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
1510
1766
  if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
@@ -1524,30 +1780,44 @@ function createBearerChallenge(requestUrl, validated, invalidToken) {
1524
1780
  }
1525
1781
  });
1526
1782
  }
1783
+ /**
1784
+ * A validator's `OAuthError` as the response. RFC 6750 fixes `invalid_token` at `401` and
1785
+ * `insufficient_scope` at `403`, each with a Bearer challenge (unless the error brings its own);
1786
+ * any other code keeps the error's status and headers, such as a `429` with `Retry-After`.
1787
+ */
1788
+ function createOAuthErrorResponse(error, requestUrl, validated) {
1789
+ const headers = {
1790
+ ...NO_CACHE_HEADERS$1,
1791
+ "Content-Type": "application/json",
1792
+ ...error.headers
1793
+ };
1794
+ const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
1795
+ let status = error.statusCode;
1796
+ if (error.code === "invalid_token") {
1797
+ status = 401;
1798
+ if (!hasChallenge) headers["WWW-Authenticate"] = createBearerChallenge(requestUrl, validated, true).headers.get("WWW-Authenticate");
1799
+ } else if (error.code === "insufficient_scope") {
1800
+ status = 403;
1801
+ if (!hasChallenge) {
1802
+ const scopes = [...new Set(error.requiredScopes ?? validated.metadata.scopes_supported ?? [])];
1803
+ if (scopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return createValidationUnavailableResponse();
1804
+ headers["WWW-Authenticate"] = "Bearer realm=\"OAuth\", error=\"insufficient_scope\"" + (scopes.length > 0 ? `, scope="${scopes.join(" ")}"` : "") + `, resource_metadata="${validated.metadataUrl.href}"`;
1805
+ }
1806
+ }
1807
+ return new Response(JSON.stringify({
1808
+ error: error.code,
1809
+ error_description: error.description
1810
+ }), {
1811
+ status,
1812
+ headers
1813
+ });
1814
+ }
1527
1815
  function createValidationUnavailableResponse() {
1528
1816
  return new Response(null, {
1529
1817
  status: 503,
1530
1818
  headers: NO_CACHE_HEADERS$1
1531
1819
  });
1532
1820
  }
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
1821
 
1552
1822
  //#endregion
1553
1823
  //#region src/oauth-consent.ts
@@ -1595,6 +1865,21 @@ function validateRememberConsentOptions(options) {
1595
1865
  if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
1596
1866
  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
1867
  }
1868
+ /** The facts a consent page must show for a request, given the client `lookupClient()` resolved. */
1869
+ function describeConsent(client, request, isClientIdMetadataDocument) {
1870
+ const redirectHost = new URL(request.redirectUri).hostname || request.redirectUri;
1871
+ return {
1872
+ clientId: request.clientId,
1873
+ clientName: client?.clientName?.trim() ? client.clientName : request.clientId,
1874
+ ...isClientIdMetadataDocument ? { clientDomain: new URL(request.clientId).hostname } : {},
1875
+ ...client?.clientUri ? { clientUri: client.clientUri } : {},
1876
+ ...client?.logoUri ? { logoUri: client.logoUri } : {},
1877
+ redirectUri: request.redirectUri,
1878
+ redirectHost,
1879
+ redirectIsLoopback: isLoopbackHostname(redirectHost),
1880
+ scope: [...request.scope]
1881
+ };
1882
+ }
1598
1883
  /** Start a consent transaction for an authorization request that must be shown to the user. */
1599
1884
  async function beginConsent(kv, cookies, request) {
1600
1885
  const { handle, hash } = await createTransaction(kv, {
@@ -1647,19 +1932,15 @@ async function denyConsent(kv, cookies, request, handle, options = {}) {
1647
1932
  const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1648
1933
  await transaction.consume();
1649
1934
  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);
1935
+ const redirectTo = authorizationErrorRedirect(record.request, "access_denied", options.description);
1655
1936
  const headers = new Headers({
1656
1937
  "Cache-Control": "no-store",
1657
- Location: redirect.href
1938
+ Location: redirectTo
1658
1939
  });
1659
1940
  headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1660
1941
  return {
1661
1942
  request: record.request,
1662
- redirectTo: redirect.href,
1943
+ redirectTo,
1663
1944
  headers
1664
1945
  };
1665
1946
  }
@@ -1887,6 +2168,9 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
1887
2168
  GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
1888
2169
  return GrantType$1;
1889
2170
  }({});
2171
+ /** Where the split server's endpoints are unless configured otherwise, under the issuer. */
2172
+ const DEFAULT_AUTHORIZE_PATH = "/authorize";
2173
+ const DEFAULT_TOKEN_PATH = "/oauth/token";
1890
2174
  function toPublicClientInfo(client) {
1891
2175
  const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
1892
2176
  return publicClient;
@@ -1946,7 +2230,6 @@ var OAuthAuthorizationServer = class {
1946
2230
  const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
1947
2231
  this.#impl = new OAuthProviderImpl({
1948
2232
  ...commonOptions,
1949
- defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
1950
2233
  authorizationServer: {
1951
2234
  issuer,
1952
2235
  resources,
@@ -2018,7 +2301,7 @@ var OAuthProviderImpl = class {
2018
2301
  constructor(options) {
2019
2302
  this.errorResponses = /* @__PURE__ */ new WeakSet();
2020
2303
  this.typedApiHandlers = [];
2021
- this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
2304
+ this.typedDefaultHandler = "authorizationServer" in options ? void 0 : this.validateHandler(options.defaultHandler, "defaultHandler");
2022
2305
  const roleBased = "authorizationServer" in options;
2023
2306
  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
2307
  let normalizedOptions;
@@ -2027,10 +2310,11 @@ var OAuthProviderImpl = class {
2027
2310
  const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
2028
2311
  this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
2029
2312
  this.explicitIssuer = authorizationServer$1.issuer;
2313
+ const issuerBase = authorizationServer$1.issuer.replace(/\/+$/, "");
2030
2314
  normalizedOptions = {
2031
2315
  ...commonOptions,
2032
- authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
2033
- tokenEndpoint: authorizationServer$1.tokenEndpoint,
2316
+ authorizeEndpoint: authorizationServer$1.authorizeEndpoint ?? `${issuerBase}${DEFAULT_AUTHORIZE_PATH}`,
2317
+ tokenEndpoint: authorizationServer$1.tokenEndpoint ?? `${issuerBase}${DEFAULT_TOKEN_PATH}`,
2034
2318
  clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
2035
2319
  };
2036
2320
  const declared = authorizationServer$1.resources;
@@ -2042,7 +2326,7 @@ var OAuthProviderImpl = class {
2042
2326
  this.explicitIssuer = void 0;
2043
2327
  normalizedOptions = options;
2044
2328
  configuredResourceServers = [{
2045
- resourceMetadata: options.resourceMetadata,
2329
+ resourceMetadata: withRequiredScopes(options.resourceMetadata, options.requiredScopes),
2046
2330
  resolveExternalToken: options.resolveExternalToken
2047
2331
  }];
2048
2332
  }
@@ -2106,11 +2390,14 @@ var OAuthProviderImpl = class {
2106
2390
  }
2107
2391
  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
2392
  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).`);
2393
+ if (this.options.allowPlainPKCE) throw new TypeError("allowPlainPKCE has been removed: MCP and OAuth 2.1 clients use S256 PKCE.");
2394
+ if (this.options.allowImplicitFlow) throw new TypeError("allowImplicitFlow has been removed: OAuth 2.1 and MCP use the authorization code flow with PKCE.");
2395
+ 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).`);
2396
+ 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
2397
  this.serverCapabilities = buildOAuthServerCapabilities({
2110
- allowImplicitFlow: !!this.options.allowImplicitFlow,
2111
- allowPlainPKCE: this.options.allowPlainPKCE === true,
2112
2398
  allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
2113
- enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
2399
+ enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization,
2400
+ allowPrivateUseRedirectUris: this.options.allowPrivateUseRedirectUris === true
2114
2401
  });
2115
2402
  validateAuthorizationServerScopes(this.options.scopesSupported);
2116
2403
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
@@ -2149,16 +2436,24 @@ var OAuthProviderImpl = class {
2149
2436
  }
2150
2437
  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
2438
  }
2152
- /** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
2439
+ /**
2440
+ * Reject an endpoint another would claim, so it could never be reached. fetch() tries metadata,
2441
+ * then token, then registration; the application routes the authorization endpoint. Each check
2442
+ * asks the predicate fetch() dispatches on, so it agrees with routing (metadata ignores the
2443
+ * query, for one).
2444
+ */
2153
2445
  validateAuthorizationServerRouteIsolation() {
2154
2446
  const issuerUrl = new URL(this.explicitIssuer);
2155
- const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
2156
2447
  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");
2448
+ if (this.isAuthorizationServerMetadataRequest(tokenUrl)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
2449
+ const authorizeUrl = new URL(this.getFullEndpointUrl(this.options.authorizeEndpoint, issuerUrl));
2450
+ if (this.isAuthorizationServerMetadataRequest(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with the authorization server metadata endpoint");
2451
+ if (this.isTokenEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with tokenEndpoint");
2158
2452
  if (!this.options.clientRegistrationEndpoint) return;
2453
+ if (this.isClientRegistrationEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with clientRegistrationEndpoint");
2159
2454
  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");
2455
+ if (this.isTokenEndpoint(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
2456
+ if (this.isAuthorizationServerMetadataRequest(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
2162
2457
  }
2163
2458
  /**
2164
2459
  * A protected route must be the canonical resource path or a path-boundary descendant of
@@ -2259,7 +2554,7 @@ var OAuthProviderImpl = class {
2259
2554
  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
2555
  }
2261
2556
  }
2262
- if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
2557
+ 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
2558
  if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
2264
2559
  }
2265
2560
  /**
@@ -2298,49 +2593,42 @@ var OAuthProviderImpl = class {
2298
2593
  const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
2299
2594
  const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
2300
2595
  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, {
2596
+ 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
2597
  status: 204,
2303
2598
  headers: { "Content-Length": "0" }
2304
2599
  }), request);
2305
2600
  }
2306
2601
  if (this.isAuthorizationServerMetadataRequest(url)) {
2307
- if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
2602
+ if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
2308
2603
  status: 405,
2309
2604
  headers: { Allow: "GET, HEAD, OPTIONS" }
2310
2605
  }), request);
2311
- const response = await this.handleMetadataDiscovery(url);
2312
- return this.addCorsHeaders(withoutBodyForHead(request, response), request);
2606
+ return withCorsHeaders(withoutBodyForHead(request, await this.handleMetadataDiscovery(url)), request);
2313
2607
  }
2314
2608
  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, {
2609
+ if (!metadataResourceServer) return withCorsHeaders(new Response(null, { status: 404 }), request);
2610
+ if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
2317
2611
  status: 405,
2318
2612
  headers: { Allow: "GET, HEAD, OPTIONS" }
2319
2613
  }), request);
2320
- const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
2321
- return this.addCorsHeaders(withoutBodyForHead(request, response), request);
2614
+ return withCorsHeaders(withoutBodyForHead(request, this.handleProtectedResourceMetadata(url, metadataResourceServer)), request);
2322
2615
  }
2323
2616
  if (this.isTokenEndpoint(url)) {
2324
2617
  const parsed = await this.parseTokenEndpointRequest(request, env);
2325
- if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
2618
+ if (parsed instanceof Response) return withCorsHeaders(parsed, request);
2326
2619
  let response;
2327
- if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
2620
+ if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
2328
2621
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
2329
2622
  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);
2623
+ return withCorsHeaders(response, request);
2335
2624
  }
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 });
2625
+ if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(await this.handleClientRegistration(request, env), request);
2626
+ if (apiRoute) return withCorsHeaders(await this.handleApiRequest(request, env, ctx, apiRoute), request);
2627
+ const defaultHandler = this.typedDefaultHandler;
2628
+ if (!defaultHandler) return new Response(null, { status: 404 });
2341
2629
  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);
2630
+ if (defaultHandler.type === HandlerType.EXPORTED_HANDLER) return defaultHandler.handler.fetch(request, env, ctx);
2631
+ return new defaultHandler.handler(ctx, env).fetch(request);
2344
2632
  }
2345
2633
  /**
2346
2634
  * Decodes a token and returns token data with decrypted props
@@ -2576,7 +2864,7 @@ var OAuthProviderImpl = class {
2576
2864
  });
2577
2865
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
2578
2866
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
2579
- if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
2867
+ if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
2580
2868
  category: "client-authentication",
2581
2869
  reason: "token_endpoint_auth_method_mismatch",
2582
2870
  detail: {
@@ -2652,26 +2940,6 @@ var OAuthProviderImpl = class {
2652
2940
  return new URL(tokenEndpoint).origin;
2653
2941
  }
2654
2942
  /**
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
2943
  * Handles the OAuth metadata discovery endpoint
2676
2944
  * Implements RFC 8414 for OAuth Server Metadata
2677
2945
  * @param requestUrl - The URL of the incoming request
@@ -2693,7 +2961,7 @@ var OAuthProviderImpl = class {
2693
2961
  registration_endpoint: registrationEndpoint,
2694
2962
  scopes_supported: this.options.scopesSupported,
2695
2963
  response_types_supported: responseTypesSupported,
2696
- response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
2964
+ response_modes_supported: ["query"],
2697
2965
  grant_types_supported: grantTypesSupported,
2698
2966
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
2699
2967
  token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
@@ -2710,7 +2978,7 @@ var OAuthProviderImpl = class {
2710
2978
  }
2711
2979
  /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
2712
2980
  normalizeProtectedResourceScopes(scopes) {
2713
- return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
2981
+ return baselineResourceScopes(scopes);
2714
2982
  }
2715
2983
  /**
2716
2984
  * Handles the OAuth Protected Resource Metadata endpoint
@@ -2780,7 +3048,10 @@ var OAuthProviderImpl = class {
2780
3048
  */
2781
3049
  async callTokenExchangeCallback(options, env) {
2782
3050
  try {
2783
- return await Promise.resolve(this.options.tokenExchangeCallback(options));
3051
+ return await Promise.resolve(this.options.tokenExchangeCallback({
3052
+ ...options,
3053
+ env
3054
+ }));
2784
3055
  } catch (error) {
2785
3056
  if (error instanceof OAuthError && error.code === "invalid_grant") try {
2786
3057
  await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
@@ -2875,9 +3146,9 @@ var OAuthProviderImpl = class {
2875
3146
  reason: "code_replayed"
2876
3147
  });
2877
3148
  }
2878
- let codeChallengeMethod;
2879
3149
  try {
2880
- codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
3150
+ if (grantData.codeChallenge) validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod);
3151
+ else normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
2881
3152
  } catch (error) {
2882
3153
  return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
2883
3154
  category: "authorization-code-grant",
@@ -2907,14 +3178,8 @@ var OAuthProviderImpl = class {
2907
3178
  category: "authorization-code-grant",
2908
3179
  reason: "code_verifier_missing"
2909
3180
  });
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" }, {
3181
+ const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
3182
+ if (base64UrlEncode(String.fromCharCode(...new Uint8Array(hashBuffer))) !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
2918
3183
  category: "authorization-code-grant",
2919
3184
  reason: "code_verifier_mismatch"
2920
3185
  });
@@ -2928,6 +3193,10 @@ var OAuthProviderImpl = class {
2928
3193
  let accessTokenEncryptionKey = encryptionKey;
2929
3194
  let encryptedAccessTokenProps = grantData.encryptedProps;
2930
3195
  let tokenScopes = this.downscope(body.scope, grantData.scope);
3196
+ if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
3197
+ category: "authorization-code-grant",
3198
+ reason: "scope_not_granted"
3199
+ });
2931
3200
  if (this.options.tokenExchangeCallback) {
2932
3201
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
2933
3202
  let grantProps = decryptedProps;
@@ -2951,11 +3220,13 @@ var OAuthProviderImpl = class {
2951
3220
  }
2952
3221
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2953
3222
  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
- });
3223
+ if (callbackResult.refreshTokenTTL !== void 0) {
3224
+ 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` }, {
3225
+ category: "authorization-code-grant",
3226
+ reason: "refresh_token_ttl_invalid"
3227
+ });
3228
+ refreshTokenTTL = callbackResult.refreshTokenTTL;
3229
+ }
2959
3230
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
2960
3231
  }
2961
3232
  const grantResult = await encryptProps(grantProps);
@@ -2990,7 +3261,7 @@ var OAuthProviderImpl = class {
2990
3261
  grantData.previousRefreshTokenId = void 0;
2991
3262
  grantData.previousRefreshTokenWrappedKey = void 0;
2992
3263
  grantData.expiresAt = expiresAt;
2993
- }
3264
+ } else grantData.expiresAt = now + accessTokenTTL;
2994
3265
  if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
2995
3266
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
2996
3267
  const tokenResponse = {
@@ -3074,6 +3345,10 @@ var OAuthProviderImpl = class {
3074
3345
  let accessTokenEncryptionKey = encryptionKey;
3075
3346
  let encryptedAccessTokenProps = grantData.encryptedProps;
3076
3347
  let tokenScopes = this.downscope(body.scope, grantData.scope);
3348
+ if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
3349
+ category: "refresh-token-grant",
3350
+ reason: "scope_not_granted"
3351
+ });
3077
3352
  let grantPropsChanged = false;
3078
3353
  if (this.options.tokenExchangeCallback) {
3079
3354
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
@@ -3099,10 +3374,6 @@ var OAuthProviderImpl = class {
3099
3374
  }
3100
3375
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
3101
3376
  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
3377
  if (callbackResult.refreshTokenIdleTTL !== void 0) {
3107
3378
  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
3379
  category: "refresh-token-grant",
@@ -3231,6 +3502,13 @@ var OAuthProviderImpl = class {
3231
3502
  });
3232
3503
  if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
3233
3504
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
3505
+ if (grantsNoneOfTheRequestedScopes(requestedScopes, tokenScopes)) throw new OAuthError("invalid_scope", {
3506
+ description: "None of the requested scopes were granted",
3507
+ internal: {
3508
+ category: "token-exchange-grant",
3509
+ reason: "scope_not_granted"
3510
+ }
3511
+ });
3234
3512
  const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
3235
3513
  const now = Math.floor(Date.now() / 1e3);
3236
3514
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
@@ -3285,13 +3563,6 @@ var OAuthProviderImpl = class {
3285
3563
  }
3286
3564
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
3287
3565
  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
3566
  if (accessTokenProps !== decryptedProps) {
3296
3567
  const tokenResult = await encryptProps(accessTokenProps);
3297
3568
  encryptedAccessTokenProps = tokenResult.encryptedData;
@@ -3311,7 +3582,7 @@ var OAuthProviderImpl = class {
3311
3582
  access_token: await this.createAccessToken({
3312
3583
  userId: tokenSummary.userId,
3313
3584
  grantId: tokenSummary.grantId,
3314
- clientId: tokenSummary.grant.clientId,
3585
+ clientId: clientInfo.clientId,
3315
3586
  scope: tokenScopes,
3316
3587
  encryptedProps: encryptedAccessTokenProps,
3317
3588
  encryptionKey: accessTokenEncryptionKey,
@@ -3595,15 +3866,7 @@ var OAuthProviderImpl = class {
3595
3866
  };
3596
3867
  }
3597
3868
  /**
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
- /**
3869
+ * Handles an RFC 7009 revocation request from an authenticated client.
3607
3870
  * - Access tokens: Revokes only the specific token
3608
3871
  * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
3609
3872
  * Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
@@ -3695,14 +3958,17 @@ var OAuthProviderImpl = class {
3695
3958
  const callbackRequest = request.clone();
3696
3959
  let parsedJson;
3697
3960
  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
- });
3961
+ const text = await readTextWithLimit(request, 1048576);
3962
+ if (text === void 0) {
3963
+ callbackRequest.body?.cancel().catch(() => void 0);
3964
+ return this.createErrorResponse("invalid_request", {
3965
+ description: "Request payload too large, must be under 1 MiB",
3966
+ statusCode: 413
3967
+ }, {
3968
+ category: "client-registration",
3969
+ reason: "payload_too_large"
3970
+ });
3971
+ }
3706
3972
  parsedJson = JSON.parse(text);
3707
3973
  } catch {
3708
3974
  return this.createErrorResponse("invalid_request", {
@@ -3717,8 +3983,16 @@ var OAuthProviderImpl = class {
3717
3983
  let metadata;
3718
3984
  try {
3719
3985
  clientMetadata = requireJsonObject(parsedJson);
3720
- metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
3986
+ const capabilities = this.options.disallowPublicClientRegistration ? {
3987
+ ...this.serverCapabilities,
3988
+ tokenEndpointAuthMethods: this.serverCapabilities.tokenEndpointAuthMethods.filter((method) => method !== "none")
3989
+ } : this.serverCapabilities;
3990
+ metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, capabilities);
3721
3991
  } catch (error) {
3992
+ if (this.options.disallowPublicClientRegistration && wouldRegisterPublicClient(clientMetadata, this.serverCapabilities)) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
3993
+ category: "client-registration",
3994
+ reason: "public_client_forbidden"
3995
+ });
3722
3996
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
3723
3997
  category: "client-registration",
3724
3998
  reason: "metadata_invalid",
@@ -3727,10 +4001,6 @@ var OAuthProviderImpl = class {
3727
4001
  }
3728
4002
  const authMethod = metadata.tokenEndpointAuthMethod;
3729
4003
  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
4004
  const clientId = generateRandomString(16);
3735
4005
  let clientSecret;
3736
4006
  let hashedSecret;
@@ -3765,7 +4035,7 @@ var OAuthProviderImpl = class {
3765
4035
  }));
3766
4036
  } catch (error) {
3767
4037
  return this.createErrorResponse("server_error", {
3768
- description: error instanceof Error ? error.message : "Client registration callback failed",
4038
+ description: "Client registration callback failed",
3769
4039
  statusCode: 500
3770
4040
  }, {
3771
4041
  category: "client-registration",
@@ -3803,7 +4073,7 @@ var OAuthProviderImpl = class {
3803
4073
  }
3804
4074
  if (clientSecret) {
3805
4075
  response.client_secret = clientSecret;
3806
- response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
4076
+ response.client_secret_expires_at = this.options.clientRegistrationTTL !== void 0 && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
3807
4077
  response.client_secret_issued_at = clientInfo.registrationDate;
3808
4078
  }
3809
4079
  return new Response(JSON.stringify(response), {
@@ -3848,7 +4118,7 @@ var OAuthProviderImpl = class {
3848
4118
  const id = await generateTokenId(accessToken);
3849
4119
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
3850
4120
  }
3851
- if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
4121
+ if (!tokenData && (!externalTokenResolver || isIssuedTokenFormat(accessToken))) return this.createErrorResponse("invalid_token", {
3852
4122
  description: "Invalid access token",
3853
4123
  statusCode: 401,
3854
4124
  headers: { "WWW-Authenticate": challenge("invalid_token") }
@@ -4054,7 +4324,7 @@ var OAuthProviderImpl = class {
4054
4324
  }
4055
4325
  }
4056
4326
  async getClient(env, clientId) {
4057
- if (this.isClientMetadataUrl(clientId)) {
4327
+ if (isClientIdMetadataDocumentUrl(clientId)) {
4058
4328
  if (!this.options.clientIdMetadataDocumentEnabled) {
4059
4329
  const clientKey$1 = `client:${clientId}`;
4060
4330
  return env.OAUTH_KV.get(clientKey$1, { type: "json" });
@@ -4265,13 +4535,6 @@ var OAuthProviderImpl = class {
4265
4535
  return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
4266
4536
  }
4267
4537
  /**
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
4538
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
4276
4539
  */
4277
4540
  buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
@@ -4343,60 +4606,6 @@ var OAuthProviderImpl = class {
4343
4606
  }
4344
4607
  };
4345
4608
  /**
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
4609
  * Intentional public error from an external bearer-token validator.
4401
4610
  *
4402
4611
  * Throw only from `resolveExternalToken` when an expected validation outcome
@@ -4471,6 +4680,75 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
4471
4680
  * to clamp absolute expirations when writing grants back to KV.
4472
4681
  */
4473
4682
  const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
4683
+ /** Whether a registration refused without `none` on offer would otherwise have been a public client. */
4684
+ function wouldRegisterPublicClient(clientMetadata, server) {
4685
+ if (!clientMetadata) return false;
4686
+ try {
4687
+ return resolveDynamicClientRegistrationMetadata(clientMetadata, server).tokenEndpointAuthMethod === "none";
4688
+ } catch {
4689
+ return false;
4690
+ }
4691
+ }
4692
+ /** The combined provider's resource metadata with its required scopes resolved into `scopes_supported`. */
4693
+ function withRequiredScopes(metadata, requiredScopes) {
4694
+ if (!metadata || typeof metadata !== "object") return metadata;
4695
+ const { scopes_supported: deprecated, ...rest } = metadata;
4696
+ const scopes = resolveRequiredScopes(requiredScopes, deprecated);
4697
+ return scopes === void 0 ? rest : {
4698
+ ...rest,
4699
+ scopes_supported: scopes
4700
+ };
4701
+ }
4702
+ /** A refresh-token lifetime: 0 for "issue no refresh token", else a lifetime KV can store. */
4703
+ function isValidRefreshTokenTTL(ttl) {
4704
+ return ttl === 0 || isValidAccessTokenTTL(ttl);
4705
+ }
4706
+ /**
4707
+ * Reads a `purgeExpiredData` cursor: the phase to resume and KV's opaque cursor within it
4708
+ * (empty for the start of that phase). No cursor starts a new sweep with the grants.
4709
+ */
4710
+ function parsePurgeCursor(cursor) {
4711
+ if (cursor === void 0) return { phase: "grants" };
4712
+ const separator = cursor.indexOf(":");
4713
+ const phase = cursor.slice(0, separator);
4714
+ if (separator < 0 || phase !== "grants" && phase !== "tokens") throw new TypeError("Invalid purgeExpiredData cursor");
4715
+ const kvCursor = cursor.slice(separator + 1);
4716
+ return kvCursor ? {
4717
+ phase,
4718
+ kvCursor
4719
+ } : { phase };
4720
+ }
4721
+ /**
4722
+ * RFC 6749 §6: a token request may narrow the grant's scopes. One that names scopes but none the
4723
+ * grant holds would otherwise get a token with no scope at all; it is `invalid_scope` instead.
4724
+ */
4725
+ function grantsNoneOfTheRequestedScopes(requested, granted) {
4726
+ return (typeof requested === "string" ? requested.split(" ").filter(Boolean) : requested ?? []).length > 0 && granted.length === 0;
4727
+ }
4728
+ /** Reads a request body as text, or returns undefined once it passes `maxBytes`. */
4729
+ async function readTextWithLimit(request, maxBytes) {
4730
+ const reader = request.body?.getReader();
4731
+ if (!reader) return "";
4732
+ const chunks = [];
4733
+ let total = 0;
4734
+ for (;;) {
4735
+ const { done, value } = await reader.read();
4736
+ if (done) break;
4737
+ total += value.byteLength;
4738
+ if (total > maxBytes) {
4739
+ reader.cancel().catch(() => void 0);
4740
+ return;
4741
+ }
4742
+ chunks.push(value);
4743
+ }
4744
+ const bytes = new Uint8Array(total);
4745
+ let offset = 0;
4746
+ for (const chunk of chunks) {
4747
+ bytes.set(chunk, offset);
4748
+ offset += chunk.byteLength;
4749
+ }
4750
+ return new TextDecoder().decode(bytes);
4751
+ }
4474
4752
  function isValidAccessTokenTTL(value) {
4475
4753
  return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
4476
4754
  }
@@ -4497,11 +4775,6 @@ const MAX_KV_LIST_LIMIT = 1e3;
4497
4775
  * matched from `list()` alone and never read.
4498
4776
  */
4499
4777
  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
4778
  /** KV serialises key metadata as JSON and rejects a write carrying more than this. */
4506
4779
  const MAX_KV_METADATA_BYTES = 1024;
4507
4780
  /**
@@ -4524,6 +4797,16 @@ function grantPutOptions(grant, expiry = {}) {
4524
4797
  metadata
4525
4798
  } : expiry;
4526
4799
  }
4800
+ /** Splits `grant:{userId}:{grantId}`. Grant IDs never contain `:`, so the last one separates them. */
4801
+ function parseGrantKey(name) {
4802
+ const rest = name.slice(6);
4803
+ const separator = rest.lastIndexOf(":");
4804
+ if (!name.startsWith("grant:") || separator <= 0 || separator === rest.length - 1) return void 0;
4805
+ return {
4806
+ userId: rest.slice(0, separator),
4807
+ grantId: rest.slice(separator + 1)
4808
+ };
4809
+ }
4527
4810
  /** Whether a listed key carries metadata this version can match a grant on. */
4528
4811
  function isGrantKeyMetadata(value) {
4529
4812
  if (typeof value !== "object" || value === null) return false;
@@ -4534,6 +4817,8 @@ function isGrantKeyMetadata(value) {
4534
4817
  * Length of generated token strings
4535
4818
  */
4536
4819
  const TOKEN_LENGTH = 32;
4820
+ const ISSUED_GRANT_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/;
4821
+ const ISSUED_TOKEN_SECRET_PATTERN = /* @__PURE__ */ new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
4537
4822
  /**
4538
4823
  * Checks if a resource server matches an audience claim.
4539
4824
  * Uses origin comparison (case-insensitive hostname via URL normalization)
@@ -4555,11 +4840,6 @@ function audienceMatches(resourceServerUrl, audienceValue) {
4555
4840
  return false;
4556
4841
  }
4557
4842
  }
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
4843
  /** Whether a request or audience names one canonical configured resource. */
4564
4844
  function isExactResource(value, configuredResource) {
4565
4845
  if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
@@ -4619,6 +4899,20 @@ function decodeFormUrlEncodedComponent(value) {
4619
4899
  return decodeURIComponent(value.replace(/\+/g, " "));
4620
4900
  }
4621
4901
  /**
4902
+ * Whether a bearer credential has the exact shape of a token this provider issues:
4903
+ * `{userId}:{grantId}:{secret}`, with a 16-character grant ID and a TOKEN_LENGTH secret from
4904
+ * generateRandomString's alphabet. Parsed from the right, because a deployer's user ID may itself
4905
+ * contain `:`. An external credential with exactly this shape would be treated as ours; opaque API
4906
+ * keys, PATs and JWTs don't have it.
4907
+ */
4908
+ function isIssuedTokenFormat(token) {
4909
+ const parts = token.split(":");
4910
+ if (parts.length < 3) return false;
4911
+ const secret = parts[parts.length - 1];
4912
+ const grantId = parts[parts.length - 2];
4913
+ return parts.slice(0, -2).join(":").length > 0 && ISSUED_GRANT_ID_PATTERN.test(grantId) && ISSUED_TOKEN_SECRET_PATTERN.test(secret);
4914
+ }
4915
+ /**
4622
4916
  * Generates a cryptographically secure random string
4623
4917
  * @param length - The length of the string to generate
4624
4918
  * @returns A random string of the specified length
@@ -4679,56 +4973,6 @@ function base64UrlEncode(str) {
4679
4973
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
4680
4974
  }
4681
4975
  /**
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
4976
  * Encodes an ArrayBuffer as base64 string
4733
4977
  * @param buffer - The ArrayBuffer to encode
4734
4978
  * @returns The base64 encoded string
@@ -4893,7 +5137,7 @@ var OAuthHelpersImpl = class {
4893
5137
  const clientInfo = await this.lookupClient(clientId);
4894
5138
  if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
4895
5139
  try {
4896
- validateRedirectUriScheme(redirectUri);
5140
+ validateRedirectUri(redirectUri, this.provider.serverCapabilities);
4897
5141
  } catch {
4898
5142
  throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
4899
5143
  }
@@ -4947,9 +5191,7 @@ var OAuthHelpersImpl = class {
4947
5191
  return client ? toPublicClientInfo(client) : null;
4948
5192
  }
4949
5193
  /**
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
5194
+ * Completes an authorization request by creating a grant and an authorization code.
4953
5195
  * @param options - Options specifying the grant details
4954
5196
  * @returns A Promise resolving to an object containing the redirect URL
4955
5197
  * @throws Error when the request's response type is not permitted
@@ -4958,102 +5200,55 @@ var OAuthHelpersImpl = class {
4958
5200
  async completeAuthorization(options) {
4959
5201
  const { clientId, redirectUri } = options.request;
4960
5202
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
5203
+ if (typeof options.userId !== "string" || options.userId.length === 0 || options.userId.includes(":")) throw new TypeError("userId must be a non-empty string without \":\"");
4961
5204
  const clientInfo = await this.lookupClient(clientId);
4962
5205
  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.");
5206
+ try {
5207
+ validateRedirectUri(redirectUri, this.provider.serverCapabilities);
5208
+ } catch {
5209
+ throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
5210
+ }
4963
5211
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
4964
5212
  const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
4965
5213
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
4966
5214
  let grantsToRevoke = [];
4967
5215
  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);
5216
+ const isCimdClient = isClientIdMetadataDocumentUrl(clientId);
5217
+ const readConcurrency = DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
5218
+ 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
5219
  }
4972
5220
  const grantId = generateRandomString(16);
4973
5221
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
4974
5222
  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
- }
5223
+ const authCodeSecret = generateRandomString(32);
5224
+ const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
5225
+ const authCodeId = await hashSecret(authCode);
5226
+ const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
5227
+ const grant = {
5228
+ id: grantId,
5229
+ clientId: options.request.clientId,
5230
+ userId: options.userId,
5231
+ scope: options.scope,
5232
+ metadata: options.metadata,
5233
+ encryptedProps: encryptedData,
5234
+ createdAt: now,
5235
+ authCodeId,
5236
+ authCodeWrappedKey,
5237
+ codeChallenge: options.request.codeChallenge,
5238
+ codeChallengeMethod: options.request.codeChallengeMethod,
5239
+ resource: effectiveResource,
5240
+ redirectUri: options.request.redirectUri
5241
+ };
5242
+ const grantKey = `grant:${options.userId}:${grantId}`;
5243
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
5244
+ const redirectUrl = new URL(options.request.redirectUri);
5245
+ redirectUrl.searchParams.set("code", authCode);
5246
+ if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
5247
+ if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
5248
+ try {
5249
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
5250
+ } catch {}
5251
+ return { redirectTo: redirectUrl.toString() };
5057
5252
  }
5058
5253
  /**
5059
5254
  * IDs of the user's grants that satisfy `matches`, judged from KV key metadata
@@ -5067,8 +5262,18 @@ var OAuthHelpersImpl = class {
5067
5262
  * share shrinks on its own.
5068
5263
  */
5069
5264
  async findGrantIds(userId, matches, readConcurrency) {
5070
- const prefix = `grant:${userId}:`;
5071
- const found = [];
5265
+ const grantIds = [];
5266
+ for await (const grants of this.findGrants(userId, matches, readConcurrency)) grantIds.push(...grants.map((grant) => grant.grantId));
5267
+ return grantIds;
5268
+ }
5269
+ /**
5270
+ * Finds grants matching `matches`, for one user or, with `userId` undefined, for every user.
5271
+ * Metadata-carrying keys are matched without a read; legacy grants are read,
5272
+ * `readConcurrency` at a time. Matches are yielded as they are found (each page's metadata
5273
+ * matches, then each batch of reads), so a caller can act on them before the scan goes on.
5274
+ */
5275
+ async *findGrants(userId, matches, readConcurrency) {
5276
+ const prefix = userId === void 0 ? "grant:" : `grant:${userId}:`;
5072
5277
  let cursor;
5073
5278
  do {
5074
5279
  const page = await this.env.OAUTH_KV.list({
@@ -5077,16 +5282,32 @@ var OAuthHelpersImpl = class {
5077
5282
  cursor
5078
5283
  });
5079
5284
  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);
5285
+ const found = [];
5286
+ for (const key of page.keys) {
5287
+ const owner = parseGrantKey(key.name);
5288
+ if (!owner || userId !== void 0 && owner.userId !== userId) continue;
5289
+ if (isGrantKeyMetadata(key.metadata)) {
5290
+ if (matches(key.metadata)) found.push(owner);
5291
+ } else toRead.push({
5292
+ name: key.name,
5293
+ ...owner
5294
+ });
5295
+ }
5296
+ if (found.length > 0) yield found;
5083
5297
  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);
5298
+ const batch = toRead.slice(start, start + readConcurrency);
5299
+ const grants = await Promise.all(batch.map(({ name }) => this.env.OAUTH_KV.get(name, { type: "json" })));
5300
+ const read = batch.filter((_, index) => {
5301
+ const grant = grants[index];
5302
+ return grant !== null && matches(grant);
5303
+ });
5304
+ if (read.length > 0) yield read.map(({ userId: userId$1, grantId }) => ({
5305
+ userId: userId$1,
5306
+ grantId
5307
+ }));
5086
5308
  }
5087
5309
  cursor = page.list_complete ? void 0 : page.cursor;
5088
5310
  } while (cursor);
5089
- return found;
5090
5311
  }
5091
5312
  /**
5092
5313
  * Creates a new OAuth client
@@ -5119,7 +5340,12 @@ var OAuthHelpersImpl = class {
5119
5340
  tokenEndpointAuthMethod,
5120
5341
  ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
5121
5342
  };
5122
- for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
5343
+ validateRegisteredRedirectUris(newClient.redirectUris, this.provider.serverCapabilities);
5344
+ validateClientCapabilities(this.provider.serverCapabilities, {
5345
+ grantTypes: newClient.grantTypes,
5346
+ responseTypes: newClient.responseTypes,
5347
+ tokenEndpointAuthMethod
5348
+ });
5123
5349
  let clientSecret;
5124
5350
  if (!isPublicClient) {
5125
5351
  clientSecret = generateRandomString(32);
@@ -5159,8 +5385,13 @@ var OAuthHelpersImpl = class {
5159
5385
  * @returns A Promise resolving to the updated client info, or null if not found
5160
5386
  */
5161
5387
  async updateClient(clientId, updates) {
5388
+ if (this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientId)) throw new TypeError("Client ID Metadata Document clients are updated by changing their document");
5162
5389
  const client = await this.provider.getClient(this.env, clientId);
5163
5390
  if (!client) return null;
5391
+ if (updates.redirectUris !== void 0) {
5392
+ if (!Array.isArray(updates.redirectUris) || updates.redirectUris.length === 0) throw new Error("redirectUris must not be empty");
5393
+ validateRegisteredRedirectUris(updates.redirectUris, this.provider.serverCapabilities);
5394
+ }
5164
5395
  const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
5165
5396
  const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
5166
5397
  const isPublicClient = authMethod === "none";
@@ -5178,6 +5409,11 @@ var OAuthHelpersImpl = class {
5178
5409
  tokenEndpointAuthMethod: authMethod,
5179
5410
  authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
5180
5411
  };
5412
+ if (updates.grantTypes !== void 0 || updates.responseTypes !== void 0 || updates.tokenEndpointAuthMethod !== void 0) validateClientCapabilities(this.provider.serverCapabilities, {
5413
+ grantTypes: updatedClient.grantTypes ?? [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN],
5414
+ responseTypes: updatedClient.responseTypes ?? ["code"],
5415
+ tokenEndpointAuthMethod: authMethod
5416
+ });
5181
5417
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
5182
5418
  else delete updatedClient.clientSecret;
5183
5419
  if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
@@ -5192,20 +5428,9 @@ var OAuthHelpersImpl = class {
5192
5428
  * @returns A Promise resolving when the deletion is confirmed.
5193
5429
  */
5194
5430
  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
5431
  await this.env.OAUTH_KV.delete(`client:${clientId}`);
5432
+ const grants = this.findGrants(void 0, (grant) => grant.clientId === clientId, DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE);
5433
+ for await (const found of grants) for (const { userId, grantId } of found) await this.revokeGrant(grantId, userId);
5209
5434
  }
5210
5435
  /**
5211
5436
  * Lists all authorization grants for a specific user with pagination support
@@ -5218,9 +5443,18 @@ var OAuthHelpersImpl = class {
5218
5443
  const listOptions = { prefix: `grant:${userId}:` };
5219
5444
  if (options?.limit !== void 0) listOptions.limit = options.limit;
5220
5445
  if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
5221
- const response = await this.env.OAUTH_KV.list(listOptions);
5446
+ const isOwn = (key) => !key.name.slice(listOptions.prefix.length).includes(":");
5447
+ let response = await this.env.OAUTH_KV.list(listOptions);
5448
+ let ownKeys = response.keys.filter(isOwn);
5449
+ while (ownKeys.length === 0 && !response.list_complete) {
5450
+ response = await this.env.OAUTH_KV.list({
5451
+ ...listOptions,
5452
+ cursor: response.cursor
5453
+ });
5454
+ ownKeys = response.keys.filter(isOwn);
5455
+ }
5222
5456
  const grantSummaries = [];
5223
- const promises = response.keys.map(async (key) => {
5457
+ const promises = ownKeys.map(async (key) => {
5224
5458
  const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
5225
5459
  if (grantData) {
5226
5460
  const summary = {
@@ -5249,6 +5483,12 @@ var OAuthHelpersImpl = class {
5249
5483
  * @param userId - The ID of the user who owns the grant
5250
5484
  * @returns A Promise resolving when the revocation is confirmed.
5251
5485
  */
5486
+ /** {@inheritDoc OAuthHelpers.describeConsent} */
5487
+ async describeConsent(authRequest) {
5488
+ const client = await this.lookupClient(authRequest.clientId);
5489
+ const isCimd = !!this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(authRequest.clientId);
5490
+ return describeConsent(client, authRequest, isCimd);
5491
+ }
5252
5492
  /** {@inheritDoc OAuthHelpers.isConsentRemembered} */
5253
5493
  isConsentRemembered(request, authRequest, remember) {
5254
5494
  return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
@@ -5317,6 +5557,7 @@ var OAuthHelpersImpl = class {
5317
5557
  const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
5318
5558
  const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
5319
5559
  const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
5560
+ const start = parsePurgeCursor(options?.cursor);
5320
5561
  const now = Math.floor(Date.now() / 1e3);
5321
5562
  const result = {
5322
5563
  grantsChecked: 0,
@@ -5325,10 +5566,10 @@ var OAuthHelpersImpl = class {
5325
5566
  tokensPurged: 0,
5326
5567
  done: false
5327
5568
  };
5328
- if (purgeOrphanedGrants || purgeExpiredGrants) {
5569
+ if (start.phase === "grants" && (purgeOrphanedGrants || purgeExpiredGrants)) {
5329
5570
  const knownGoodClients = /* @__PURE__ */ new Set();
5330
5571
  const knownMissingClients = /* @__PURE__ */ new Set();
5331
- let grantCursor;
5572
+ let grantCursor = start.kvCursor;
5332
5573
  let grantsDone = false;
5333
5574
  while (!grantsDone && result.grantsChecked < batchSize) {
5334
5575
  const listOptions = {
@@ -5344,7 +5585,7 @@ var OAuthHelpersImpl = class {
5344
5585
  if (!grantData) continue;
5345
5586
  let shouldPurge = false;
5346
5587
  if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
5347
- if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
5588
+ if (!shouldPurge && purgeOrphanedGrants && !isClientIdMetadataDocumentUrl(grantData.clientId)) {
5348
5589
  if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
5349
5590
  else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
5350
5591
  else {
@@ -5360,12 +5601,15 @@ var OAuthHelpersImpl = class {
5360
5601
  if (page.list_complete) grantsDone = true;
5361
5602
  else grantCursor = page.cursor;
5362
5603
  }
5363
- if (!grantsDone) return result;
5604
+ if (!grantsDone) return {
5605
+ ...result,
5606
+ cursor: `grants:${grantCursor ?? ""}`
5607
+ };
5364
5608
  }
5365
5609
  if (purgeOrphanedTokens) {
5366
5610
  const knownGoodGrants = /* @__PURE__ */ new Set();
5367
5611
  const knownMissingGrants = /* @__PURE__ */ new Set();
5368
- let tokenCursor;
5612
+ let tokenCursor = start.phase === "tokens" ? start.kvCursor : void 0;
5369
5613
  let tokensDone = false;
5370
5614
  while (!tokensDone && result.tokensChecked < batchSize) {
5371
5615
  const listOptions = {
@@ -5393,7 +5637,10 @@ var OAuthHelpersImpl = class {
5393
5637
  if (page.list_complete) tokensDone = true;
5394
5638
  else tokenCursor = page.cursor;
5395
5639
  }
5396
- if (!tokensDone) return result;
5640
+ if (!tokensDone) return {
5641
+ ...result,
5642
+ cursor: `tokens:${tokenCursor ?? ""}`
5643
+ };
5397
5644
  }
5398
5645
  result.done = true;
5399
5646
  return result;
@@ -5406,4 +5653,4 @@ var OAuthHelpersImpl = class {
5406
5653
  var oauth_provider_default = OAuthProvider;
5407
5654
 
5408
5655
  //#endregion
5409
- export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
5656
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, authorizationErrorRedirect, oauth_provider_default as default, getOAuthApi, insufficientScope };