@cloudflare/workers-oauth-provider 1.0.0 → 1.2.0

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