@cloudflare/workers-oauth-provider 0.10.4 → 1.0.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.
@@ -911,6 +911,96 @@ async function verifyIdJagSignature(input) {
911
911
  }
912
912
  }
913
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
+ /**
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.
965
+ */
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;
982
+ }
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;
1002
+ }
1003
+
914
1004
  //#endregion
915
1005
  //#region src/ema/validators.ts
916
1006
  /**
@@ -1036,7 +1126,7 @@ function isWellFormedTrustedIssuer(issuer) {
1036
1126
  * - `scope` (if present) conforms to RFC 6749 §3.3 grammar
1037
1127
  */
1038
1128
  function validateIdJagClaims(input) {
1039
- const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource, matchOriginOnly } = input;
1129
+ const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource } = input;
1040
1130
  const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
1041
1131
  const iss = readRequiredString(rawClaims, "iss");
1042
1132
  if (!iss.ok) return iss;
@@ -1076,7 +1166,7 @@ function validateIdJagClaims(input) {
1076
1166
  reason: "resource_invalid",
1077
1167
  resource: resource.value
1078
1168
  });
1079
- if (!resourceMatches(resource.value, configuredResource, matchOriginOnly)) return err({
1169
+ if (!resourceMatches(resource.value, configuredResource)) return err({
1080
1170
  reason: "resource_mismatch",
1081
1171
  expected: configuredResource,
1082
1172
  got: resource.value
@@ -1228,6 +1318,237 @@ function readNumericDateClaim(claims, claimName) {
1228
1318
  return ok(value);
1229
1319
  }
1230
1320
 
1321
+ //#endregion
1322
+ //#region src/oauth-resource-server.ts
1323
+ const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 = "/.well-known/oauth-protected-resource";
1324
+ const NO_CACHE_HEADERS$1 = {
1325
+ "Cache-Control": "no-store",
1326
+ Pragma: "no-cache"
1327
+ };
1328
+ /**
1329
+ * The response for a valid token that lacks the scopes an operation needs (RFC 6750 §3.1,
1330
+ * MCP scope challenge handling): `403` with `WWW-Authenticate: Bearer error="insufficient_scope"`,
1331
+ * the `scope` the operation requires and the `resource_metadata` URL the client already knows.
1332
+ * Name every scope the operation needs in one response; clients treat the list as complete.
1333
+ *
1334
+ * ```ts
1335
+ * if (!ctx.auth.scope.includes('calendar:write')) return insufficientScope(ctx.auth, ['calendar:write']);
1336
+ * ```
1337
+ */
1338
+ 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");
1340
+ let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
1341
+ challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
1342
+ if (description !== void 0) {
1343
+ const quotable = description.replace(/["\\]/g, "").replace(/[^\x21-\x7e]+/g, " ").trim();
1344
+ if (quotable) challenge += `, error_description="${quotable}"`;
1345
+ }
1346
+ return new Response(JSON.stringify({
1347
+ error: "insufficient_scope",
1348
+ ...description !== void 0 ? { error_description: description } : {}
1349
+ }), {
1350
+ status: 403,
1351
+ headers: {
1352
+ ...NO_CACHE_HEADERS$1,
1353
+ "Content-Type": "application/json",
1354
+ "WWW-Authenticate": challenge
1355
+ }
1356
+ });
1357
+ }
1358
+ /**
1359
+ * One OAuth protected resource, in the authorization server's Worker or its own. Publishes
1360
+ * RFC 9728 metadata, challenges unauthenticated requests, validates the bearer token through
1361
+ * the configured `validateToken`, enforces audience and expiry on what it returns, and routes
1362
+ * only the canonical resource and its descendants to the application handler.
1363
+ *
1364
+ * `export default new OAuthResourceServer({ … })`, or dispatch to `fetch()` from your own
1365
+ * router, exactly as with `OAuthAuthorizationServer`.
1366
+ */
1367
+ var OAuthResourceServer = class {
1368
+ #options;
1369
+ #validated;
1370
+ constructor(options) {
1371
+ this.#validated = validateOptions(options);
1372
+ this.#options = options;
1373
+ }
1374
+ async fetch(request, env, ctx) {
1375
+ const options = this.#options;
1376
+ const validated = this.#validated;
1377
+ const url = new URL(request.url);
1378
+ if (isProtectedResourceMetadataPath(url)) {
1379
+ if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1380
+ status: 204,
1381
+ headers: { "Content-Length": "0" }
1382
+ }), 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, {
1385
+ status: 405,
1386
+ headers: { Allow: "GET, HEAD, OPTIONS" }
1387
+ }), request);
1388
+ const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
1389
+ return addCorsHeaders(request.method === "HEAD" ? new Response(null, {
1390
+ status: 200,
1391
+ headers: metadata.headers
1392
+ }) : metadata, request);
1393
+ }
1394
+ if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
1395
+ if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1396
+ status: 204,
1397
+ headers: { "Content-Length": "0" }
1398
+ }), request);
1399
+ const token = parseBearerToken(request.headers.get("Authorization"));
1400
+ if (!token) return addCorsHeaders(createBearerChallenge(url, validated, false), request);
1401
+ let validation;
1402
+ try {
1403
+ validation = await options.validateToken(env, request)(validated.resource, token);
1404
+ } catch {
1405
+ return addCorsHeaders(createValidationUnavailableResponse(), request);
1406
+ }
1407
+ if (!isValidTokenValidation(validation, validated.resource)) return addCorsHeaders(createBearerChallenge(url, validated, true), request);
1408
+ const context = ctx;
1409
+ context.props = validation.props;
1410
+ context.auth = {
1411
+ token,
1412
+ audience: validated.resource,
1413
+ ...validation.expiresAt !== void 0 ? { expiresAt: validation.expiresAt } : {},
1414
+ scope: [...validation.scope ?? []],
1415
+ ...validation.userId !== void 0 ? { userId: validation.userId } : {},
1416
+ ...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
1417
+ };
1418
+ const handler = options.handler;
1419
+ return addCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
1420
+ }
1421
+ };
1422
+ function validateOptions(options) {
1423
+ if (!options || typeof options !== "object") throw new TypeError("OAuth resource server options are required");
1424
+ if (!options.handler || !isEntrypointClass(options.handler) && typeof options.handler.fetch !== "function") throw new TypeError("handler must provide a fetch function or extend WorkerEntrypoint");
1425
+ if (typeof options.validateToken !== "function") throw new TypeError("validateToken must be a function");
1426
+ const resource = options.resourceMetadata?.resource;
1427
+ const resourceUrl = parseCanonicalUrl(resource);
1428
+ if (!resourceUrl) throw new TypeError("resourceMetadata.resource must be a canonical absolute HTTPS URI without a fragment (http is accepted only on a loopback host)");
1429
+ if (isProtectedResourceMetadataPath(resourceUrl)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1} namespace`);
1430
+ const authorizationServers = options.resourceMetadata.authorization_servers;
1431
+ if (!Array.isArray(authorizationServers) || authorizationServers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
1432
+ for (const issuer of authorizationServers) {
1433
+ const issuerUrl = parseCanonicalUrl(issuer);
1434
+ if (!issuerUrl || issuerUrl.search || issuerUrl.hash) throw new TypeError("resourceMetadata.authorization_servers must contain canonical HTTPS issuer URLs (http is accepted only on a loopback host)");
1435
+ }
1436
+ const bearerMethods = options.resourceMetadata.bearer_methods_supported;
1437
+ 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");
1441
+ return {
1442
+ resource,
1443
+ resourceUrl,
1444
+ metadataUrl: new URL(getResourceMetadataUrl(resource)),
1445
+ metadata: {
1446
+ resource,
1447
+ authorization_servers: [...authorizationServers],
1448
+ ...resourceScopes.length ? { scopes_supported: resourceScopes } : {},
1449
+ bearer_methods_supported: bearerMethods ? [...bearerMethods] : ["header"],
1450
+ ...options.resourceMetadata.resource_name !== void 0 ? { resource_name: options.resourceMetadata.resource_name } : {}
1451
+ }
1452
+ };
1453
+ }
1454
+ function parseCanonicalUrl(value) {
1455
+ if (typeof value !== "string" || !validateResourceUri(value)) return null;
1456
+ let parsed;
1457
+ try {
1458
+ parsed = new URL(value);
1459
+ } catch {
1460
+ return null;
1461
+ }
1462
+ if (!hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.protocol !== parsed.protocol.toLowerCase() || parsed.hostname !== parsed.hostname.toLowerCase()) return null;
1463
+ if (parsed.href !== value && parsed.origin !== value) return null;
1464
+ return parsed;
1465
+ }
1466
+ function isValidScopeToken(scope) {
1467
+ return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
1468
+ }
1469
+ function getResourceMetadataUrl(resource) {
1470
+ const parsed = new URL(resource);
1471
+ const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
1472
+ return `${parsed.origin}${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}${suffix}${parsed.search}`;
1473
+ }
1474
+ /** The same test the combined provider applies to its handlers. */
1475
+ function isEntrypointClass(handler) {
1476
+ return typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint;
1477
+ }
1478
+ function isProtectedResourceMetadataPath(url) {
1479
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 || url.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}/`);
1480
+ }
1481
+ function isMetadataUrlRequest(requestUrl, metadataUrl) {
1482
+ return requestUrl.origin === metadataUrl.origin && requestUrl.pathname === metadataUrl.pathname && requestCarriesResourceQuery(requestUrl, metadataUrl);
1483
+ }
1484
+ function isCanonicalResourceRequest(requestUrl, resourceUrl) {
1485
+ if (requestUrl.origin !== resourceUrl.origin) return false;
1486
+ if (!requestCarriesResourceQuery(requestUrl, resourceUrl)) return false;
1487
+ const resourcePath = resourceUrl.pathname;
1488
+ if (requestUrl.pathname === resourcePath) return true;
1489
+ if (resourcePath === "/") return requestUrl.pathname.startsWith("/");
1490
+ const descendantPrefix = resourcePath.endsWith("/") ? resourcePath : `${resourcePath}/`;
1491
+ return requestUrl.pathname.startsWith(descendantPrefix);
1492
+ }
1493
+ function parseBearerToken(authorization) {
1494
+ if (!authorization) return null;
1495
+ return /^Bearer[\t ]+([^\s,]+)$/i.exec(authorization)?.[1] || null;
1496
+ }
1497
+ function isValidTokenValidation(validation, canonicalResource) {
1498
+ if (!validation || typeof validation !== "object") return false;
1499
+ if (typeof validation.audience !== "string" || !resourceMatches(validation.audience, canonicalResource)) return false;
1500
+ if (!Object.prototype.hasOwnProperty.call(validation, "props")) return false;
1501
+ if (validation.expiresAt !== void 0) {
1502
+ if (typeof validation.expiresAt !== "number" || !Number.isFinite(validation.expiresAt)) return false;
1503
+ if (validation.expiresAt <= Date.now() / 1e3) return false;
1504
+ }
1505
+ if (validation.scope !== void 0) {
1506
+ if (!Array.isArray(validation.scope)) return false;
1507
+ if (validation.scope.some((scope) => typeof scope !== "string" || !isValidScopeToken(scope))) return false;
1508
+ }
1509
+ if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
1510
+ if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
1511
+ return true;
1512
+ }
1513
+ function createBearerChallenge(requestUrl, validated, invalidToken) {
1514
+ let challenge = "Bearer realm=\"OAuth\"";
1515
+ if (isCanonicalResourceRequest(requestUrl, validated.resourceUrl)) challenge += `, resource_metadata="${validated.metadataUrl.href}"`;
1516
+ if (invalidToken) challenge += ", error=\"invalid_token\"";
1517
+ const scopes = validated.metadata.scopes_supported;
1518
+ if (scopes && scopes.length > 0) challenge += `, scope="${scopes.join(" ")}"`;
1519
+ return new Response(null, {
1520
+ status: 401,
1521
+ headers: {
1522
+ ...NO_CACHE_HEADERS$1,
1523
+ "WWW-Authenticate": challenge
1524
+ }
1525
+ });
1526
+ }
1527
+ function createValidationUnavailableResponse() {
1528
+ return new Response(null, {
1529
+ status: 503,
1530
+ headers: NO_CACHE_HEADERS$1
1531
+ });
1532
+ }
1533
+ function addCorsHeaders(response, request) {
1534
+ const origin = request.headers.get("Origin");
1535
+ if (!origin) return response;
1536
+ const withCors = new Response(response.body, response);
1537
+ withCors.headers.set("Access-Control-Allow-Origin", origin);
1538
+ withCors.headers.set("Access-Control-Allow-Methods", "*");
1539
+ withCors.headers.set("Access-Control-Allow-Headers", "Authorization, *");
1540
+ appendHeaderValue$1(withCors.headers, "Vary", "Origin");
1541
+ appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
1542
+ appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "Retry-After");
1543
+ withCors.headers.set("Access-Control-Max-Age", "86400");
1544
+ return withCors;
1545
+ }
1546
+ function appendHeaderValue$1(headers, name, value) {
1547
+ const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1548
+ if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
1549
+ headers.set(name, values.join(", "));
1550
+ }
1551
+
1231
1552
  //#endregion
1232
1553
  //#region src/oauth-provider.ts
1233
1554
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
@@ -1256,7 +1577,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
1256
1577
  return GrantType$1;
1257
1578
  }({});
1258
1579
  function toPublicClientInfo(client) {
1259
- const { authMethodExplicit: _explicit, ...publicClient } = client;
1580
+ const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
1260
1581
  return publicClient;
1261
1582
  }
1262
1583
  function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
@@ -1302,6 +1623,66 @@ var OAuthProvider = class {
1302
1623
  }
1303
1624
  };
1304
1625
  /**
1626
+ * The authorization-server role on its own. It serves discovery, token, revocation and
1627
+ * registration endpoints from `fetch()`, exposes the interactive authorization flow through
1628
+ * `getOAuthApi()`, and validates its access tokens for any declared resource through
1629
+ * `validateToken()`. Resources are hosted, in this Worker or another, by
1630
+ * `OAuthResourceServer`, whose `validateToken` points back here.
1631
+ */
1632
+ var OAuthAuthorizationServer = class {
1633
+ #impl;
1634
+ constructor(options) {
1635
+ const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
1636
+ this.#impl = new OAuthProviderImpl({
1637
+ ...commonOptions,
1638
+ defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
1639
+ authorizationServer: {
1640
+ issuer,
1641
+ resources,
1642
+ authorizeEndpoint,
1643
+ tokenEndpoint,
1644
+ clientRegistrationEndpoint,
1645
+ defaultResource,
1646
+ legacyGrantResource
1647
+ }
1648
+ });
1649
+ }
1650
+ /**
1651
+ * Validate an access token for one declared resource: the token's KV record is loaded, its
1652
+ * audience checked against `resource`, and its props decrypted. Resolves `null` for a token
1653
+ * that is not valid for that resource, and rejects for a resource this server does not
1654
+ * declare.
1655
+ *
1656
+ * This is what a resource server calls, whether it shares this Worker or reaches it over a
1657
+ * Service Binding. For the latter, expose it from a `WorkerEntrypoint`:
1658
+ *
1659
+ * ```ts
1660
+ * export default class AuthServer extends WorkerEntrypoint<Env> {
1661
+ * fetch(request: Request) {
1662
+ * return authorizationServer.fetch(request, this.env, this.ctx);
1663
+ * }
1664
+ * validateToken(resource: string, token: string) {
1665
+ * return authorizationServer.validateToken(resource, token, this.env);
1666
+ * }
1667
+ * }
1668
+ * ```
1669
+ */
1670
+ validateToken(resource, token, env) {
1671
+ return this.#impl.validateAccessToken(token, resource, env);
1672
+ }
1673
+ /** Serve protocol-owned authorization-server endpoints, excluding the application authorization UI. */
1674
+ fetch(request, env, ctx) {
1675
+ return this.#impl.fetchAuthorizationServer(request, env, ctx);
1676
+ }
1677
+ /** Obtain helpers for scheduled jobs or custom code outside a fetch dispatch. */
1678
+ getOAuthApi(env) {
1679
+ return this.#impl.createOAuthHelpers(env);
1680
+ }
1681
+ purgeExpiredData(env, options) {
1682
+ return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
1683
+ }
1684
+ };
1685
+ /**
1305
1686
  * Gets OAuthHelpers for the given environment
1306
1687
  * @param options - Configuration options for the OAuth provider
1307
1688
  * @param env - Cloudflare Worker environment variables
@@ -1324,37 +1705,95 @@ var OAuthProviderImpl = class {
1324
1705
  * @param options - Configuration options for the provider
1325
1706
  */
1326
1707
  constructor(options) {
1708
+ this.errorResponses = /* @__PURE__ */ new WeakSet();
1327
1709
  this.typedApiHandlers = [];
1328
- const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
1329
- const hasMultiHandlerConfig = !!options.apiHandlers;
1330
- if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
1331
- if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
1332
1710
  this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
1333
- if (hasSingleHandlerConfig) {
1334
- const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
1335
- if (Array.isArray(options.apiRoute)) options.apiRoute.forEach((route, index) => {
1336
- this.validateEndpoint(route, `apiRoute[${index}]`);
1337
- this.typedApiHandlers.push([route, apiHandler]);
1338
- });
1339
- else {
1340
- this.validateEndpoint(options.apiRoute, "apiRoute");
1341
- this.typedApiHandlers.push([options.apiRoute, apiHandler]);
1342
- }
1343
- } else for (const [route, handler] of Object.entries(options.apiHandlers)) {
1344
- this.validateEndpoint(route, `apiHandlers key: ${route}`);
1345
- this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
1711
+ const roleBased = "authorizationServer" in options;
1712
+ 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
+ let normalizedOptions;
1714
+ let configuredResourceServers;
1715
+ if (roleBased) {
1716
+ const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
1717
+ this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
1718
+ this.explicitIssuer = authorizationServer$1.issuer;
1719
+ normalizedOptions = {
1720
+ ...commonOptions,
1721
+ authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
1722
+ tokenEndpoint: authorizationServer$1.tokenEndpoint,
1723
+ clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
1724
+ };
1725
+ const declared = authorizationServer$1.resources;
1726
+ if (!Array.isArray(declared) || declared.length === 0 || declared.some((value) => typeof value !== "string")) throw new TypeError("resources must list at least one canonical protected resource identifier");
1727
+ const duplicate = declared.find((resource, index) => declared.slice(0, index).some((earlier) => isExactResource(earlier, resource)));
1728
+ if (duplicate !== void 0) throw new TypeError(`resources must be unique; duplicate ${duplicate}`);
1729
+ configuredResourceServers = declared.map((resource) => ({ resourceMetadata: { resource } }));
1730
+ } else {
1731
+ this.explicitIssuer = void 0;
1732
+ normalizedOptions = options;
1733
+ configuredResourceServers = [{
1734
+ resourceMetadata: options.resourceMetadata,
1735
+ resolveExternalToken: options.resolveExternalToken
1736
+ }];
1346
1737
  }
1347
- this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
1348
- this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
1349
- if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
1350
1738
  this.options = {
1351
1739
  accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
1352
1740
  refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
1353
1741
  clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
1354
1742
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
1355
- ...options
1743
+ ...normalizedOptions
1356
1744
  };
1357
- if (!Number.isInteger(this.options.accessTokenTTL) || this.options.accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
1745
+ this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
1746
+ this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
1747
+ if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
1748
+ if (roleBased) this.validateAuthorizationServerRouteIsolation();
1749
+ this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
1750
+ const metadata = this.snapshotResourceMetadata(resourceMetadata);
1751
+ this.validateResourceMetadataOptions(metadata);
1752
+ return {
1753
+ resourceMetadata: metadata,
1754
+ resolveExternalToken
1755
+ };
1756
+ });
1757
+ for (const [index, server] of this.resourceServers.entries()) if (this.resourceServers.slice(0, index).some((other) => isExactResource(other.resourceMetadata.resource, server.resourceMetadata.resource))) throw new TypeError(`resources must be unique; duplicate ${server.resourceMetadata.resource}`);
1758
+ if (!roleBased) {
1759
+ const legacyOptions = options;
1760
+ const hasSingleHandlerConfig = !!(legacyOptions.apiRoute && legacyOptions.apiHandler);
1761
+ const hasMultiHandlerConfig = !!legacyOptions.apiHandlers;
1762
+ if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
1763
+ if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
1764
+ const resourceServer = this.resourceServers[0];
1765
+ if (hasSingleHandlerConfig) {
1766
+ const handler = this.validateHandler(legacyOptions.apiHandler, "apiHandler");
1767
+ (Array.isArray(legacyOptions.apiRoute) ? legacyOptions.apiRoute : [legacyOptions.apiRoute]).forEach((route, index) => {
1768
+ this.validateEndpoint(route, Array.isArray(legacyOptions.apiRoute) ? `apiRoute[${index}]` : "apiRoute");
1769
+ this.typedApiHandlers.push({
1770
+ route,
1771
+ handler,
1772
+ resourceServer
1773
+ });
1774
+ });
1775
+ } else for (const [route, rawHandler] of Object.entries(legacyOptions.apiHandlers)) {
1776
+ this.validateEndpoint(route, `apiHandlers key: ${route}`);
1777
+ const handler = this.validateHandler(rawHandler, `apiHandlers[${route}]`);
1778
+ this.typedApiHandlers.push({
1779
+ route,
1780
+ handler,
1781
+ resourceServer
1782
+ });
1783
+ }
1784
+ for (const { route } of this.typedApiHandlers) this.assertRouteCoveredByResource(route, resourceServer.resourceMetadata.resource);
1785
+ }
1786
+ const resources = this.resourceServers.map((server) => server.resourceMetadata.resource);
1787
+ const soleResource = resources.length === 1 ? resources[0] : void 0;
1788
+ const authorizationServer = roleBased ? options.authorizationServer : void 0;
1789
+ this.configuredDefaultAuthorizationResource = authorizationServer?.defaultResource ?? soleResource;
1790
+ this.configuredLegacyGrantResource = authorizationServer?.legacyGrantResource ?? soleResource;
1791
+ if (roleBased) {
1792
+ this.getDefaultAuthorizationResource();
1793
+ this.getLegacyGrantResource();
1794
+ }
1795
+ 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
+ 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).`);
1358
1797
  this.serverCapabilities = buildOAuthServerCapabilities({
1359
1798
  allowImplicitFlow: !!this.options.allowImplicitFlow,
1360
1799
  allowPlainPKCE: this.options.allowPlainPKCE === true,
@@ -1362,7 +1801,6 @@ var OAuthProviderImpl = class {
1362
1801
  enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
1363
1802
  });
1364
1803
  validateAuthorizationServerScopes(this.options.scopesSupported);
1365
- this.validateResourceMetadataOptions(this.options.resourceMetadata);
1366
1804
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
1367
1805
  if (this.options.enterpriseManagedAuthorization) {
1368
1806
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -1378,11 +1816,90 @@ var OAuthProviderImpl = class {
1378
1816
  validateEndpoint(endpoint, name) {
1379
1817
  if (this.isPath(endpoint)) {
1380
1818
  if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
1381
- } else try {
1382
- new URL(endpoint);
1383
- } catch (e) {
1384
- throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
1819
+ if (this.explicitIssuer && new URL(endpoint, new URL(this.explicitIssuer).origin).hash) throw new TypeError(`${name} must not contain a fragment`);
1820
+ } else {
1821
+ let parsed;
1822
+ try {
1823
+ parsed = new URL(endpoint);
1824
+ } catch (e) {
1825
+ throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
1826
+ }
1827
+ if (this.explicitIssuer && (!hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.hash)) throw new TypeError(`${name} must be an absolute HTTPS URL without userinfo or a fragment (http is accepted only on a loopback host)`);
1828
+ }
1829
+ }
1830
+ /** Validate the explicit issuer used to host-gate the role-based AS. */
1831
+ validateAuthorizationServerIssuer(issuer) {
1832
+ let parsed;
1833
+ try {
1834
+ parsed = new URL(issuer);
1835
+ } catch {
1836
+ throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL");
1385
1837
  }
1838
+ 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
+ }
1840
+ /** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
1841
+ validateAuthorizationServerRouteIsolation() {
1842
+ const issuerUrl = new URL(this.explicitIssuer);
1843
+ const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
1844
+ 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");
1846
+ if (!this.options.clientRegistrationEndpoint) return;
1847
+ 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");
1850
+ }
1851
+ /**
1852
+ * A protected route must be the canonical resource path or a path-boundary descendant of
1853
+ * it. Anything else can never validate a token, because every token is bound to the
1854
+ * canonical resource, so it would be a permanently 401 zone.
1855
+ */
1856
+ assertRouteCoveredByResource(route, resource) {
1857
+ const resourceUrl = new URL(resource);
1858
+ let routePath;
1859
+ if (this.isPath(route)) routePath = route.split("?")[0];
1860
+ else {
1861
+ const routeUrl = new URL(route);
1862
+ if (routeUrl.origin !== resourceUrl.origin) throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. An absolute protected route must be on the resource's origin.`);
1863
+ for (const [name, value] of resourceUrl.searchParams) {
1864
+ const routeValues = routeUrl.searchParams.getAll(name);
1865
+ if (routeValues.length > 0 && !routeValues.includes(value)) throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. An absolute protected route must carry the resource's query parameters.`);
1866
+ }
1867
+ routePath = routeUrl.pathname;
1868
+ }
1869
+ if (isPathDescendant(routePath, resourceUrl.pathname)) return;
1870
+ throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. Protected routes must be the canonical resource path or a descendant of it; use ${resourceUrl.origin} as the resource to cover every path on that origin.`);
1871
+ }
1872
+ /** Resolve a configured policy value to the registry's canonical spelling. */
1873
+ resolveConfiguredResourceDefault(resource, name) {
1874
+ if (resource === void 0) return void 0;
1875
+ const configured = this.findConfiguredResource(resource);
1876
+ if (!configured) throw new TypeError(`${name} must name one of the configured protected resources`);
1877
+ return configured;
1878
+ }
1879
+ getDefaultAuthorizationResource() {
1880
+ const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
1881
+ return this.resolveConfiguredResourceDefault(this.configuredDefaultAuthorizationResource ?? sole, "defaultResource");
1882
+ }
1883
+ getLegacyGrantResource() {
1884
+ const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
1885
+ return this.resolveConfiguredResourceDefault(this.configuredLegacyGrantResource ?? sole, "legacyGrantResource");
1886
+ }
1887
+ /**
1888
+ * The audience a stored access token is treated as bound to.
1889
+ *
1890
+ * A token issued before resource binding has no stored audience. It keeps
1891
+ * working at the server-selected migration resource (the sole resource, or
1892
+ * `legacyGrantResource` in a multi-resource deployment) until it expires;
1893
+ * refresh binds its grant and returns a bound replacement. The client never
1894
+ * chooses this destination. Without a migration resource the token is
1895
+ * treated as unbound and rejected.
1896
+ *
1897
+ * The destination is deployment policy, not an issuance-time claim: changing
1898
+ * `legacyGrantResource` re-targets every surviving unbound token and grant.
1899
+ * Keep it fixed for the length of the migration window.
1900
+ */
1901
+ resolveStoredTokenAudience(audience) {
1902
+ return audience === void 0 ? this.getLegacyGrantResource() : audience;
1386
1903
  }
1387
1904
  /**
1388
1905
  * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
@@ -1402,10 +1919,22 @@ var OAuthProviderImpl = class {
1402
1919
  };
1403
1920
  throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
1404
1921
  }
1922
+ /** Snapshot caller-owned metadata so later mutation cannot change routing or policy. */
1923
+ snapshotResourceMetadata(metadata) {
1924
+ return {
1925
+ ...metadata,
1926
+ ...metadata?.authorization_servers ? { authorization_servers: [...metadata.authorization_servers] } : {},
1927
+ ...metadata?.scopes_supported ? { scopes_supported: [...metadata.scopes_supported] } : {},
1928
+ ...metadata?.bearer_methods_supported ? { bearer_methods_supported: [...metadata.bearer_methods_supported] } : {}
1929
+ };
1930
+ }
1405
1931
  /** Validate configured RFC 9728 protected resource metadata. */
1406
1932
  validateResourceMetadataOptions(options) {
1407
- if (!options) return;
1408
- if (options.resource !== void 0 && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
1933
+ if (!options || !validateResourceUri(options.resource) || !hasAcceptedCanonicalScheme(new URL(options.resource))) throw new TypeError("resourceMetadata.resource is required and must be an absolute HTTPS URI without a fragment (http is accepted only on a loopback host)");
1934
+ if (foldResourceSchemeAndHost(options.resource) !== options.resource) throw new TypeError("resourceMetadata.resource must use a lowercase scheme and lowercase host");
1935
+ const parsedResource = new URL(options.resource);
1936
+ if (parsedResource.username || parsedResource.password || parsedResource.href !== options.resource && parsedResource.origin !== options.resource) throw new TypeError("resourceMetadata.resource must use canonical URL serialization without userinfo, a default port, or dot segments");
1937
+ if (parsedResource.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || parsedResource.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX}/`)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX} namespace`);
1409
1938
  if (options.authorization_servers !== void 0) {
1410
1939
  if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
1411
1940
  for (const issuer of options.authorization_servers) {
@@ -1415,7 +1944,7 @@ var OAuthProviderImpl = class {
1415
1944
  } catch {
1416
1945
  throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
1417
1946
  }
1418
- if (parsed.protocol !== "https:" || parsed.search || parsed.hash) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
1947
+ 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)");
1419
1948
  }
1420
1949
  }
1421
1950
  if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
@@ -1433,7 +1962,6 @@ var OAuthProviderImpl = class {
1433
1962
  if (!options) return;
1434
1963
  if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
1435
1964
  if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
1436
- if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
1437
1965
  if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
1438
1966
  if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
1439
1967
  if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
@@ -1447,20 +1975,38 @@ var OAuthProviderImpl = class {
1447
1975
  * @returns A Promise resolving to an HTTP Response
1448
1976
  */
1449
1977
  async fetch(request, env, ctx) {
1978
+ return this.fetchForRoles(request, env, ctx, "combined");
1979
+ }
1980
+ async fetchAuthorizationServer(request, env, ctx) {
1981
+ return this.fetchForRoles(request, env, ctx, "authorization-server");
1982
+ }
1983
+ async fetchForRoles(request, env, ctx, role) {
1450
1984
  const url = new URL(request.url);
1985
+ const servesProtectedResources = role === "combined";
1986
+ const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
1987
+ const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
1451
1988
  if (request.method === "OPTIONS") {
1452
- if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isProtectedResourceMetadataRequest(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
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, {
1453
1990
  status: 204,
1454
1991
  headers: { "Content-Length": "0" }
1455
1992
  }), request);
1456
1993
  }
1457
- if (url.pathname === "/.well-known/oauth-authorization-server") {
1994
+ if (this.isAuthorizationServerMetadataRequest(url)) {
1995
+ if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
1996
+ status: 405,
1997
+ headers: { Allow: "GET, HEAD, OPTIONS" }
1998
+ }), request);
1458
1999
  const response = await this.handleMetadataDiscovery(url);
1459
- return this.addCorsHeaders(response, request);
2000
+ return this.addCorsHeaders(withoutBodyForHead(request, response), request);
1460
2001
  }
1461
- if (this.isProtectedResourceMetadataRequest(url)) {
1462
- const response = this.handleProtectedResourceMetadata(url);
1463
- return this.addCorsHeaders(response, request);
2002
+ 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, {
2005
+ status: 405,
2006
+ headers: { Allow: "GET, HEAD, OPTIONS" }
2007
+ }), request);
2008
+ const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
2009
+ return this.addCorsHeaders(withoutBodyForHead(request, response), request);
1464
2010
  }
1465
2011
  if (this.isTokenEndpoint(url)) {
1466
2012
  const parsed = await this.parseTokenEndpointRequest(request, env);
@@ -1468,16 +2014,18 @@ var OAuthProviderImpl = class {
1468
2014
  let response;
1469
2015
  if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
1470
2016
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
2017
+ if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
1471
2018
  return this.addCorsHeaders(response, request);
1472
2019
  }
1473
2020
  if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
1474
2021
  const response = await this.handleClientRegistration(request, env);
1475
2022
  return this.addCorsHeaders(response, request);
1476
2023
  }
1477
- if (this.isApiRequest(url)) {
1478
- const response = await this.handleApiRequest(request, env, ctx);
2024
+ if (apiRoute) {
2025
+ const response = await this.handleApiRequest(request, env, ctx, apiRoute);
1479
2026
  return this.addCorsHeaders(response, request);
1480
2027
  }
2028
+ if (typeof role === "object") return new Response(null, { status: 404 });
1481
2029
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1482
2030
  if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
1483
2031
  return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
@@ -1514,6 +2062,20 @@ var OAuthProviderImpl = class {
1514
2062
  }
1515
2063
  };
1516
2064
  }
2065
+ async validateAccessToken(token, resource, env) {
2066
+ const configuredResource = this.findConfiguredResource(resource);
2067
+ if (!configuredResource) throw new TypeError("resource must name one registered protected resource");
2068
+ const summary = await this.unwrapToken(token, env);
2069
+ if (!summary || !isExactResource(this.resolveStoredTokenAudience(summary.audience), configuredResource)) return null;
2070
+ return {
2071
+ props: summary.grant.props,
2072
+ audience: configuredResource,
2073
+ expiresAt: summary.expiresAt,
2074
+ scope: summary.scope,
2075
+ userId: summary.userId,
2076
+ clientId: summary.grant.clientId
2077
+ };
2078
+ }
1517
2079
  /**
1518
2080
  * Determines if an endpoint configuration is a path or a full URL
1519
2081
  * @param endpoint - The endpoint configuration
@@ -1528,12 +2090,26 @@ var OAuthProviderImpl = class {
1528
2090
  * @param endpoint - The endpoint pattern (full URL or path)
1529
2091
  * @returns True if the URL matches the endpoint pattern
1530
2092
  */
1531
- matchEndpoint(url, endpoint) {
1532
- if (this.isPath(endpoint)) return url.pathname === endpoint;
1533
- else {
1534
- const endpointUrl = new URL(endpoint);
1535
- return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
2093
+ matchEndpoint(url, endpoint, allowAdditionalQuery = false) {
2094
+ if (!this.explicitIssuer) {
2095
+ if (this.isPath(endpoint)) return url.pathname === endpoint;
2096
+ const legacyEndpoint = new URL(endpoint);
2097
+ return url.hostname === legacyEndpoint.hostname && url.pathname === legacyEndpoint.pathname;
2098
+ }
2099
+ const endpointUrl = new URL(this.getFullEndpointUrl(endpoint, url));
2100
+ if (url.origin !== endpointUrl.origin || url.pathname !== endpointUrl.pathname) return false;
2101
+ const unmatchedActualQuery = [...url.searchParams.entries()];
2102
+ const configuredQueryNames = /* @__PURE__ */ new Set();
2103
+ for (const [name, value] of endpointUrl.searchParams) {
2104
+ configuredQueryNames.add(name);
2105
+ const match = unmatchedActualQuery.findIndex(([actualName, actualValue]) => {
2106
+ return actualName === name && actualValue === value;
2107
+ });
2108
+ if (match === -1) return false;
2109
+ unmatchedActualQuery.splice(match, 1);
1536
2110
  }
2111
+ if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
2112
+ return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
1537
2113
  }
1538
2114
  /**
1539
2115
  * Checks if a URL matches the configured token endpoint
@@ -1541,7 +2117,10 @@ var OAuthProviderImpl = class {
1541
2117
  * @returns True if the URL matches the token endpoint
1542
2118
  */
1543
2119
  isTokenEndpoint(url) {
1544
- return this.matchEndpoint(url, this.options.tokenEndpoint);
2120
+ return this.matchEndpoint(url, this.options.tokenEndpoint, true);
2121
+ }
2122
+ isAuthorizationEndpointRequest(url) {
2123
+ return this.matchEndpoint(url, this.options.authorizeEndpoint, true);
1545
2124
  }
1546
2125
  /**
1547
2126
  * Checks if a URL matches the configured client registration endpoint
@@ -1550,28 +2129,37 @@ var OAuthProviderImpl = class {
1550
2129
  */
1551
2130
  isClientRegistrationEndpoint(url) {
1552
2131
  if (!this.options.clientRegistrationEndpoint) return false;
1553
- return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
2132
+ return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
1554
2133
  }
1555
2134
  /**
1556
2135
  * Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
1557
- * Matches both the root well-known path and path-suffixed variants per RFC 9728 §3.1.
2136
+ * Only the well-known URL constructed from the configured canonical resource
2137
+ * may return its document; aliases would violate RFC 9728 §3.3.
1558
2138
  */
1559
- isProtectedResourceMetadataRequest(url) {
1560
- return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
2139
+ findResourceServerForMetadataUrl(url) {
2140
+ return this.resourceServers.find((server) => {
2141
+ const expected = new URL(this.getConfiguredResourceMetadataUrl(server.resourceMetadata.resource));
2142
+ return url.origin === expected.origin && url.pathname === expected.pathname && requestCarriesResourceQuery(url, expected);
2143
+ });
1561
2144
  }
1562
- /**
1563
- * Derives the resource identifier from a protected resource metadata well-known URL.
1564
- * Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
1565
- * so the resource identifier is reconstructed by removing the well-known prefix.
1566
- *
1567
- * Examples:
1568
- * /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
1569
- * /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
1570
- */
1571
- deriveResourceIdentifier(requestUrl) {
1572
- const suffix = requestUrl.pathname.slice(37);
1573
- if (!suffix || suffix === "/") return requestUrl.origin;
1574
- return `${requestUrl.origin}${suffix}`;
2145
+ /** Exact RFC 8414 discovery location for the configured issuer. */
2146
+ getAuthorizationServerMetadataUrl(requestUrl) {
2147
+ if (!this.explicitIssuer) return `${requestUrl.origin}/.well-known/oauth-authorization-server`;
2148
+ const issuer = new URL(this.explicitIssuer);
2149
+ const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
2150
+ return `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`;
2151
+ }
2152
+ isAuthorizationServerMetadataRequest(url) {
2153
+ const expected = new URL(this.getAuthorizationServerMetadataUrl(url));
2154
+ return url.origin === expected.origin && url.pathname === expected.pathname;
2155
+ }
2156
+ /** Whether this instance is the role-based authorization server with a fixed issuer. */
2157
+ get hasExplicitIssuer() {
2158
+ return this.explicitIssuer !== void 0;
2159
+ }
2160
+ /** Whether a URL is in the RFC 9728 protected-resource metadata namespace. */
2161
+ isProtectedResourceMetadataPath(url) {
2162
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
1575
2163
  }
1576
2164
  createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
1577
2165
  return this.createErrorResponse("invalid_client", {
@@ -1588,13 +2176,20 @@ var OAuthProviderImpl = class {
1588
2176
  async parseTokenEndpointRequest(request, env) {
1589
2177
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
1590
2178
  description: "Method not allowed",
1591
- statusCode: 405
2179
+ statusCode: 405,
2180
+ headers: { Allow: "POST, OPTIONS" }
2181
+ }, {
2182
+ category: "token-endpoint-request",
2183
+ reason: "method_not_allowed"
1592
2184
  });
1593
2185
  const contentType = request.headers.get("Content-Type") || "";
1594
2186
  let body = {};
1595
2187
  if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
1596
2188
  description: "Content-Type must be application/x-www-form-urlencoded",
1597
2189
  statusCode: 400
2190
+ }, {
2191
+ category: "token-endpoint-request",
2192
+ reason: "unsupported_content_type"
1598
2193
  });
1599
2194
  let formData;
1600
2195
  try {
@@ -1603,6 +2198,9 @@ var OAuthProviderImpl = class {
1603
2198
  return this.createErrorResponse("invalid_request", {
1604
2199
  description: "Request body must be valid application/x-www-form-urlencoded data",
1605
2200
  statusCode: 400
2201
+ }, {
2202
+ category: "token-endpoint-request",
2203
+ reason: "malformed_request_body"
1606
2204
  });
1607
2205
  }
1608
2206
  const processedKeys = /* @__PURE__ */ new Set();
@@ -1613,6 +2211,10 @@ var OAuthProviderImpl = class {
1613
2211
  if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
1614
2212
  description: `Request parameter "${key}" must not be repeated`,
1615
2213
  statusCode: 400
2214
+ }, {
2215
+ category: "token-endpoint-request",
2216
+ reason: "repeated_parameter",
2217
+ detail: { parameter: key }
1616
2218
  });
1617
2219
  body[key] = allValues.length > 1 ? allValues : value;
1618
2220
  }
@@ -1624,15 +2226,24 @@ var OAuthProviderImpl = class {
1624
2226
  if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
1625
2227
  description: "Client must not use multiple authentication methods",
1626
2228
  statusCode: 400
2229
+ }, {
2230
+ category: "client-authentication",
2231
+ reason: "multiple_authentication_methods"
2232
+ });
2233
+ if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted, {
2234
+ category: "client-authentication",
2235
+ reason: "malformed_basic_credentials"
1627
2236
  });
1628
- if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
1629
2237
  clientId = basicAuthorization.clientId;
1630
2238
  clientSecret = basicAuthorization.clientSecret;
1631
2239
  } else {
1632
2240
  clientId = body.client_id;
1633
2241
  clientSecret = body.client_secret || "";
1634
2242
  }
1635
- if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted);
2243
+ if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted, {
2244
+ category: "client-authentication",
2245
+ reason: "client_id_missing"
2246
+ });
1636
2247
  let clientInfo;
1637
2248
  try {
1638
2249
  clientInfo = await this.getClient(env, clientId);
@@ -1647,7 +2258,10 @@ var OAuthProviderImpl = class {
1647
2258
  }, request);
1648
2259
  throw error;
1649
2260
  }
1650
- if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
2261
+ if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
2262
+ category: "client-authentication",
2263
+ reason: "client_not_found"
2264
+ });
1651
2265
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
1652
2266
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
1653
2267
  if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
@@ -1660,9 +2274,18 @@ var OAuthProviderImpl = class {
1660
2274
  }
1661
2275
  });
1662
2276
  if (presentedAuthMethod !== "none") {
1663
- if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1664
- if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
1665
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted);
2277
+ if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
2278
+ category: "client-authentication",
2279
+ reason: "client_secret_missing"
2280
+ });
2281
+ if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted, {
2282
+ category: "client-authentication",
2283
+ reason: "client_secret_not_registered"
2284
+ });
2285
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted, {
2286
+ category: "client-authentication",
2287
+ reason: "client_secret_mismatch"
2288
+ });
1666
2289
  }
1667
2290
  const isRevocationRequest = !body.grant_type && !!body.token;
1668
2291
  return {
@@ -1678,30 +2301,24 @@ var OAuthProviderImpl = class {
1678
2301
  * @returns True if the URL matches the API route
1679
2302
  */
1680
2303
  matchApiRoute(url, route) {
1681
- if (this.isPath(route)) {
1682
- if (route === "/") return url.pathname === "/";
1683
- return url.pathname.startsWith(route);
1684
- } else {
2304
+ const pathMatches = (configuredPath, rootMatchesDescendants) => {
2305
+ if (configuredPath === "/") return rootMatchesDescendants || url.pathname === "/";
2306
+ const normalized = configuredPath.endsWith("/") ? configuredPath.slice(0, -1) : configuredPath;
2307
+ return url.pathname === normalized || url.pathname.startsWith(normalized + "/");
2308
+ };
2309
+ if (this.isPath(route)) return pathMatches(route, false);
2310
+ else {
1685
2311
  const apiUrl = new URL(route);
1686
- return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
2312
+ return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
1687
2313
  }
1688
2314
  }
1689
2315
  /**
1690
- * Checks if a URL is an API request based on the configured API route(s)
1691
- * @param url - The URL to check
1692
- * @returns True if the URL matches any of the API routes
1693
- */
1694
- isApiRequest(url) {
1695
- for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
1696
- return false;
1697
- }
1698
- /**
1699
2316
  * Finds the appropriate API handler for a URL
1700
2317
  * @param url - The URL to find a handler for
1701
2318
  * @returns The TypedHandler for the URL, or undefined if no handler matches
1702
2319
  */
1703
- findApiHandlerForUrl(url) {
1704
- for (const [route, handler] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return handler;
2320
+ findApiRouteForUrl(url) {
2321
+ return this.typedApiHandlers.filter(({ route }) => this.matchApiRoute(url, route)).sort((left, right) => new URL(right.route, url.origin).pathname.length - new URL(left.route, url.origin).pathname.length)[0];
1705
2322
  }
1706
2323
  /**
1707
2324
  * Gets the full URL for an endpoint, using the provided request URL's
@@ -1711,13 +2328,14 @@ var OAuthProviderImpl = class {
1711
2328
  * @returns The full URL for the endpoint
1712
2329
  */
1713
2330
  getFullEndpointUrl(endpoint, requestUrl) {
1714
- if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
2331
+ if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
1715
2332
  else return endpoint;
1716
2333
  }
1717
2334
  /**
1718
2335
  * Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
1719
2336
  */
1720
2337
  getAuthorizationServerIssuer(requestUrl) {
2338
+ if (this.explicitIssuer) return this.explicitIssuer;
1721
2339
  const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1722
2340
  return new URL(tokenEndpoint).origin;
1723
2341
  }
@@ -1734,6 +2352,7 @@ var OAuthProviderImpl = class {
1734
2352
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
1735
2353
  newResponse.headers.set("Access-Control-Allow-Methods", "*");
1736
2354
  newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
2355
+ appendHeaderValue(newResponse.headers, "Vary", "Origin");
1737
2356
  const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
1738
2357
  for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
1739
2358
  newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
@@ -1755,9 +2374,10 @@ var OAuthProviderImpl = class {
1755
2374
  const grantTypesSupported = this.serverCapabilities.grantTypes;
1756
2375
  const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
1757
2376
  const metadata = {
1758
- issuer: new URL(tokenEndpoint).origin,
2377
+ issuer: this.getAuthorizationServerIssuer(requestUrl),
1759
2378
  authorization_endpoint: authorizeEndpoint,
1760
2379
  token_endpoint: tokenEndpoint,
2380
+ ...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
1761
2381
  registration_endpoint: registrationEndpoint,
1762
2382
  scopes_supported: this.options.scopesSupported,
1763
2383
  response_types_supported: responseTypesSupported,
@@ -1773,8 +2393,8 @@ var OAuthProviderImpl = class {
1773
2393
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1774
2394
  }
1775
2395
  /** Scopes that are baseline requirements of the protected resource itself. */
1776
- getProtectedResourceScopes() {
1777
- return this.normalizeProtectedResourceScopes(this.options.resourceMetadata?.scopes_supported ?? []);
2396
+ getProtectedResourceScopes(resourceServer) {
2397
+ return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
1778
2398
  }
1779
2399
  /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
1780
2400
  normalizeProtectedResourceScopes(scopes) {
@@ -1786,18 +2406,17 @@ var OAuthProviderImpl = class {
1786
2406
  * @param requestUrl - The URL of the incoming request
1787
2407
  * @returns Response with protected resource metadata
1788
2408
  */
1789
- handleProtectedResourceMetadata(requestUrl) {
1790
- const rm = this.options.resourceMetadata;
1791
- const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1792
- const authServerOrigin = new URL(tokenEndpointUrl).origin;
1793
- const resourceScopes = this.getProtectedResourceScopes();
2409
+ handleProtectedResourceMetadata(requestUrl, resourceServer) {
2410
+ const rm = resourceServer.resourceMetadata;
2411
+ const authorizationServer = this.getAuthorizationServerIssuer(requestUrl);
2412
+ const resourceScopes = this.getProtectedResourceScopes(resourceServer);
1794
2413
  const metadata = {
1795
- resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
1796
- authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
2414
+ resource: rm.resource,
2415
+ authorization_servers: rm.authorization_servers ?? [authorizationServer],
1797
2416
  ...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
1798
- bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
2417
+ bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
1799
2418
  };
1800
- if (rm?.resource_name) metadata.resource_name = rm.resource_name;
2419
+ if (rm.resource_name) metadata.resource_name = rm.resource_name;
1801
2420
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1802
2421
  }
1803
2422
  /**
@@ -1811,11 +2430,23 @@ var OAuthProviderImpl = class {
1811
2430
  async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
1812
2431
  try {
1813
2432
  const grantType = body.grant_type;
2433
+ if (!(grantType === GrantType.AUTHORIZATION_CODE || grantType === GrantType.REFRESH_TOKEN || grantType === GrantType.TOKEN_EXCHANGE && !!this.options.allowTokenExchangeGrant || grantType === GrantType.JWT_BEARER && !!this.options.enterpriseManagedAuthorization)) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
2434
+ category: "token-endpoint-request",
2435
+ reason: "grant_type_not_supported"
2436
+ });
2437
+ if (grantType !== GrantType.JWT_BEARER && Array.isArray(clientInfo.grantTypes)) {
2438
+ const registered = clientInfo.grantTypes;
2439
+ if (!(registered.includes(grantType) || grantType === GrantType.REFRESH_TOKEN && registered.includes(GrantType.AUTHORIZATION_CODE))) return this.createErrorResponse("unauthorized_client", { description: "The client is not registered for this grant type" }, {
2440
+ category: "token-endpoint-request",
2441
+ reason: "grant_type_not_registered"
2442
+ });
2443
+ }
2444
+ this.validateTokenRequestResourceIndicator(body.resource);
1814
2445
  if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
1815
2446
  else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
1816
2447
  else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
1817
2448
  else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
1818
- else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
2449
+ throw new Error("Unreachable supported grant type");
1819
2450
  } catch (error) {
1820
2451
  const response = this.createOAuthErrorResponse(error);
1821
2452
  if (response) return response;
@@ -1831,7 +2462,11 @@ var OAuthProviderImpl = class {
1831
2462
  */
1832
2463
  createOAuthErrorResponse(error) {
1833
2464
  if (!(error instanceof OAuthError)) return void 0;
1834
- return this.createErrorResponse(error.code, error.options);
2465
+ return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
2466
+ category: "token-exchange-callback",
2467
+ reason: "callback_error",
2468
+ detail: error
2469
+ });
1835
2470
  }
1836
2471
  /**
1837
2472
  * Build a structured protected-resource response from an external-token error.
@@ -1840,7 +2475,7 @@ var OAuthProviderImpl = class {
1840
2475
  * bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
1841
2476
  * supplied one. Other errors retain the pre-existing behavior and are re-thrown.
1842
2477
  */
1843
- createExternalTokenErrorResponse(error, resourceMetadataUrl) {
2478
+ createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
1844
2479
  if (!(error instanceof ExternalTokenError)) return void 0;
1845
2480
  const headers = error.headers ?? {};
1846
2481
  const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
@@ -1851,13 +2486,17 @@ var OAuthProviderImpl = class {
1851
2486
  if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
1852
2487
  challengeHeaders = {
1853
2488
  ...headers,
1854
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
2489
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
1855
2490
  };
1856
2491
  }
1857
2492
  return this.createErrorResponse(error.code, {
1858
2493
  description: error.description,
1859
2494
  statusCode: error.statusCode,
1860
2495
  headers: challengeHeaders ?? headers
2496
+ }, {
2497
+ category: "protected-resource",
2498
+ reason: "resolver_rejected",
2499
+ detail: error
1861
2500
  });
1862
2501
  }
1863
2502
  /**
@@ -1872,34 +2511,72 @@ var OAuthProviderImpl = class {
1872
2511
  const code = body.code;
1873
2512
  const redirectUri = body.redirect_uri;
1874
2513
  const codeVerifier = body.code_verifier;
1875
- if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
2514
+ if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }, {
2515
+ category: "authorization-code-grant",
2516
+ reason: "code_missing"
2517
+ });
1876
2518
  const codeParts = code.split(":");
1877
- if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
2519
+ if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }, {
2520
+ category: "authorization-code-grant",
2521
+ reason: "code_malformed"
2522
+ });
1878
2523
  const [userId, grantId, _] = codeParts;
1879
2524
  const grantKey = `grant:${userId}:${grantId}`;
1880
2525
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1881
- if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
2526
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" }, {
2527
+ category: "authorization-code-grant",
2528
+ reason: "grant_not_found"
2529
+ });
1882
2530
  const codeHash = await hashSecret(code);
1883
- if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1884
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
2531
+ if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }, {
2532
+ category: "authorization-code-grant",
2533
+ reason: "code_mismatch"
2534
+ });
2535
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
2536
+ category: "authorization-code-grant",
2537
+ reason: "client_mismatch"
2538
+ });
1885
2539
  if (!grantData.authCodeWrappedKey) {
1886
2540
  try {
1887
2541
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1888
2542
  } catch {}
1889
- return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
2543
+ return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }, {
2544
+ category: "authorization-code-grant",
2545
+ reason: "code_replayed"
2546
+ });
1890
2547
  }
1891
2548
  let codeChallengeMethod;
1892
2549
  try {
1893
2550
  codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
1894
2551
  } catch (error) {
1895
- return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" });
2552
+ return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
2553
+ category: "authorization-code-grant",
2554
+ reason: "pkce_method_invalid",
2555
+ detail: error
2556
+ });
1896
2557
  }
1897
2558
  const isPkceEnabled = !!grantData.codeChallenge;
1898
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1899
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
1900
- if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
2559
+ if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }, {
2560
+ category: "authorization-code-grant",
2561
+ reason: "redirect_uri_missing"
2562
+ });
2563
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" }, {
2564
+ category: "authorization-code-grant",
2565
+ reason: "redirect_uri_invalid"
2566
+ });
2567
+ if (redirectUri && grantData.redirectUri !== void 0 && redirectUri !== grantData.redirectUri) return this.createErrorResponse("invalid_grant", { description: "redirect_uri does not match the authorization request" }, {
2568
+ category: "authorization-code-grant",
2569
+ reason: "redirect_uri_mismatch"
2570
+ });
2571
+ if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" }, {
2572
+ category: "authorization-code-grant",
2573
+ reason: "unexpected_code_verifier"
2574
+ });
1901
2575
  if (isPkceEnabled) {
1902
- if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
2576
+ if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }, {
2577
+ category: "authorization-code-grant",
2578
+ reason: "code_verifier_missing"
2579
+ });
1903
2580
  let calculatedChallenge;
1904
2581
  if (codeChallengeMethod === "S256") {
1905
2582
  const data = new TextEncoder().encode(codeVerifier);
@@ -1907,9 +2584,13 @@ var OAuthProviderImpl = class {
1907
2584
  const hashArray = Array.from(new Uint8Array(hashBuffer));
1908
2585
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
1909
2586
  } else calculatedChallenge = codeVerifier;
1910
- if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
2587
+ if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
2588
+ category: "authorization-code-grant",
2589
+ reason: "code_verifier_mismatch"
2590
+ });
1911
2591
  }
1912
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
2592
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
2593
+ const audience = resourceResolution.audience;
1913
2594
  let accessTokenTTL = this.options.accessTokenTTL;
1914
2595
  let refreshTokenTTL = this.options.refreshTokenTTL;
1915
2596
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
@@ -1924,10 +2605,12 @@ var OAuthProviderImpl = class {
1924
2605
  const callbackOptions = {
1925
2606
  grantType: GrantType.AUTHORIZATION_CODE,
1926
2607
  clientId: clientInfo.clientId,
2608
+ subjectClientId: grantData.clientId,
1927
2609
  userId,
1928
2610
  grantId,
1929
2611
  scope: grantData.scope,
1930
2612
  requestedScope: tokenScopes,
2613
+ resource: audience,
1931
2614
  props: decryptedProps
1932
2615
  };
1933
2616
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -1939,6 +2622,10 @@ var OAuthProviderImpl = class {
1939
2622
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
1940
2623
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
1941
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
+ });
1942
2629
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1943
2630
  }
1944
2631
  const grantResult = await encryptProps(grantProps);
@@ -1953,6 +2640,10 @@ var OAuthProviderImpl = class {
1953
2640
  accessTokenEncryptionKey = grantEncryptionKey;
1954
2641
  }
1955
2642
  }
2643
+ if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
2644
+ category: "authorization-code-grant",
2645
+ reason: "requested_ttl_too_short"
2646
+ });
1956
2647
  const now = Math.floor(Date.now() / 1e3);
1957
2648
  const useRefreshToken = refreshTokenTTL !== 0;
1958
2649
  delete grantData.codeChallenge;
@@ -1970,6 +2661,7 @@ var OAuthProviderImpl = class {
1970
2661
  grantData.previousRefreshTokenWrappedKey = void 0;
1971
2662
  grantData.expiresAt = expiresAt;
1972
2663
  }
2664
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
1973
2665
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1974
2666
  const tokenResponse = {
1975
2667
  access_token: await this.createAccessToken({
@@ -1985,10 +2677,10 @@ var OAuthProviderImpl = class {
1985
2677
  }),
1986
2678
  token_type: "bearer",
1987
2679
  expires_in: accessTokenTTL,
1988
- scope: tokenScopes.join(" ")
2680
+ scope: tokenScopes.join(" "),
2681
+ resource: audience
1989
2682
  };
1990
2683
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
1991
- if (audience) tokenResponse.resource = audience;
1992
2684
  return new Response(JSON.stringify(tokenResponse), { headers: {
1993
2685
  "Content-Type": "application/json",
1994
2686
  ...NO_CACHE_HEADERS
@@ -2004,26 +2696,46 @@ var OAuthProviderImpl = class {
2004
2696
  */
2005
2697
  async handleRefreshTokenGrant(body, clientInfo, env) {
2006
2698
  const refreshToken = body.refresh_token;
2007
- if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
2699
+ if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }, {
2700
+ category: "refresh-token-grant",
2701
+ reason: "refresh_token_missing"
2702
+ });
2008
2703
  const tokenParts = refreshToken.split(":");
2009
- if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
2704
+ if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }, {
2705
+ category: "refresh-token-grant",
2706
+ reason: "refresh_token_malformed"
2707
+ });
2010
2708
  const [userId, grantId, _] = tokenParts;
2011
2709
  const providedTokenHash = await generateTokenId(refreshToken);
2012
2710
  const grantKey = `grant:${userId}:${grantId}`;
2013
2711
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2014
- if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
2712
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }, {
2713
+ category: "refresh-token-grant",
2714
+ reason: "grant_not_found"
2715
+ });
2015
2716
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
2016
2717
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
2017
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
2018
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
2718
+ if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }, {
2719
+ category: "refresh-token-grant",
2720
+ reason: "refresh_token_mismatch"
2721
+ });
2722
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
2723
+ category: "refresh-token-grant",
2724
+ reason: "client_mismatch"
2725
+ });
2019
2726
  if (grantData.expiresAt !== void 0) {
2020
2727
  const now$1 = Math.floor(Date.now() / 1e3);
2021
- if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
2728
+ if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
2729
+ category: "refresh-token-grant",
2730
+ reason: "refresh_token_expired"
2731
+ });
2022
2732
  }
2023
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
2733
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
2734
+ const audience = resourceResolution.audience;
2024
2735
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2025
2736
  const accessTokenId = await generateTokenId(newAccessToken);
2026
2737
  let accessTokenTTL = this.options.accessTokenTTL;
2738
+ let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
2027
2739
  let wrappedKeyToUse;
2028
2740
  if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
2029
2741
  else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
@@ -2040,10 +2752,12 @@ var OAuthProviderImpl = class {
2040
2752
  const callbackOptions = {
2041
2753
  grantType: GrantType.REFRESH_TOKEN,
2042
2754
  clientId: clientInfo.clientId,
2755
+ subjectClientId: grantData.clientId,
2043
2756
  userId,
2044
2757
  grantId,
2045
2758
  scope: grantData.scope,
2046
2759
  requestedScope: tokenScopes,
2760
+ resource: audience,
2047
2761
  props: decryptedProps
2048
2762
  };
2049
2763
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -2055,7 +2769,17 @@ var OAuthProviderImpl = class {
2055
2769
  }
2056
2770
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2057
2771
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
2058
- if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
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
+ if (callbackResult.refreshTokenIdleTTL !== void 0) {
2777
+ 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
+ category: "refresh-token-grant",
2779
+ reason: "refresh_token_idle_ttl_invalid"
2780
+ });
2781
+ refreshTokenIdleTTL = callbackResult.refreshTokenIdleTTL;
2782
+ }
2059
2783
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
2060
2784
  }
2061
2785
  if (grantPropsChanged) {
@@ -2076,12 +2800,19 @@ var OAuthProviderImpl = class {
2076
2800
  }
2077
2801
  }
2078
2802
  const now = Math.floor(Date.now() / 1e3);
2079
- if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
2803
+ if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
2804
+ category: "refresh-token-grant",
2805
+ reason: "refresh_token_expired"
2806
+ });
2807
+ if (refreshTokenIdleTTL !== void 0) grantData.expiresAt = now + refreshTokenIdleTTL;
2080
2808
  if (grantData.expiresAt !== void 0) {
2081
2809
  const remainingRefreshTokenLifetime = grantData.expiresAt - now;
2082
2810
  if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
2083
2811
  }
2084
- if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
2812
+ if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
2813
+ category: "refresh-token-grant",
2814
+ reason: "requested_ttl_too_short"
2815
+ });
2085
2816
  const accessTokenExpiresAt = now + accessTokenTTL;
2086
2817
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
2087
2818
  const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
@@ -2091,6 +2822,7 @@ var OAuthProviderImpl = class {
2091
2822
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
2092
2823
  grantData.refreshTokenId = newRefreshTokenId;
2093
2824
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
2825
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
2094
2826
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
2095
2827
  const accessTokenData = {
2096
2828
  id: accessTokenId,
@@ -2118,9 +2850,9 @@ var OAuthProviderImpl = class {
2118
2850
  token_type: "bearer",
2119
2851
  expires_in: accessTokenTTL,
2120
2852
  refresh_token: newRefreshToken,
2121
- scope: tokenScopes.join(" ")
2853
+ scope: tokenScopes.join(" "),
2854
+ resource: audience
2122
2855
  };
2123
- if (audience) tokenResponse.resource = audience;
2124
2856
  return new Response(JSON.stringify(tokenResponse), { headers: {
2125
2857
  "Content-Type": "application/json",
2126
2858
  ...NO_CACHE_HEADERS
@@ -2134,7 +2866,7 @@ var OAuthProviderImpl = class {
2134
2866
  * module-private.
2135
2867
  * @param subjectToken - The subject token to exchange
2136
2868
  * @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
2137
- * @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
2869
+ * @param requestedResource - Optional resource/audience; when present, it must match the configured canonical resource
2138
2870
  * @param expiresIn - Optional TTL override in seconds
2139
2871
  * @param clientInfo - The client making the exchange request
2140
2872
  * @param env - Cloudflare Worker environment variables
@@ -2143,24 +2875,61 @@ var OAuthProviderImpl = class {
2143
2875
  */
2144
2876
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
2145
2877
  const tokenSummary = await this.unwrapToken(subjectToken, env);
2146
- if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
2878
+ if (!tokenSummary) throw new OAuthError("invalid_request", {
2879
+ description: "Invalid or expired subject token",
2880
+ internal: {
2881
+ category: "token-exchange-grant",
2882
+ reason: "subject_token_invalid"
2883
+ }
2884
+ });
2147
2885
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
2148
2886
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2149
- if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
2887
+ if (!grantData) throw new OAuthError("invalid_request", {
2888
+ description: "Grant not found",
2889
+ internal: {
2890
+ category: "token-exchange-grant",
2891
+ reason: "grant_not_found"
2892
+ }
2893
+ });
2894
+ const crossClientExchange = grantData.clientId !== clientInfo.clientId;
2895
+ const crossClientRejection = () => new OAuthError("invalid_request", {
2896
+ description: "The subject token was issued to a different client",
2897
+ internal: {
2898
+ category: "token-exchange-grant",
2899
+ reason: "cross_client_subject_token"
2900
+ }
2901
+ });
2902
+ if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
2150
2903
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
2151
- const configuredResource = this.options.resourceMetadata?.resource;
2152
- if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
2153
- const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
2904
+ const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
2154
2905
  const now = Math.floor(Date.now() / 1e3);
2155
2906
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
2156
- if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
2907
+ if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", {
2908
+ description: "Subject token is too close to expiry to exchange",
2909
+ internal: {
2910
+ category: "token-exchange-grant",
2911
+ reason: "subject_token_near_expiry"
2912
+ }
2913
+ });
2157
2914
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
2158
2915
  if (expiresIn !== void 0) {
2159
- if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
2916
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", {
2917
+ description: "Invalid expires_in parameter",
2918
+ internal: {
2919
+ category: "token-exchange-grant",
2920
+ reason: "expires_in_invalid"
2921
+ }
2922
+ });
2160
2923
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
2161
2924
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
2162
2925
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
2163
- if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
2926
+ if (!subjectTokenData) throw new OAuthError("invalid_request", {
2927
+ description: "Subject token data not found",
2928
+ internal: {
2929
+ category: "token-exchange-grant",
2930
+ reason: "subject_token_data_missing"
2931
+ }
2932
+ });
2164
2933
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
2165
2934
  let accessTokenEncryptionKey = encryptionKey;
2166
2935
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -2169,13 +2938,16 @@ var OAuthProviderImpl = class {
2169
2938
  const callbackOptions = {
2170
2939
  grantType: GrantType.TOKEN_EXCHANGE,
2171
2940
  clientId: clientInfo.clientId,
2941
+ subjectClientId: grantData.clientId,
2172
2942
  userId: tokenSummary.userId,
2173
2943
  grantId: tokenSummary.grantId,
2174
2944
  scope: tokenSummary.grant.scope,
2175
2945
  requestedScope: tokenScopes,
2946
+ resource: newAudience,
2176
2947
  props: decryptedProps
2177
2948
  };
2178
2949
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
2950
+ if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
2179
2951
  if (callbackResult) {
2180
2952
  let accessTokenProps = decryptedProps;
2181
2953
  if (callbackResult.newProps) {
@@ -2183,6 +2955,13 @@ var OAuthProviderImpl = class {
2183
2955
  }
2184
2956
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2185
2957
  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
+ });
2186
2965
  if (accessTokenProps !== decryptedProps) {
2187
2966
  const tokenResult = await encryptProps(accessTokenProps);
2188
2967
  encryptedAccessTokenProps = tokenResult.encryptedData;
@@ -2191,8 +2970,14 @@ var OAuthProviderImpl = class {
2191
2970
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
2192
2971
  }
2193
2972
  }
2194
- if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
2195
- const tokenResponse = {
2973
+ if (!isValidAccessTokenTTL(accessTokenTTL)) throw new OAuthError("invalid_request", {
2974
+ description: "Requested token lifetime must be at least 60 seconds",
2975
+ internal: {
2976
+ category: "token-exchange-grant",
2977
+ reason: "requested_ttl_too_short"
2978
+ }
2979
+ });
2980
+ return {
2196
2981
  access_token: await this.createAccessToken({
2197
2982
  userId: tokenSummary.userId,
2198
2983
  grantId: tokenSummary.grantId,
@@ -2207,10 +2992,9 @@ var OAuthProviderImpl = class {
2207
2992
  issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
2208
2993
  token_type: "bearer",
2209
2994
  expires_in: accessTokenTTL,
2210
- scope: tokenScopes.join(" ")
2995
+ scope: tokenScopes.join(" "),
2996
+ resource: newAudience
2211
2997
  };
2212
- if (newAudience) tokenResponse.resource = newAudience;
2213
- return tokenResponse;
2214
2998
  }
2215
2999
  /**
2216
3000
  * Handles OAuth 2.0 token exchange requests (RFC 8693)
@@ -2226,18 +3010,36 @@ var OAuthProviderImpl = class {
2226
3010
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
2227
3011
  const requestedScope = body.scope;
2228
3012
  const requestedResource = body.resource;
2229
- if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
2230
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
2231
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
2232
- if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
3013
+ if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }, {
3014
+ category: "token-exchange-grant",
3015
+ reason: "subject_token_missing"
3016
+ });
3017
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" }, {
3018
+ category: "token-exchange-grant",
3019
+ reason: "subject_token_type_missing"
3020
+ });
3021
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" }, {
3022
+ category: "token-exchange-grant",
3023
+ reason: "subject_token_type_unsupported"
3024
+ });
3025
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" }, {
3026
+ category: "token-exchange-grant",
3027
+ reason: "requested_token_type_unsupported"
3028
+ });
2233
3029
  let requestedScopes;
2234
3030
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
2235
3031
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
2236
- else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
3032
+ else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }, {
3033
+ category: "token-exchange-grant",
3034
+ reason: "scope_malformed"
3035
+ });
2237
3036
  let expiresIn;
2238
3037
  if (body.expires_in !== void 0) {
2239
3038
  const requestedTTL = parseInt(body.expires_in, 10);
2240
- if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
3039
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }, {
3040
+ category: "token-exchange-grant",
3041
+ reason: "expires_in_invalid"
3042
+ });
2241
3043
  expiresIn = requestedTTL;
2242
3044
  }
2243
3045
  try {
@@ -2262,10 +3064,16 @@ var OAuthProviderImpl = class {
2262
3064
  */
2263
3065
  async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
2264
3066
  const enterpriseOptions = this.options.enterpriseManagedAuthorization;
2265
- if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
3067
+ if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
3068
+ category: "enterprise-managed-authorization",
3069
+ reason: "grant_disabled"
3070
+ });
2266
3071
  if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
2267
3072
  description: "Enterprise-managed authorization requires client authentication",
2268
3073
  statusCode: 401
3074
+ }, {
3075
+ category: "enterprise-managed-authorization",
3076
+ reason: "client_authentication_required"
2269
3077
  });
2270
3078
  const result = await this.runEmaPipeline({
2271
3079
  body,
@@ -2300,8 +3108,8 @@ var OAuthProviderImpl = class {
2300
3108
  async runEmaPipeline(args) {
2301
3109
  const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
2302
3110
  const { jwksProvider, jtiStore } = this;
2303
- const configuredResource = this.options.resourceMetadata?.resource;
2304
- if (!jwksProvider || !jtiStore || !configuredResource) throw new Error("EMA pipeline invoked without configured adapters");
3111
+ const configuredResource = this.resolveNewTokenResource(body.resource);
3112
+ if (!jwksProvider || !jtiStore) throw new Error("EMA pipeline invoked without configured adapters");
2305
3113
  const now = Math.floor(Date.now() / 1e3);
2306
3114
  const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
2307
3115
  if (!parsed.ok) return parsed;
@@ -2331,7 +3139,6 @@ var OAuthProviderImpl = class {
2331
3139
  expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
2332
3140
  clientId: clientInfo.clientId,
2333
3141
  configuredResource,
2334
- matchOriginOnly: !!this.options.resourceMatchOriginOnly,
2335
3142
  now,
2336
3143
  clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
2337
3144
  maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
@@ -2353,7 +3160,7 @@ var OAuthProviderImpl = class {
2353
3160
  mapperOutput = await enterpriseOptions.mapClaims({
2354
3161
  claims: claims.value.claims,
2355
3162
  clientInfo,
2356
- resource: claims.value.resource,
3163
+ resource: configuredResource,
2357
3164
  requestedScope: requestedScope.value,
2358
3165
  request: args.request,
2359
3166
  env
@@ -2379,7 +3186,7 @@ var OAuthProviderImpl = class {
2379
3186
  mapperProps: mapped.value.props,
2380
3187
  mapperMetadata: mapped.value.metadata,
2381
3188
  assertionScopes: claims.value.assertionScopes,
2382
- resource: claims.value.resource,
3189
+ resource: configuredResource,
2383
3190
  accessTokenTTLSeconds: ttl.value,
2384
3191
  env,
2385
3192
  now: issueNow
@@ -2478,7 +3285,10 @@ var OAuthProviderImpl = class {
2478
3285
  async revokeToken(body, clientInfo, env) {
2479
3286
  const token = body.token;
2480
3287
  const tokenTypeHint = body.token_type_hint;
2481
- if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
3288
+ if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }, {
3289
+ category: "token-revocation",
3290
+ reason: "token_missing"
3291
+ });
2482
3292
  const tokenParts = token.split(":");
2483
3293
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
2484
3294
  const [userId, grantId, _] = tokenParts;
@@ -2533,14 +3343,24 @@ var OAuthProviderImpl = class {
2533
3343
  if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
2534
3344
  description: "Client registration is not enabled",
2535
3345
  statusCode: 501
3346
+ }, {
3347
+ category: "client-registration",
3348
+ reason: "registration_disabled"
2536
3349
  });
2537
3350
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
2538
3351
  description: "Method not allowed",
2539
- statusCode: 405
3352
+ statusCode: 405,
3353
+ headers: { Allow: "POST, OPTIONS" }
3354
+ }, {
3355
+ category: "client-registration",
3356
+ reason: "method_not_allowed"
2540
3357
  });
2541
3358
  if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
2542
3359
  description: "Request payload too large, must be under 1 MiB",
2543
3360
  statusCode: 413
3361
+ }, {
3362
+ category: "client-registration",
3363
+ reason: "declared_payload_too_large"
2544
3364
  });
2545
3365
  const callbackRequest = request.clone();
2546
3366
  let parsedJson;
@@ -2549,12 +3369,18 @@ var OAuthProviderImpl = class {
2549
3369
  if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
2550
3370
  description: "Request payload too large, must be under 1 MiB",
2551
3371
  statusCode: 413
3372
+ }, {
3373
+ category: "client-registration",
3374
+ reason: "payload_too_large"
2552
3375
  });
2553
3376
  parsedJson = JSON.parse(text);
2554
3377
  } catch {
2555
3378
  return this.createErrorResponse("invalid_request", {
2556
3379
  description: "Invalid JSON payload",
2557
3380
  statusCode: 400
3381
+ }, {
3382
+ category: "client-registration",
3383
+ reason: "json_malformed"
2558
3384
  });
2559
3385
  }
2560
3386
  let clientMetadata;
@@ -2563,11 +3389,18 @@ var OAuthProviderImpl = class {
2563
3389
  clientMetadata = requireJsonObject(parsedJson);
2564
3390
  metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
2565
3391
  } catch (error) {
2566
- return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
3392
+ return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
3393
+ category: "client-registration",
3394
+ reason: "metadata_invalid",
3395
+ detail: error
3396
+ });
2567
3397
  }
2568
3398
  const authMethod = metadata.tokenEndpointAuthMethod;
2569
3399
  const isPublicClient = authMethod === "none";
2570
- if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
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
+ });
2571
3404
  const clientId = generateRandomString(16);
2572
3405
  let clientSecret;
2573
3406
  let hashedSecret;
@@ -2604,16 +3437,22 @@ var OAuthProviderImpl = class {
2604
3437
  return this.createErrorResponse("server_error", {
2605
3438
  description: error instanceof Error ? error.message : "Client registration callback failed",
2606
3439
  statusCode: 500
3440
+ }, {
3441
+ category: "client-registration",
3442
+ reason: "callback_failed",
3443
+ detail: error
2607
3444
  });
2608
3445
  }
2609
3446
  if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
2610
3447
  description: callbackResult.description || "Client registration denied",
2611
3448
  statusCode: callbackResult.status ?? 400
3449
+ }, {
3450
+ category: "client-registration",
3451
+ reason: "callback_denied",
3452
+ detail: callbackResult
2612
3453
  });
2613
3454
  }
2614
- const clientKvOptions = {};
2615
- if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
2616
- await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
3455
+ await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
2617
3456
  const response = {
2618
3457
  client_id: clientInfo.clientId,
2619
3458
  redirect_uris: clientInfo.redirectUris,
@@ -2652,18 +3491,23 @@ var OAuthProviderImpl = class {
2652
3491
  * @param ctx - Cloudflare Worker execution context
2653
3492
  * @returns Response from the API handler or error
2654
3493
  */
2655
- async handleApiRequest(request, env, ctx) {
3494
+ async handleApiRequest(request, env, ctx, apiRoute) {
2656
3495
  const url = new URL(request.url);
2657
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
3496
+ const { resourceServer } = apiRoute;
3497
+ const configuredResource = resourceServer.resourceMetadata.resource;
3498
+ const externalTokenResolver = resourceServer.resolveExternalToken;
3499
+ const resourceMetadataUrl = this.getResourceMetadataUrlForRequest(url, resourceServer);
3500
+ const challenge = (error, description, scopes = []) => this.buildWwwAuthenticateHeader(resourceMetadataUrl, error, description, scopes, resourceServer);
2658
3501
  const authHeader = request.headers.get("Authorization");
2659
- if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
3502
+ const bearerMatch = authHeader ? /^Bearer[\t ]+([^\s,]+)$/i.exec(authHeader) : null;
3503
+ if (!bearerMatch) return new Response(null, {
2660
3504
  status: 401,
2661
3505
  headers: {
2662
3506
  ...NO_CACHE_HEADERS,
2663
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
3507
+ "WWW-Authenticate": challenge()
2664
3508
  }
2665
3509
  });
2666
- const accessToken = authHeader.substring(7);
3510
+ const accessToken = bearerMatch[1];
2667
3511
  const parts = accessToken.split(":");
2668
3512
  const isPossiblyInternalFormat = parts.length === 3;
2669
3513
  let tokenData = null;
@@ -2674,75 +3518,104 @@ var OAuthProviderImpl = class {
2674
3518
  const id = await generateTokenId(accessToken);
2675
3519
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
2676
3520
  }
2677
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
3521
+ if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
2678
3522
  description: "Invalid access token",
2679
3523
  statusCode: 401,
2680
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3524
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3525
+ }, {
3526
+ category: "protected-resource",
3527
+ reason: "token_not_found"
2681
3528
  });
2682
3529
  if (tokenData) {
2683
- const configuredResource = this.options.resourceMetadata?.resource;
2684
- if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
3530
+ const tokenAudience = this.resolveStoredTokenAudience(tokenData.audience);
3531
+ if (!isExactResource(tokenAudience, configuredResource)) return this.createErrorResponse("invalid_token", {
2685
3532
  description: "Access token is not bound to the configured resource",
2686
3533
  statusCode: 401,
2687
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3534
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3535
+ }, {
3536
+ category: "protected-resource",
3537
+ reason: "token_audience_unbound"
2688
3538
  });
2689
3539
  const now = Math.floor(Date.now() / 1e3);
2690
3540
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2691
3541
  description: "Access token expired",
2692
3542
  statusCode: 401,
2693
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3543
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3544
+ }, {
3545
+ category: "protected-resource",
3546
+ reason: "token_expired"
2694
3547
  });
2695
- if (tokenData.audience) {
3548
+ if (tokenAudience) {
2696
3549
  const requestUrl = new URL(request.url);
2697
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2698
- if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
3550
+ const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
3551
+ if (!(Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
2699
3552
  description: "Token audience does not match resource server",
2700
3553
  statusCode: 401,
2701
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
3554
+ headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
3555
+ }, {
3556
+ category: "protected-resource",
3557
+ reason: "audience_mismatch"
2702
3558
  });
2703
3559
  }
2704
3560
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2705
- } else if (this.options.resolveExternalToken) {
3561
+ ctx.auth = {
3562
+ token: accessToken,
3563
+ audience: configuredResource,
3564
+ expiresAt: tokenData.expiresAt,
3565
+ scope: [...tokenData.scope || tokenData.grant.scope],
3566
+ userId: tokenData.userId,
3567
+ clientId: tokenData.grant.clientId
3568
+ };
3569
+ } else if (externalTokenResolver) {
2706
3570
  let ext;
2707
3571
  try {
2708
- ext = await this.options.resolveExternalToken({
3572
+ ext = await externalTokenResolver({
2709
3573
  token: accessToken,
2710
3574
  request,
2711
3575
  env
2712
3576
  });
2713
3577
  } catch (error) {
2714
- const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
3578
+ const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
2715
3579
  if (response) return response;
2716
3580
  throw error;
2717
3581
  }
2718
3582
  if (!ext) return this.createErrorResponse("invalid_token", {
2719
3583
  description: "Invalid access token",
2720
3584
  statusCode: 401,
2721
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3585
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3586
+ }, {
3587
+ category: "protected-resource",
3588
+ reason: "external_token_rejected"
2722
3589
  });
2723
- const configuredResource = this.options.resourceMetadata?.resource;
2724
- if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
3590
+ if (typeof ext.audience !== "string" || !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2725
3591
  description: "External access token is not bound to the configured resource",
2726
3592
  statusCode: 401,
2727
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3593
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3594
+ }, {
3595
+ category: "protected-resource",
3596
+ reason: "external_audience_unbound"
2728
3597
  });
2729
3598
  if (ext.audience) {
2730
3599
  const requestUrl = new URL(request.url);
2731
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2732
- if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
3600
+ const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
3601
+ if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
2733
3602
  description: "Token audience does not match resource server",
2734
3603
  statusCode: 401,
2735
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
3604
+ headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
3605
+ }, {
3606
+ category: "protected-resource",
3607
+ reason: "external_audience_mismatch"
2736
3608
  });
2737
3609
  }
2738
3610
  ctx.props = ext.props;
3611
+ ctx.auth = {
3612
+ token: accessToken,
3613
+ audience: configuredResource,
3614
+ scope: []
3615
+ };
2739
3616
  }
2740
3617
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
2741
- const apiHandler = this.findApiHandlerForUrl(url);
2742
- if (!apiHandler) return this.createErrorResponse("invalid_request", {
2743
- description: "No handler found for API route",
2744
- statusCode: 404
2745
- });
3618
+ const apiHandler = apiRoute.handler;
2746
3619
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
2747
3620
  else return new apiHandler.handler(ctx, env).fetch(request);
2748
3621
  }
@@ -2766,7 +3639,7 @@ var OAuthProviderImpl = class {
2766
3639
  const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
2767
3640
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
2768
3641
  try {
2769
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
3642
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
2770
3643
  } catch (error) {
2771
3644
  this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
2772
3645
  throw error;
@@ -2777,7 +3650,12 @@ var OAuthProviderImpl = class {
2777
3650
  throw new OAuthError("temporarily_unavailable", {
2778
3651
  description: "Token issuance is temporarily unavailable; retry shortly",
2779
3652
  statusCode: 429,
2780
- headers: { "Retry-After": "30" }
3653
+ headers: { "Retry-After": "30" },
3654
+ internal: {
3655
+ category: "token-issuance",
3656
+ reason: "kv_rate_limited",
3657
+ detail: error
3658
+ }
2781
3659
  });
2782
3660
  }
2783
3661
  isKvRateLimitError(error) {
@@ -2800,6 +3678,51 @@ var OAuthProviderImpl = class {
2800
3678
  * propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
2801
3679
  * upstream outage is distinguishable from an unregistered client.
2802
3680
  */
3681
+ /**
3682
+ * Write a client record that is subject to `clientRegistrationTTL`. The record carries
3683
+ * the expiry it was written with, so a later token request can tell a registration the
3684
+ * provider may renew from a permanent one without listing KV. With the TTL unset the
3685
+ * record is written to last, and any stamp from an earlier configuration is dropped.
3686
+ */
3687
+ async putRegisteredClient(env, client, now) {
3688
+ const ttl = this.options.clientRegistrationTTL;
3689
+ const { registrationExpiresAt: _previous, ...record } = client;
3690
+ if (ttl === void 0) {
3691
+ await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify(record));
3692
+ return;
3693
+ }
3694
+ await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify({
3695
+ ...record,
3696
+ registrationExpiresAt: now + ttl
3697
+ }), { expirationTtl: ttl });
3698
+ }
3699
+ /**
3700
+ * Renew a dynamically registered client whose registration has passed half of
3701
+ * `clientRegistrationTTL`, so a client still exchanging tokens does not vanish from
3702
+ * under its live grants. Without this, a grant that outlives its registration fails
3703
+ * every refresh with `invalid_client` although nothing was revoked. Only records
3704
+ * stamped with `registrationExpiresAt` qualify: CIMD clients are never stored,
3705
+ * `createClient()` records never expire, and a record from before the stamp existed
3706
+ * cannot be told from a permanent one, so it keeps its original schedule and
3707
+ * re-registers once. The half-life rule bounds this to one write per client per half
3708
+ * TTL. Best effort: the token response it follows has already been produced.
3709
+ *
3710
+ * KV has no compare-and-set, so a `deleteClient()` that lands between the read below and
3711
+ * the write is undone by it, as it would be by a concurrent `updateClient()`. The window
3712
+ * is one KV round trip, once per client per half TTL; closing it needs the serialised
3713
+ * storage of #237.
3714
+ */
3715
+ async renewClientRegistrationIfDue(env, client, now) {
3716
+ const ttl = this.options.clientRegistrationTTL;
3717
+ if (ttl === void 0 || client.registrationExpiresAt === void 0) return;
3718
+ if (client.registrationExpiresAt - now > ttl / 2) return;
3719
+ try {
3720
+ const current = await env.OAUTH_KV.get(`client:${client.clientId}`, { type: "json" });
3721
+ if (current) await this.putRegisteredClient(env, current, now);
3722
+ } catch (error) {
3723
+ console.warn(`Failed to renew client registration ${client.clientId}:`, error);
3724
+ }
3725
+ }
2803
3726
  async getClient(env, clientId) {
2804
3727
  if (this.isClientMetadataUrl(clientId)) {
2805
3728
  if (!this.options.clientIdMetadataDocumentEnabled) {
@@ -2817,33 +3740,140 @@ var OAuthProviderImpl = class {
2817
3740
  const clientKey = `client:${clientId}`;
2818
3741
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2819
3742
  }
3743
+ /** Resolve a value to the registry's canonical spelling, requiring one value. */
3744
+ /**
3745
+ * Resolve a stored grant or token resource. A 0.x record may hold an array; it resolves
3746
+ * when exactly one configured resource appears in it, so multi-audience grants for two
3747
+ * resources this server still hosts are ambiguous and fail closed.
3748
+ */
3749
+ findStoredConfiguredResource(value) {
3750
+ if (!Array.isArray(value)) return this.findConfiguredResource(value);
3751
+ const matches = /* @__PURE__ */ new Set();
3752
+ for (const entry of value) {
3753
+ const configured = this.findConfiguredResource(entry);
3754
+ if (configured) matches.add(configured);
3755
+ }
3756
+ return matches.size === 1 ? [...matches][0] : void 0;
3757
+ }
3758
+ findConfiguredResource(value) {
3759
+ const distinct = Array.isArray(value) ? [...new Set(value)] : [value];
3760
+ const singular = distinct.length === 1 ? distinct[0] : void 0;
3761
+ if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
3762
+ return this.resourceServers.map((server) => server.resourceMetadata.resource).find((configured) => isExactResource(singular, configured));
3763
+ }
3764
+ /** Select the audience for a new interactive authorization. */
3765
+ resolveAuthorizationRequestResource(requestedResource) {
3766
+ if (requestedResource === void 0) {
3767
+ const defaultResource = this.getDefaultAuthorizationResource();
3768
+ if (defaultResource) return defaultResource;
3769
+ throw new AuthorizationError("invalid_target", { description: "The resource parameter is required when the authorization server has multiple resources" });
3770
+ }
3771
+ const configured = this.findConfiguredResource(requestedResource);
3772
+ if (!configured) throw new AuthorizationError("invalid_target", { description: "The resource parameter must name exactly one configured protected resource" });
3773
+ return configured;
3774
+ }
3775
+ /** Whether an existing grant belongs to the replacement bucket for a resource. */
3776
+ shouldReplaceGrantForResource(grantResource, resource) {
3777
+ if (grantResource === void 0) return this.getLegacyGrantResource() === resource;
3778
+ return isExactResource(grantResource, resource);
3779
+ }
3780
+ /** Select the audience for a new non-interactive grant such as EMA. */
3781
+ resolveNewTokenResource(requestedResource) {
3782
+ try {
3783
+ return this.resolveAuthorizationRequestResource(requestedResource);
3784
+ } catch (error) {
3785
+ if (error instanceof AuthorizationError) throw new OAuthError("invalid_target", {
3786
+ description: error.description,
3787
+ internal: {
3788
+ category: "resource-indicator",
3789
+ reason: "resource_invalid",
3790
+ detail: error
3791
+ }
3792
+ });
3793
+ throw error;
3794
+ }
3795
+ }
3796
+ /** Validate explicit resource syntax and configured-resource policy. */
3797
+ validateTokenRequestResourceIndicator(requestedResource) {
3798
+ if (requestedResource === void 0) return;
3799
+ if (!this.findConfiguredResource(requestedResource)) throw new OAuthError("invalid_target", {
3800
+ description: "The resource parameter must name exactly one configured protected resource",
3801
+ internal: {
3802
+ category: "resource-indicator",
3803
+ reason: "resource_not_configured"
3804
+ }
3805
+ });
3806
+ }
3807
+ /** Resolve token exchange strictly within the subject token's audience ceiling. */
3808
+ resolveTokenExchangeResource(requestedResource, subjectResource) {
3809
+ const subjectAudience = this.findStoredConfiguredResource(this.resolveStoredTokenAudience(subjectResource));
3810
+ if (!subjectAudience) throw new OAuthError("invalid_target", {
3811
+ description: "Subject token is not bound to a configured resource",
3812
+ internal: {
3813
+ category: "resource-indicator",
3814
+ reason: "subject_token_audience_unbound"
3815
+ }
3816
+ });
3817
+ const resourceWasProvided = requestedResource !== void 0;
3818
+ const requestedAudience = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
3819
+ if (resourceWasProvided && (!requestedAudience || requestedAudience !== subjectAudience)) throw new OAuthError("invalid_target", {
3820
+ description: "The requested resource must exactly match the subject token audience",
3821
+ internal: {
3822
+ category: "resource-indicator",
3823
+ reason: "resource_subject_token_mismatch"
3824
+ }
3825
+ });
3826
+ return subjectAudience;
3827
+ }
2820
3828
  /**
2821
3829
  * Resolves an access-token audience from a token request and its authorization grant.
2822
- * A configured canonical resource is inherited when omitted but cannot be overridden.
2823
- * Without configuration, RFC 8707 downscoping is allowed, omission inherits a
2824
- * bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
3830
+ * The configured canonical resource is inherited when omitted and cannot be overridden.
2825
3831
  */
2826
- resolveTokenResource(requestedResource, grantedResource) {
3832
+ resolveTokenResource(requestedResource, grant) {
3833
+ const grantedResource = grant.resource;
2827
3834
  const resourceWasProvided = requestedResource !== void 0;
2828
- const requestedAudience = parseResourceParameter(requestedResource);
2829
- if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2830
3835
  const grantResourceWasStored = grantedResource !== void 0;
2831
- const grantedAudience = parseResourceParameter(grantedResource);
2832
- if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2833
- const configuredResource = this.options.resourceMetadata?.resource;
2834
- if (configuredResource) {
2835
- if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2836
- if (isExactResource(grantedResource, configuredResource)) return configuredResource;
2837
- if (!grantResourceWasStored) return configuredResource;
2838
- throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
2839
- }
2840
- const originOnly = !!this.options.resourceMatchOriginOnly;
2841
- if (resourceWasProvided && grantResourceWasStored) {
2842
- const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2843
- const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2844
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
3836
+ const canonicalGrantResource = this.findStoredConfiguredResource(grantedResource);
3837
+ const canonicalRequestedResource = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
3838
+ if (grantResourceWasStored && !canonicalGrantResource) throw new OAuthError("invalid_grant", {
3839
+ description: "The authorization grant is not bound to a configured resource",
3840
+ internal: {
3841
+ category: "resource-indicator",
3842
+ reason: "grant_audience_unbound"
3843
+ }
3844
+ });
3845
+ if (canonicalGrantResource) {
3846
+ if (resourceWasProvided && canonicalRequestedResource !== canonicalGrantResource) throw new OAuthError("invalid_target", {
3847
+ description: "The requested resource does not match the authorization grant",
3848
+ internal: {
3849
+ category: "resource-indicator",
3850
+ reason: "resource_grant_mismatch"
3851
+ }
3852
+ });
3853
+ return {
3854
+ audience: canonicalGrantResource,
3855
+ ...grantedResource === canonicalGrantResource ? {} : { grantResourceBackfill: canonicalGrantResource }
3856
+ };
2845
3857
  }
2846
- return requestedAudience ?? grantedAudience;
3858
+ const legacyGrantResource = this.getLegacyGrantResource();
3859
+ if (!legacyGrantResource) throw new OAuthError("invalid_grant", {
3860
+ description: "This legacy authorization grant has no resource binding and must be reauthorized",
3861
+ internal: {
3862
+ category: "resource-indicator",
3863
+ reason: "legacy_grant_unbound"
3864
+ }
3865
+ });
3866
+ if (resourceWasProvided && canonicalRequestedResource !== legacyGrantResource) throw new OAuthError("invalid_target", {
3867
+ description: "The requested resource does not match the server legacy-grant migration policy",
3868
+ internal: {
3869
+ category: "resource-indicator",
3870
+ reason: "legacy_grant_resource_mismatch"
3871
+ }
3872
+ });
3873
+ return {
3874
+ audience: legacyGrantResource,
3875
+ grantResourceBackfill: legacyGrantResource
3876
+ };
2847
3877
  }
2848
3878
  /**
2849
3879
  * Creates and stores an access token
@@ -2852,7 +3882,13 @@ var OAuthProviderImpl = class {
2852
3882
  */
2853
3883
  async createAccessToken(params) {
2854
3884
  const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
2855
- if (expiresIn < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
3885
+ if (!isValidAccessTokenTTL(expiresIn)) throw new OAuthError("invalid_request", {
3886
+ description: "Requested token lifetime must be at least 60 seconds",
3887
+ internal: {
3888
+ category: "token-issuance",
3889
+ reason: "requested_ttl_too_short"
3890
+ }
3891
+ });
2856
3892
  const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2857
3893
  const now = Math.floor(Date.now() / 1e3);
2858
3894
  const accessTokenId = await generateTokenId(accessToken);
@@ -2908,14 +3944,36 @@ var OAuthProviderImpl = class {
2908
3944
  /**
2909
3945
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2910
3946
  */
2911
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
2912
- let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
3947
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
3948
+ let header = "Bearer realm=\"OAuth\"";
3949
+ if (resourceMetadataUrl) header += `, resource_metadata="${resourceMetadataUrl}"`;
2913
3950
  if (error) header += `, error="${error}"`;
2914
- const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
3951
+ const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
2915
3952
  if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
2916
3953
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2917
3954
  return header;
2918
3955
  }
3956
+ /** Build the RFC 9728 well-known URL for the configured canonical resource. */
3957
+ getConfiguredResourceMetadataUrl(resource) {
3958
+ const authorityStart = resource.indexOf("://") + 3;
3959
+ const suffixOffset = resource.slice(authorityStart).search(/[/?]/);
3960
+ const suffixStart = suffixOffset === -1 ? resource.length : authorityStart + suffixOffset;
3961
+ let suffix = resource.slice(suffixStart);
3962
+ if (suffix === "/") suffix = "";
3963
+ if (suffix.startsWith("/?")) suffix = suffix.slice(1);
3964
+ return resource.slice(0, suffixStart) + PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + suffix;
3965
+ }
3966
+ /**
3967
+ * RFC 9728 §3.3 requires metadata fetched from a challenge to identify the
3968
+ * original protected-resource URL. Descendant and alias routes can still use
3969
+ * a base audience, but must not advertise a document for a different URL.
3970
+ */
3971
+ getResourceMetadataUrlForRequest(requestUrl, resourceServer) {
3972
+ const configuredResource = resourceServer.resourceMetadata.resource;
3973
+ if (isExactResource(requestUrl.href, configuredResource)) return this.getConfiguredResourceMetadataUrl(configuredResource);
3974
+ const resourceUrl = new URL(configuredResource);
3975
+ if (requestUrl.origin === resourceUrl.origin && requestCarriesResourceQuery(requestUrl, resourceUrl) && isPathDescendant(requestUrl.pathname, resourceUrl.pathname)) return this.getConfiguredResourceMetadataUrl(configuredResource);
3976
+ }
2919
3977
  /**
2920
3978
  * Helper function to create OAuth error responses.
2921
3979
  *
@@ -2936,21 +3994,22 @@ var OAuthProviderImpl = class {
2936
3994
  description,
2937
3995
  status: responseStatus,
2938
3996
  headers: responseHeaders,
2939
- ...internal ? { internal } : {},
3997
+ internal,
2940
3998
  ...request ? { request } : {}
2941
3999
  });
2942
- if (customErrorResponse) return customErrorResponse;
2943
4000
  const body = JSON.stringify({
2944
4001
  error: code,
2945
4002
  error_description: description
2946
4003
  });
2947
- return new Response(body, {
4004
+ const response = customErrorResponse ?? new Response(body, {
2948
4005
  status: responseStatus,
2949
4006
  headers: {
2950
4007
  "Content-Type": "application/json",
2951
4008
  ...responseHeaders
2952
4009
  }
2953
4010
  });
4011
+ this.errorResponses.add(response);
4012
+ return response;
2954
4013
  }
2955
4014
  };
2956
4015
  /**
@@ -3081,6 +4140,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
3081
4140
  * to clamp absolute expirations when writing grants back to KV.
3082
4141
  */
3083
4142
  const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
4143
+ function isValidAccessTokenTTL(value) {
4144
+ return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
4145
+ }
3084
4146
  /**
3085
4147
  * Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
3086
4148
  * absolute KV expiration. Absolute expirations are validated against KV's clock at the
@@ -3099,8 +4161,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
3099
4161
  */
3100
4162
  const MAX_KV_LIST_LIMIT = 1e3;
3101
4163
  /**
3102
- * Default batch size for paginating existing grants when revoking them
3103
- * during completeAuthorization. Conservative for each KV list page.
4164
+ * How many grants written without key metadata `completeAuthorization()` reads at
4165
+ * once while looking for grants to replace. Grants written with key metadata are
4166
+ * matched from `list()` alone and never read.
3104
4167
  */
3105
4168
  const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
3106
4169
  function getRevokeExistingGrantsBatchSize(batchSize) {
@@ -3108,27 +4171,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
3108
4171
  if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
3109
4172
  return Math.min(batchSize, MAX_KV_LIST_LIMIT);
3110
4173
  }
4174
+ /** KV serialises key metadata as JSON and rejects a write carrying more than this. */
4175
+ const MAX_KV_METADATA_BYTES = 1024;
3111
4176
  /**
3112
- * Length of generated token strings
4177
+ * Key metadata for a grant record, or `undefined` when it would not fit. A grant
4178
+ * written without metadata is read individually when scanned, exactly like a grant
4179
+ * written before metadata existed; that only happens when a `client_id` and two URLs
4180
+ * together exceed a kilobyte.
3113
4181
  */
3114
- const TOKEN_LENGTH = 32;
4182
+ function grantKeyMetadata(grant) {
4183
+ const metadata = { clientId: grant.clientId };
4184
+ if (grant.resource !== void 0) metadata.resource = grant.resource;
4185
+ if (grant.redirectUri !== void 0) metadata.redirectUri = grant.redirectUri;
4186
+ return new TextEncoder().encode(JSON.stringify(metadata)).byteLength <= MAX_KV_METADATA_BYTES ? metadata : void 0;
4187
+ }
4188
+ /** Every grant write goes through here so the key metadata cannot drift from the record. */
4189
+ function grantPutOptions(grant, expiry = {}) {
4190
+ const metadata = grantKeyMetadata(grant);
4191
+ return metadata ? {
4192
+ ...expiry,
4193
+ metadata
4194
+ } : expiry;
4195
+ }
4196
+ /** Whether a listed key carries metadata this version can match a grant on. */
4197
+ function isGrantKeyMetadata(value) {
4198
+ if (typeof value !== "object" || value === null) return false;
4199
+ const { clientId, resource, redirectUri } = value;
4200
+ return typeof clientId === "string" && (resource === void 0 || typeof resource === "string" || Array.isArray(resource)) && (redirectUri === void 0 || typeof redirectUri === "string");
4201
+ }
3115
4202
  /**
3116
- * Validates a resource URI per RFC 8707 Section 2
3117
- * @param uri - The URI string to validate
3118
- * @returns true if valid, false otherwise
4203
+ * Length of generated token strings
3119
4204
  */
3120
- function validateResourceUri(uri) {
3121
- if (!uri || typeof uri !== "string") return false;
3122
- try {
3123
- const parsed = new URL(uri);
3124
- if (!parsed.protocol) return false;
3125
- if (parsed.hash) return false;
3126
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3127
- return true;
3128
- } catch {
3129
- return false;
3130
- }
3131
- }
4205
+ const TOKEN_LENGTH = 32;
3132
4206
  /**
3133
4207
  * Checks if a resource server matches an audience claim.
3134
4208
  * Uses origin comparison (case-insensitive hostname via URL normalization)
@@ -3142,42 +4216,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
3142
4216
  const resource = new URL(resourceServerUrl);
3143
4217
  const audience = new URL(audienceValue);
3144
4218
  if (resource.origin !== audience.origin) return false;
3145
- if (audience.search && resource.search !== audience.search) return false;
4219
+ if (!requestCarriesResourceQuery(resource, audience)) return false;
3146
4220
  if (audience.pathname === "/" || audience.pathname === "") return true;
3147
- return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
4221
+ const descendantPrefix = audience.pathname.endsWith("/") ? audience.pathname : audience.pathname + "/";
4222
+ return resource.pathname === audience.pathname || resource.pathname.startsWith(descendantPrefix);
3148
4223
  } catch {
3149
4224
  return false;
3150
4225
  }
3151
4226
  }
3152
- /**
3153
- * Parses and validates the resource parameter from a token request (RFC 8707)
3154
- * Handles single string or array of strings (from multiple form parameters)
3155
- * @param value - The resource parameter value from the request body
3156
- * @returns The validated value as string, string array, or undefined if validation fails
3157
- */
3158
- function parseResourceParameter(value) {
3159
- if (!value) return;
3160
- const uris = Array.isArray(value) ? value : [value];
3161
- if (uris.length === 0) return;
3162
- for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
3163
- return value;
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(", "));
3164
4231
  }
3165
- /** Whether a request or audience names one exact configured resource. */
4232
+ /** Whether a request or audience names one canonical configured resource. */
3166
4233
  function isExactResource(value, configuredResource) {
3167
- return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
4234
+ if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
4235
+ return typeof value === "string" && resourceMatches(value, configuredResource);
3168
4236
  }
3169
- /**
3170
- * Checks if a requested resource matches a granted resource.
3171
- * When originOnly is true, compares only the origin (scheme + host + port),
3172
- * allowing path-aware resources to match origin-only grants.
3173
- */
3174
- function resourceMatches(requested, granted, originOnly) {
3175
- if (!originOnly) return requested === granted;
3176
- try {
3177
- return new URL(requested).origin === new URL(granted).origin;
3178
- } catch {
3179
- return requested === granted;
3180
- }
4237
+ /** RFC 9110 §9.3.2: a HEAD response carries the GET headers and no body. */
4238
+ function withoutBodyForHead(request, response) {
4239
+ if (request.method !== "HEAD") return response;
4240
+ return new Response(null, {
4241
+ status: response.status,
4242
+ headers: response.headers
4243
+ });
4244
+ }
4245
+ /** Whether `candidate` is `base` or a path-boundary descendant of it (trailing slashes ignored). */
4246
+ function isPathDescendant(candidate, base) {
4247
+ if (base === "" || base === "/") return true;
4248
+ if (base.endsWith("/")) return candidate.startsWith(base);
4249
+ return candidate === base || candidate.startsWith(`${base}/`);
3181
4250
  }
3182
4251
  /**
3183
4252
  * Hashes a secret value using SHA-256
@@ -3248,11 +4317,7 @@ async function generateTokenId(token) {
3248
4317
  */
3249
4318
  function isLoopbackUri(uri) {
3250
4319
  try {
3251
- const host = new URL(uri).hostname;
3252
- if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
3253
- if (host === "::1" || host === "[::1]") return true;
3254
- if (host.toLowerCase() === "localhost") return true;
3255
- return false;
4320
+ return isLoopbackHostname(new URL(uri).hostname);
3256
4321
  } catch {
3257
4322
  return false;
3258
4323
  }
@@ -3482,6 +4547,7 @@ var OAuthHelpersImpl = class {
3482
4547
  */
3483
4548
  async parseAuthRequest(request) {
3484
4549
  const url = new URL(request.url);
4550
+ if (this.provider.hasExplicitIssuer && !this.provider.isAuthorizationEndpointRequest(url)) throw new AuthorizationError("invalid_request", { description: "Authorization request was sent to an unconfigured endpoint" });
3485
4551
  const responseType = url.searchParams.get("response_type") || "";
3486
4552
  const clientId = url.searchParams.get("client_id") || "";
3487
4553
  const redirectUri = url.searchParams.get("redirect_uri") || "";
@@ -3504,13 +4570,12 @@ var OAuthHelpersImpl = class {
3504
4570
  const withRedirect = (error) => {
3505
4571
  throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3506
4572
  };
3507
- const resourceWasProvided = resourceParam !== void 0;
3508
- let resource = parseResourceParameter(resourceParam);
3509
- if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3510
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3511
- if (configuredResource) {
3512
- if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
3513
- resource = configuredResource;
4573
+ let resource;
4574
+ try {
4575
+ resource = this.provider.resolveAuthorizationRequestResource(resourceParam);
4576
+ } catch (error) {
4577
+ if (error instanceof AuthorizationError) withRedirect(error);
4578
+ throw error;
3514
4579
  }
3515
4580
  try {
3516
4581
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
@@ -3565,26 +4630,13 @@ var OAuthHelpersImpl = class {
3565
4630
  const clientInfo = await this.lookupClient(clientId);
3566
4631
  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.");
3567
4632
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3568
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3569
- const resourceWasProvided = options.request.resource !== void 0;
3570
- const parsedResource = parseResourceParameter(options.request.resource);
3571
- if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
3572
- if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
3573
- const effectiveResource = configuredResource ?? parsedResource;
4633
+ const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
3574
4634
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3575
4635
  let grantsToRevoke = [];
3576
4636
  if (options.revokeExistingGrants !== false) {
3577
4637
  const isCimdClient = this.provider.isClientMetadataUrl(clientId);
3578
- const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
3579
- let cursor;
3580
- do {
3581
- const page = await this.listUserGrants(options.userId, {
3582
- cursor,
3583
- limit: batchSize
3584
- });
3585
- for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
3586
- cursor = page.cursor;
3587
- } while (cursor);
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);
3588
4640
  }
3589
4641
  const grantId = generateRandomString(16);
3590
4642
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
@@ -3596,8 +4648,7 @@ var OAuthHelpersImpl = class {
3596
4648
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3597
4649
  const accessTokenExpiresAt = now + accessTokenTTL;
3598
4650
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
3599
- const audience = parseResourceParameter(effectiveResource);
3600
- if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
4651
+ const audience = effectiveResource;
3601
4652
  const grant = {
3602
4653
  id: grantId,
3603
4654
  clientId: options.request.clientId,
@@ -3610,7 +4661,7 @@ var OAuthHelpersImpl = class {
3610
4661
  redirectUri: options.request.redirectUri
3611
4662
  };
3612
4663
  const grantKey = `grant:${options.userId}:${grantId}`;
3613
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
4664
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
3614
4665
  const accessTokenData = {
3615
4666
  id: accessTokenId,
3616
4667
  grantId,
@@ -3633,6 +4684,7 @@ var OAuthHelpersImpl = class {
3633
4684
  fragment.set("token_type", "bearer");
3634
4685
  fragment.set("expires_in", accessTokenTTL.toString());
3635
4686
  fragment.set("scope", options.scope.join(" "));
4687
+ fragment.set("resource", effectiveResource);
3636
4688
  if (options.request.state) fragment.set("state", options.request.state);
3637
4689
  if (options.request.issuer) fragment.set("iss", options.request.issuer);
3638
4690
  redirectUrl.hash = fragment.toString();
@@ -3661,7 +4713,7 @@ var OAuthHelpersImpl = class {
3661
4713
  redirectUri: options.request.redirectUri
3662
4714
  };
3663
4715
  const grantKey = `grant:${options.userId}:${grantId}`;
3664
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
4716
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
3665
4717
  const redirectUrl = new URL(options.request.redirectUri);
3666
4718
  redirectUrl.searchParams.set("code", authCode);
3667
4719
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
@@ -3673,6 +4725,39 @@ var OAuthHelpersImpl = class {
3673
4725
  }
3674
4726
  }
3675
4727
  /**
4728
+ * IDs of the user's grants that satisfy `matches`, judged from KV key metadata
4729
+ * wherever a grant carries it. Only grants written without metadata, by a version
4730
+ * before 1.0 or because theirs would not fit, are read, `readConcurrency` at a time.
4731
+ *
4732
+ * This runs on the interactive authorization path, where the pre-metadata cost of one
4733
+ * `get()` per grant broke re-authorization for a user with more grants than a Worker
4734
+ * has subrequests. The cost is now one `list()` per thousand grants plus one `get()`
4735
+ * per legacy grant, and every refresh rewrites its grant with metadata, so the legacy
4736
+ * share shrinks on its own.
4737
+ */
4738
+ async findGrantIds(userId, matches, readConcurrency) {
4739
+ const prefix = `grant:${userId}:`;
4740
+ const found = [];
4741
+ let cursor;
4742
+ do {
4743
+ const page = await this.env.OAUTH_KV.list({
4744
+ prefix,
4745
+ limit: MAX_KV_LIST_LIMIT,
4746
+ cursor
4747
+ });
4748
+ 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);
4752
+ 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);
4755
+ }
4756
+ cursor = page.list_complete ? void 0 : page.cursor;
4757
+ } while (cursor);
4758
+ return found;
4759
+ }
4760
+ /**
3676
4761
  * Creates a new OAuth client
3677
4762
  * @param clientInfo - Partial client information to create the client with
3678
4763
  * @returns A Promise resolving to the created client info
@@ -3764,9 +4849,7 @@ var OAuthHelpersImpl = class {
3764
4849
  };
3765
4850
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
3766
4851
  else delete updatedClient.clientSecret;
3767
- const clientKvOptions = {};
3768
- if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
3769
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
4852
+ await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
3770
4853
  const response = toPublicClientInfo(updatedClient);
3771
4854
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
3772
4855
  return response;
@@ -3816,7 +4899,8 @@ var OAuthHelpersImpl = class {
3816
4899
  metadata: grantData.metadata,
3817
4900
  createdAt: grantData.createdAt,
3818
4901
  expiresAt: grantData.expiresAt,
3819
- redirectUri: grantData.redirectUri
4902
+ redirectUri: grantData.redirectUri,
4903
+ resource: grantData.resource
3820
4904
  };
3821
4905
  grantSummaries.push(summary);
3822
4906
  }
@@ -3966,4 +5050,4 @@ var OAuthHelpersImpl = class {
3966
5050
  var oauth_provider_default = OAuthProvider;
3967
5051
 
3968
5052
  //#endregion
3969
- export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
5053
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };