@cloudflare/workers-oauth-provider 0.10.3 → 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.
@@ -581,6 +581,8 @@ const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
581
581
  * values to amplify load on the IdP's JWKS endpoint.
582
582
  */
583
583
  const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
584
+ /** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
585
+ const EMA_JTI_MIN_TTL_SECONDS = 60;
584
586
  /** Default JWT signing algorithm assumed for a trusted issuer. */
585
587
  const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
586
588
  /** JWT signing algorithms supported by the built-in WebCrypto verifier. */
@@ -698,7 +700,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
698
700
  /** Create the default KV-backed JTI store. KV TTL handles cleanup. */
699
701
  function createKvJtiStore() {
700
702
  return { async markUsed({ issuer, jti, exp, now, env }) {
701
- const ttl = Math.max(1, exp - now);
703
+ const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
702
704
  const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
703
705
  if (await env.OAUTH_KV.get(key)) return err({
704
706
  reason: "replayed",
@@ -909,6 +911,96 @@ async function verifyIdJagSignature(input) {
909
911
  }
910
912
  }
911
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
+
912
1004
  //#endregion
913
1005
  //#region src/ema/validators.ts
914
1006
  /**
@@ -1034,7 +1126,7 @@ function isWellFormedTrustedIssuer(issuer) {
1034
1126
  * - `scope` (if present) conforms to RFC 6749 §3.3 grammar
1035
1127
  */
1036
1128
  function validateIdJagClaims(input) {
1037
- const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource, matchOriginOnly } = input;
1129
+ const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource } = input;
1038
1130
  const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
1039
1131
  const iss = readRequiredString(rawClaims, "iss");
1040
1132
  if (!iss.ok) return iss;
@@ -1074,7 +1166,7 @@ function validateIdJagClaims(input) {
1074
1166
  reason: "resource_invalid",
1075
1167
  resource: resource.value
1076
1168
  });
1077
- if (!resourceMatches(resource.value, configuredResource, matchOriginOnly)) return err({
1169
+ if (!resourceMatches(resource.value, configuredResource)) return err({
1078
1170
  reason: "resource_mismatch",
1079
1171
  expected: configuredResource,
1080
1172
  got: resource.value
@@ -1226,6 +1318,237 @@ function readNumericDateClaim(claims, claimName) {
1226
1318
  return ok(value);
1227
1319
  }
1228
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
+
1229
1552
  //#endregion
1230
1553
  //#region src/oauth-provider.ts
1231
1554
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
@@ -1254,7 +1577,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
1254
1577
  return GrantType$1;
1255
1578
  }({});
1256
1579
  function toPublicClientInfo(client) {
1257
- const { authMethodExplicit: _explicit, ...publicClient } = client;
1580
+ const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
1258
1581
  return publicClient;
1259
1582
  }
1260
1583
  function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
@@ -1300,6 +1623,66 @@ var OAuthProvider = class {
1300
1623
  }
1301
1624
  };
1302
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
+ /**
1303
1686
  * Gets OAuthHelpers for the given environment
1304
1687
  * @param options - Configuration options for the OAuth provider
1305
1688
  * @param env - Cloudflare Worker environment variables
@@ -1322,37 +1705,95 @@ var OAuthProviderImpl = class {
1322
1705
  * @param options - Configuration options for the provider
1323
1706
  */
1324
1707
  constructor(options) {
1708
+ this.errorResponses = /* @__PURE__ */ new WeakSet();
1325
1709
  this.typedApiHandlers = [];
1326
- const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
1327
- const hasMultiHandlerConfig = !!options.apiHandlers;
1328
- if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
1329
- if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
1330
1710
  this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
1331
- if (hasSingleHandlerConfig) {
1332
- const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
1333
- if (Array.isArray(options.apiRoute)) options.apiRoute.forEach((route, index) => {
1334
- this.validateEndpoint(route, `apiRoute[${index}]`);
1335
- this.typedApiHandlers.push([route, apiHandler]);
1336
- });
1337
- else {
1338
- this.validateEndpoint(options.apiRoute, "apiRoute");
1339
- this.typedApiHandlers.push([options.apiRoute, apiHandler]);
1340
- }
1341
- } else for (const [route, handler] of Object.entries(options.apiHandlers)) {
1342
- this.validateEndpoint(route, `apiHandlers key: ${route}`);
1343
- 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
+ }];
1344
1737
  }
1345
- this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
1346
- this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
1347
- if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
1348
1738
  this.options = {
1349
1739
  accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
1350
1740
  refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
1351
1741
  clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
1352
1742
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
1353
- ...options
1743
+ ...normalizedOptions
1354
1744
  };
1355
- 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).`);
1356
1797
  this.serverCapabilities = buildOAuthServerCapabilities({
1357
1798
  allowImplicitFlow: !!this.options.allowImplicitFlow,
1358
1799
  allowPlainPKCE: this.options.allowPlainPKCE === true,
@@ -1360,7 +1801,6 @@ var OAuthProviderImpl = class {
1360
1801
  enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
1361
1802
  });
1362
1803
  validateAuthorizationServerScopes(this.options.scopesSupported);
1363
- this.validateResourceMetadataOptions(this.options.resourceMetadata);
1364
1804
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
1365
1805
  if (this.options.enterpriseManagedAuthorization) {
1366
1806
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -1376,11 +1816,90 @@ var OAuthProviderImpl = class {
1376
1816
  validateEndpoint(endpoint, name) {
1377
1817
  if (this.isPath(endpoint)) {
1378
1818
  if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
1379
- } else try {
1380
- new URL(endpoint);
1381
- } catch (e) {
1382
- 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");
1383
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;
1384
1903
  }
1385
1904
  /**
1386
1905
  * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
@@ -1400,10 +1919,22 @@ var OAuthProviderImpl = class {
1400
1919
  };
1401
1920
  throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
1402
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
+ }
1403
1931
  /** Validate configured RFC 9728 protected resource metadata. */
1404
1932
  validateResourceMetadataOptions(options) {
1405
- if (!options) return;
1406
- 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`);
1407
1938
  if (options.authorization_servers !== void 0) {
1408
1939
  if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
1409
1940
  for (const issuer of options.authorization_servers) {
@@ -1413,7 +1944,7 @@ var OAuthProviderImpl = class {
1413
1944
  } catch {
1414
1945
  throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
1415
1946
  }
1416
- 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)");
1417
1948
  }
1418
1949
  }
1419
1950
  if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
@@ -1431,7 +1962,6 @@ var OAuthProviderImpl = class {
1431
1962
  if (!options) return;
1432
1963
  if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
1433
1964
  if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
1434
- if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
1435
1965
  if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
1436
1966
  if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
1437
1967
  if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
@@ -1445,20 +1975,38 @@ var OAuthProviderImpl = class {
1445
1975
  * @returns A Promise resolving to an HTTP Response
1446
1976
  */
1447
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) {
1448
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;
1449
1988
  if (request.method === "OPTIONS") {
1450
- 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, {
1451
1990
  status: 204,
1452
1991
  headers: { "Content-Length": "0" }
1453
1992
  }), request);
1454
1993
  }
1455
- 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);
1456
1999
  const response = await this.handleMetadataDiscovery(url);
1457
- return this.addCorsHeaders(response, request);
2000
+ return this.addCorsHeaders(withoutBodyForHead(request, response), request);
1458
2001
  }
1459
- if (this.isProtectedResourceMetadataRequest(url)) {
1460
- const response = this.handleProtectedResourceMetadata(url);
1461
- 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);
1462
2010
  }
1463
2011
  if (this.isTokenEndpoint(url)) {
1464
2012
  const parsed = await this.parseTokenEndpointRequest(request, env);
@@ -1466,16 +2014,18 @@ var OAuthProviderImpl = class {
1466
2014
  let response;
1467
2015
  if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
1468
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));
1469
2018
  return this.addCorsHeaders(response, request);
1470
2019
  }
1471
2020
  if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
1472
2021
  const response = await this.handleClientRegistration(request, env);
1473
2022
  return this.addCorsHeaders(response, request);
1474
2023
  }
1475
- if (this.isApiRequest(url)) {
1476
- const response = await this.handleApiRequest(request, env, ctx);
2024
+ if (apiRoute) {
2025
+ const response = await this.handleApiRequest(request, env, ctx, apiRoute);
1477
2026
  return this.addCorsHeaders(response, request);
1478
2027
  }
2028
+ if (typeof role === "object") return new Response(null, { status: 404 });
1479
2029
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1480
2030
  if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
1481
2031
  return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
@@ -1512,6 +2062,20 @@ var OAuthProviderImpl = class {
1512
2062
  }
1513
2063
  };
1514
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
+ }
1515
2079
  /**
1516
2080
  * Determines if an endpoint configuration is a path or a full URL
1517
2081
  * @param endpoint - The endpoint configuration
@@ -1526,12 +2090,26 @@ var OAuthProviderImpl = class {
1526
2090
  * @param endpoint - The endpoint pattern (full URL or path)
1527
2091
  * @returns True if the URL matches the endpoint pattern
1528
2092
  */
1529
- matchEndpoint(url, endpoint) {
1530
- if (this.isPath(endpoint)) return url.pathname === endpoint;
1531
- else {
1532
- const endpointUrl = new URL(endpoint);
1533
- 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);
1534
2110
  }
2111
+ if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
2112
+ return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
1535
2113
  }
1536
2114
  /**
1537
2115
  * Checks if a URL matches the configured token endpoint
@@ -1539,7 +2117,10 @@ var OAuthProviderImpl = class {
1539
2117
  * @returns True if the URL matches the token endpoint
1540
2118
  */
1541
2119
  isTokenEndpoint(url) {
1542
- 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);
1543
2124
  }
1544
2125
  /**
1545
2126
  * Checks if a URL matches the configured client registration endpoint
@@ -1548,28 +2129,37 @@ var OAuthProviderImpl = class {
1548
2129
  */
1549
2130
  isClientRegistrationEndpoint(url) {
1550
2131
  if (!this.options.clientRegistrationEndpoint) return false;
1551
- return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
2132
+ return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
1552
2133
  }
1553
2134
  /**
1554
2135
  * Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
1555
- * 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.
1556
2138
  */
1557
- isProtectedResourceMetadataRequest(url) {
1558
- 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
+ });
1559
2144
  }
1560
- /**
1561
- * Derives the resource identifier from a protected resource metadata well-known URL.
1562
- * Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
1563
- * so the resource identifier is reconstructed by removing the well-known prefix.
1564
- *
1565
- * Examples:
1566
- * /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
1567
- * /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
1568
- */
1569
- deriveResourceIdentifier(requestUrl) {
1570
- const suffix = requestUrl.pathname.slice(37);
1571
- if (!suffix || suffix === "/") return requestUrl.origin;
1572
- 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 + "/");
1573
2163
  }
1574
2164
  createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
1575
2165
  return this.createErrorResponse("invalid_client", {
@@ -1586,13 +2176,20 @@ var OAuthProviderImpl = class {
1586
2176
  async parseTokenEndpointRequest(request, env) {
1587
2177
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
1588
2178
  description: "Method not allowed",
1589
- statusCode: 405
2179
+ statusCode: 405,
2180
+ headers: { Allow: "POST, OPTIONS" }
2181
+ }, {
2182
+ category: "token-endpoint-request",
2183
+ reason: "method_not_allowed"
1590
2184
  });
1591
2185
  const contentType = request.headers.get("Content-Type") || "";
1592
2186
  let body = {};
1593
2187
  if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
1594
2188
  description: "Content-Type must be application/x-www-form-urlencoded",
1595
2189
  statusCode: 400
2190
+ }, {
2191
+ category: "token-endpoint-request",
2192
+ reason: "unsupported_content_type"
1596
2193
  });
1597
2194
  let formData;
1598
2195
  try {
@@ -1601,6 +2198,9 @@ var OAuthProviderImpl = class {
1601
2198
  return this.createErrorResponse("invalid_request", {
1602
2199
  description: "Request body must be valid application/x-www-form-urlencoded data",
1603
2200
  statusCode: 400
2201
+ }, {
2202
+ category: "token-endpoint-request",
2203
+ reason: "malformed_request_body"
1604
2204
  });
1605
2205
  }
1606
2206
  const processedKeys = /* @__PURE__ */ new Set();
@@ -1611,6 +2211,10 @@ var OAuthProviderImpl = class {
1611
2211
  if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
1612
2212
  description: `Request parameter "${key}" must not be repeated`,
1613
2213
  statusCode: 400
2214
+ }, {
2215
+ category: "token-endpoint-request",
2216
+ reason: "repeated_parameter",
2217
+ detail: { parameter: key }
1614
2218
  });
1615
2219
  body[key] = allValues.length > 1 ? allValues : value;
1616
2220
  }
@@ -1622,15 +2226,24 @@ var OAuthProviderImpl = class {
1622
2226
  if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
1623
2227
  description: "Client must not use multiple authentication methods",
1624
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"
1625
2236
  });
1626
- if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
1627
2237
  clientId = basicAuthorization.clientId;
1628
2238
  clientSecret = basicAuthorization.clientSecret;
1629
2239
  } else {
1630
2240
  clientId = body.client_id;
1631
2241
  clientSecret = body.client_secret || "";
1632
2242
  }
1633
- 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
+ });
1634
2247
  let clientInfo;
1635
2248
  try {
1636
2249
  clientInfo = await this.getClient(env, clientId);
@@ -1645,7 +2258,10 @@ var OAuthProviderImpl = class {
1645
2258
  }, request);
1646
2259
  throw error;
1647
2260
  }
1648
- 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
+ });
1649
2265
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
1650
2266
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
1651
2267
  if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
@@ -1658,9 +2274,18 @@ var OAuthProviderImpl = class {
1658
2274
  }
1659
2275
  });
1660
2276
  if (presentedAuthMethod !== "none") {
1661
- if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1662
- if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
1663
- 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
+ });
1664
2289
  }
1665
2290
  const isRevocationRequest = !body.grant_type && !!body.token;
1666
2291
  return {
@@ -1676,30 +2301,24 @@ var OAuthProviderImpl = class {
1676
2301
  * @returns True if the URL matches the API route
1677
2302
  */
1678
2303
  matchApiRoute(url, route) {
1679
- if (this.isPath(route)) {
1680
- if (route === "/") return url.pathname === "/";
1681
- return url.pathname.startsWith(route);
1682
- } 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 {
1683
2311
  const apiUrl = new URL(route);
1684
- return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
2312
+ return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
1685
2313
  }
1686
2314
  }
1687
2315
  /**
1688
- * Checks if a URL is an API request based on the configured API route(s)
1689
- * @param url - The URL to check
1690
- * @returns True if the URL matches any of the API routes
1691
- */
1692
- isApiRequest(url) {
1693
- for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
1694
- return false;
1695
- }
1696
- /**
1697
2316
  * Finds the appropriate API handler for a URL
1698
2317
  * @param url - The URL to find a handler for
1699
2318
  * @returns The TypedHandler for the URL, or undefined if no handler matches
1700
2319
  */
1701
- findApiHandlerForUrl(url) {
1702
- 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];
1703
2322
  }
1704
2323
  /**
1705
2324
  * Gets the full URL for an endpoint, using the provided request URL's
@@ -1709,13 +2328,14 @@ var OAuthProviderImpl = class {
1709
2328
  * @returns The full URL for the endpoint
1710
2329
  */
1711
2330
  getFullEndpointUrl(endpoint, requestUrl) {
1712
- if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
2331
+ if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
1713
2332
  else return endpoint;
1714
2333
  }
1715
2334
  /**
1716
2335
  * Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
1717
2336
  */
1718
2337
  getAuthorizationServerIssuer(requestUrl) {
2338
+ if (this.explicitIssuer) return this.explicitIssuer;
1719
2339
  const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1720
2340
  return new URL(tokenEndpoint).origin;
1721
2341
  }
@@ -1732,6 +2352,7 @@ var OAuthProviderImpl = class {
1732
2352
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
1733
2353
  newResponse.headers.set("Access-Control-Allow-Methods", "*");
1734
2354
  newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
2355
+ appendHeaderValue(newResponse.headers, "Vary", "Origin");
1735
2356
  const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
1736
2357
  for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
1737
2358
  newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
@@ -1753,9 +2374,10 @@ var OAuthProviderImpl = class {
1753
2374
  const grantTypesSupported = this.serverCapabilities.grantTypes;
1754
2375
  const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
1755
2376
  const metadata = {
1756
- issuer: new URL(tokenEndpoint).origin,
2377
+ issuer: this.getAuthorizationServerIssuer(requestUrl),
1757
2378
  authorization_endpoint: authorizeEndpoint,
1758
2379
  token_endpoint: tokenEndpoint,
2380
+ ...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
1759
2381
  registration_endpoint: registrationEndpoint,
1760
2382
  scopes_supported: this.options.scopesSupported,
1761
2383
  response_types_supported: responseTypesSupported,
@@ -1771,8 +2393,8 @@ var OAuthProviderImpl = class {
1771
2393
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1772
2394
  }
1773
2395
  /** Scopes that are baseline requirements of the protected resource itself. */
1774
- getProtectedResourceScopes() {
1775
- return this.normalizeProtectedResourceScopes(this.options.resourceMetadata?.scopes_supported ?? []);
2396
+ getProtectedResourceScopes(resourceServer) {
2397
+ return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
1776
2398
  }
1777
2399
  /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
1778
2400
  normalizeProtectedResourceScopes(scopes) {
@@ -1784,18 +2406,17 @@ var OAuthProviderImpl = class {
1784
2406
  * @param requestUrl - The URL of the incoming request
1785
2407
  * @returns Response with protected resource metadata
1786
2408
  */
1787
- handleProtectedResourceMetadata(requestUrl) {
1788
- const rm = this.options.resourceMetadata;
1789
- const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1790
- const authServerOrigin = new URL(tokenEndpointUrl).origin;
1791
- 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);
1792
2413
  const metadata = {
1793
- resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
1794
- authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
2414
+ resource: rm.resource,
2415
+ authorization_servers: rm.authorization_servers ?? [authorizationServer],
1795
2416
  ...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
1796
- bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
2417
+ bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
1797
2418
  };
1798
- if (rm?.resource_name) metadata.resource_name = rm.resource_name;
2419
+ if (rm.resource_name) metadata.resource_name = rm.resource_name;
1799
2420
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1800
2421
  }
1801
2422
  /**
@@ -1809,11 +2430,23 @@ var OAuthProviderImpl = class {
1809
2430
  async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
1810
2431
  try {
1811
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);
1812
2445
  if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
1813
2446
  else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
1814
2447
  else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
1815
2448
  else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
1816
- else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
2449
+ throw new Error("Unreachable supported grant type");
1817
2450
  } catch (error) {
1818
2451
  const response = this.createOAuthErrorResponse(error);
1819
2452
  if (response) return response;
@@ -1829,7 +2462,11 @@ var OAuthProviderImpl = class {
1829
2462
  */
1830
2463
  createOAuthErrorResponse(error) {
1831
2464
  if (!(error instanceof OAuthError)) return void 0;
1832
- 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
+ });
1833
2470
  }
1834
2471
  /**
1835
2472
  * Build a structured protected-resource response from an external-token error.
@@ -1838,7 +2475,7 @@ var OAuthProviderImpl = class {
1838
2475
  * bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
1839
2476
  * supplied one. Other errors retain the pre-existing behavior and are re-thrown.
1840
2477
  */
1841
- createExternalTokenErrorResponse(error, resourceMetadataUrl) {
2478
+ createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
1842
2479
  if (!(error instanceof ExternalTokenError)) return void 0;
1843
2480
  const headers = error.headers ?? {};
1844
2481
  const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
@@ -1849,13 +2486,17 @@ var OAuthProviderImpl = class {
1849
2486
  if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
1850
2487
  challengeHeaders = {
1851
2488
  ...headers,
1852
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
2489
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
1853
2490
  };
1854
2491
  }
1855
2492
  return this.createErrorResponse(error.code, {
1856
2493
  description: error.description,
1857
2494
  statusCode: error.statusCode,
1858
2495
  headers: challengeHeaders ?? headers
2496
+ }, {
2497
+ category: "protected-resource",
2498
+ reason: "resolver_rejected",
2499
+ detail: error
1859
2500
  });
1860
2501
  }
1861
2502
  /**
@@ -1870,34 +2511,72 @@ var OAuthProviderImpl = class {
1870
2511
  const code = body.code;
1871
2512
  const redirectUri = body.redirect_uri;
1872
2513
  const codeVerifier = body.code_verifier;
1873
- 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
+ });
1874
2518
  const codeParts = code.split(":");
1875
- 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
+ });
1876
2523
  const [userId, grantId, _] = codeParts;
1877
2524
  const grantKey = `grant:${userId}:${grantId}`;
1878
2525
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1879
- 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
+ });
1880
2530
  const codeHash = await hashSecret(code);
1881
- if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1882
- 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
+ });
1883
2539
  if (!grantData.authCodeWrappedKey) {
1884
2540
  try {
1885
2541
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1886
2542
  } catch {}
1887
- 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
+ });
1888
2547
  }
1889
2548
  let codeChallengeMethod;
1890
2549
  try {
1891
2550
  codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
1892
2551
  } catch (error) {
1893
- 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
+ });
1894
2557
  }
1895
2558
  const isPkceEnabled = !!grantData.codeChallenge;
1896
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1897
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
1898
- 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
+ });
1899
2575
  if (isPkceEnabled) {
1900
- 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
+ });
1901
2580
  let calculatedChallenge;
1902
2581
  if (codeChallengeMethod === "S256") {
1903
2582
  const data = new TextEncoder().encode(codeVerifier);
@@ -1905,9 +2584,13 @@ var OAuthProviderImpl = class {
1905
2584
  const hashArray = Array.from(new Uint8Array(hashBuffer));
1906
2585
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
1907
2586
  } else calculatedChallenge = codeVerifier;
1908
- 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
+ });
1909
2591
  }
1910
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
2592
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
2593
+ const audience = resourceResolution.audience;
1911
2594
  let accessTokenTTL = this.options.accessTokenTTL;
1912
2595
  let refreshTokenTTL = this.options.refreshTokenTTL;
1913
2596
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
@@ -1922,10 +2605,12 @@ var OAuthProviderImpl = class {
1922
2605
  const callbackOptions = {
1923
2606
  grantType: GrantType.AUTHORIZATION_CODE,
1924
2607
  clientId: clientInfo.clientId,
2608
+ subjectClientId: grantData.clientId,
1925
2609
  userId,
1926
2610
  grantId,
1927
2611
  scope: grantData.scope,
1928
2612
  requestedScope: tokenScopes,
2613
+ resource: audience,
1929
2614
  props: decryptedProps
1930
2615
  };
1931
2616
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -1937,6 +2622,10 @@ var OAuthProviderImpl = class {
1937
2622
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
1938
2623
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
1939
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
+ });
1940
2629
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1941
2630
  }
1942
2631
  const grantResult = await encryptProps(grantProps);
@@ -1951,6 +2640,10 @@ var OAuthProviderImpl = class {
1951
2640
  accessTokenEncryptionKey = grantEncryptionKey;
1952
2641
  }
1953
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
+ });
1954
2647
  const now = Math.floor(Date.now() / 1e3);
1955
2648
  const useRefreshToken = refreshTokenTTL !== 0;
1956
2649
  delete grantData.codeChallenge;
@@ -1968,6 +2661,7 @@ var OAuthProviderImpl = class {
1968
2661
  grantData.previousRefreshTokenWrappedKey = void 0;
1969
2662
  grantData.expiresAt = expiresAt;
1970
2663
  }
2664
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
1971
2665
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1972
2666
  const tokenResponse = {
1973
2667
  access_token: await this.createAccessToken({
@@ -1983,10 +2677,10 @@ var OAuthProviderImpl = class {
1983
2677
  }),
1984
2678
  token_type: "bearer",
1985
2679
  expires_in: accessTokenTTL,
1986
- scope: tokenScopes.join(" ")
2680
+ scope: tokenScopes.join(" "),
2681
+ resource: audience
1987
2682
  };
1988
2683
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
1989
- if (audience) tokenResponse.resource = audience;
1990
2684
  return new Response(JSON.stringify(tokenResponse), { headers: {
1991
2685
  "Content-Type": "application/json",
1992
2686
  ...NO_CACHE_HEADERS
@@ -2002,26 +2696,46 @@ var OAuthProviderImpl = class {
2002
2696
  */
2003
2697
  async handleRefreshTokenGrant(body, clientInfo, env) {
2004
2698
  const refreshToken = body.refresh_token;
2005
- 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
+ });
2006
2703
  const tokenParts = refreshToken.split(":");
2007
- 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
+ });
2008
2708
  const [userId, grantId, _] = tokenParts;
2009
2709
  const providedTokenHash = await generateTokenId(refreshToken);
2010
2710
  const grantKey = `grant:${userId}:${grantId}`;
2011
2711
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2012
- 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
+ });
2013
2716
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
2014
2717
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
2015
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
2016
- 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
+ });
2017
2726
  if (grantData.expiresAt !== void 0) {
2018
2727
  const now$1 = Math.floor(Date.now() / 1e3);
2019
- 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
+ });
2020
2732
  }
2021
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
2733
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
2734
+ const audience = resourceResolution.audience;
2022
2735
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2023
2736
  const accessTokenId = await generateTokenId(newAccessToken);
2024
2737
  let accessTokenTTL = this.options.accessTokenTTL;
2738
+ let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
2025
2739
  let wrappedKeyToUse;
2026
2740
  if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
2027
2741
  else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
@@ -2038,10 +2752,12 @@ var OAuthProviderImpl = class {
2038
2752
  const callbackOptions = {
2039
2753
  grantType: GrantType.REFRESH_TOKEN,
2040
2754
  clientId: clientInfo.clientId,
2755
+ subjectClientId: grantData.clientId,
2041
2756
  userId,
2042
2757
  grantId,
2043
2758
  scope: grantData.scope,
2044
2759
  requestedScope: tokenScopes,
2760
+ resource: audience,
2045
2761
  props: decryptedProps
2046
2762
  };
2047
2763
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -2053,7 +2769,17 @@ var OAuthProviderImpl = class {
2053
2769
  }
2054
2770
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2055
2771
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
2056
- 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
+ }
2057
2783
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
2058
2784
  }
2059
2785
  if (grantPropsChanged) {
@@ -2074,12 +2800,19 @@ var OAuthProviderImpl = class {
2074
2800
  }
2075
2801
  }
2076
2802
  const now = Math.floor(Date.now() / 1e3);
2077
- 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;
2078
2808
  if (grantData.expiresAt !== void 0) {
2079
2809
  const remainingRefreshTokenLifetime = grantData.expiresAt - now;
2080
2810
  if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
2081
2811
  }
2082
- 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
+ });
2083
2816
  const accessTokenExpiresAt = now + accessTokenTTL;
2084
2817
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
2085
2818
  const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
@@ -2089,6 +2822,7 @@ var OAuthProviderImpl = class {
2089
2822
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
2090
2823
  grantData.refreshTokenId = newRefreshTokenId;
2091
2824
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
2825
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
2092
2826
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
2093
2827
  const accessTokenData = {
2094
2828
  id: accessTokenId,
@@ -2116,9 +2850,9 @@ var OAuthProviderImpl = class {
2116
2850
  token_type: "bearer",
2117
2851
  expires_in: accessTokenTTL,
2118
2852
  refresh_token: newRefreshToken,
2119
- scope: tokenScopes.join(" ")
2853
+ scope: tokenScopes.join(" "),
2854
+ resource: audience
2120
2855
  };
2121
- if (audience) tokenResponse.resource = audience;
2122
2856
  return new Response(JSON.stringify(tokenResponse), { headers: {
2123
2857
  "Content-Type": "application/json",
2124
2858
  ...NO_CACHE_HEADERS
@@ -2132,7 +2866,7 @@ var OAuthProviderImpl = class {
2132
2866
  * module-private.
2133
2867
  * @param subjectToken - The subject token to exchange
2134
2868
  * @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
2135
- * @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
2136
2870
  * @param expiresIn - Optional TTL override in seconds
2137
2871
  * @param clientInfo - The client making the exchange request
2138
2872
  * @param env - Cloudflare Worker environment variables
@@ -2141,24 +2875,61 @@ var OAuthProviderImpl = class {
2141
2875
  */
2142
2876
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
2143
2877
  const tokenSummary = await this.unwrapToken(subjectToken, env);
2144
- 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
+ });
2145
2885
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
2146
2886
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2147
- 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();
2148
2903
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
2149
- const configuredResource = this.options.resourceMetadata?.resource;
2150
- if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
2151
- const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
2904
+ const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
2152
2905
  const now = Math.floor(Date.now() / 1e3);
2153
2906
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
2154
- 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
+ });
2155
2914
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
2156
2915
  if (expiresIn !== void 0) {
2157
- 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
+ });
2158
2923
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
2159
2924
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
2160
2925
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
2161
- 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
+ });
2162
2933
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
2163
2934
  let accessTokenEncryptionKey = encryptionKey;
2164
2935
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -2167,13 +2938,16 @@ var OAuthProviderImpl = class {
2167
2938
  const callbackOptions = {
2168
2939
  grantType: GrantType.TOKEN_EXCHANGE,
2169
2940
  clientId: clientInfo.clientId,
2941
+ subjectClientId: grantData.clientId,
2170
2942
  userId: tokenSummary.userId,
2171
2943
  grantId: tokenSummary.grantId,
2172
2944
  scope: tokenSummary.grant.scope,
2173
2945
  requestedScope: tokenScopes,
2946
+ resource: newAudience,
2174
2947
  props: decryptedProps
2175
2948
  };
2176
2949
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
2950
+ if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
2177
2951
  if (callbackResult) {
2178
2952
  let accessTokenProps = decryptedProps;
2179
2953
  if (callbackResult.newProps) {
@@ -2181,6 +2955,13 @@ var OAuthProviderImpl = class {
2181
2955
  }
2182
2956
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2183
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
+ });
2184
2965
  if (accessTokenProps !== decryptedProps) {
2185
2966
  const tokenResult = await encryptProps(accessTokenProps);
2186
2967
  encryptedAccessTokenProps = tokenResult.encryptedData;
@@ -2189,8 +2970,14 @@ var OAuthProviderImpl = class {
2189
2970
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
2190
2971
  }
2191
2972
  }
2192
- if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
2193
- 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 {
2194
2981
  access_token: await this.createAccessToken({
2195
2982
  userId: tokenSummary.userId,
2196
2983
  grantId: tokenSummary.grantId,
@@ -2205,10 +2992,9 @@ var OAuthProviderImpl = class {
2205
2992
  issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
2206
2993
  token_type: "bearer",
2207
2994
  expires_in: accessTokenTTL,
2208
- scope: tokenScopes.join(" ")
2995
+ scope: tokenScopes.join(" "),
2996
+ resource: newAudience
2209
2997
  };
2210
- if (newAudience) tokenResponse.resource = newAudience;
2211
- return tokenResponse;
2212
2998
  }
2213
2999
  /**
2214
3000
  * Handles OAuth 2.0 token exchange requests (RFC 8693)
@@ -2224,18 +3010,36 @@ var OAuthProviderImpl = class {
2224
3010
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
2225
3011
  const requestedScope = body.scope;
2226
3012
  const requestedResource = body.resource;
2227
- if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
2228
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
2229
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
2230
- 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
+ });
2231
3029
  let requestedScopes;
2232
3030
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
2233
3031
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
2234
- 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
+ });
2235
3036
  let expiresIn;
2236
3037
  if (body.expires_in !== void 0) {
2237
3038
  const requestedTTL = parseInt(body.expires_in, 10);
2238
- 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
+ });
2239
3043
  expiresIn = requestedTTL;
2240
3044
  }
2241
3045
  try {
@@ -2260,10 +3064,16 @@ var OAuthProviderImpl = class {
2260
3064
  */
2261
3065
  async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
2262
3066
  const enterpriseOptions = this.options.enterpriseManagedAuthorization;
2263
- 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
+ });
2264
3071
  if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
2265
3072
  description: "Enterprise-managed authorization requires client authentication",
2266
3073
  statusCode: 401
3074
+ }, {
3075
+ category: "enterprise-managed-authorization",
3076
+ reason: "client_authentication_required"
2267
3077
  });
2268
3078
  const result = await this.runEmaPipeline({
2269
3079
  body,
@@ -2298,8 +3108,8 @@ var OAuthProviderImpl = class {
2298
3108
  async runEmaPipeline(args) {
2299
3109
  const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
2300
3110
  const { jwksProvider, jtiStore } = this;
2301
- const configuredResource = this.options.resourceMetadata?.resource;
2302
- 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");
2303
3113
  const now = Math.floor(Date.now() / 1e3);
2304
3114
  const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
2305
3115
  if (!parsed.ok) return parsed;
@@ -2329,7 +3139,6 @@ var OAuthProviderImpl = class {
2329
3139
  expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
2330
3140
  clientId: clientInfo.clientId,
2331
3141
  configuredResource,
2332
- matchOriginOnly: !!this.options.resourceMatchOriginOnly,
2333
3142
  now,
2334
3143
  clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
2335
3144
  maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
@@ -2351,7 +3160,7 @@ var OAuthProviderImpl = class {
2351
3160
  mapperOutput = await enterpriseOptions.mapClaims({
2352
3161
  claims: claims.value.claims,
2353
3162
  clientInfo,
2354
- resource: claims.value.resource,
3163
+ resource: configuredResource,
2355
3164
  requestedScope: requestedScope.value,
2356
3165
  request: args.request,
2357
3166
  env
@@ -2377,7 +3186,7 @@ var OAuthProviderImpl = class {
2377
3186
  mapperProps: mapped.value.props,
2378
3187
  mapperMetadata: mapped.value.metadata,
2379
3188
  assertionScopes: claims.value.assertionScopes,
2380
- resource: claims.value.resource,
3189
+ resource: configuredResource,
2381
3190
  accessTokenTTLSeconds: ttl.value,
2382
3191
  env,
2383
3192
  now: issueNow
@@ -2476,7 +3285,10 @@ var OAuthProviderImpl = class {
2476
3285
  async revokeToken(body, clientInfo, env) {
2477
3286
  const token = body.token;
2478
3287
  const tokenTypeHint = body.token_type_hint;
2479
- 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
+ });
2480
3292
  const tokenParts = token.split(":");
2481
3293
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
2482
3294
  const [userId, grantId, _] = tokenParts;
@@ -2531,14 +3343,24 @@ var OAuthProviderImpl = class {
2531
3343
  if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
2532
3344
  description: "Client registration is not enabled",
2533
3345
  statusCode: 501
3346
+ }, {
3347
+ category: "client-registration",
3348
+ reason: "registration_disabled"
2534
3349
  });
2535
3350
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
2536
3351
  description: "Method not allowed",
2537
- statusCode: 405
3352
+ statusCode: 405,
3353
+ headers: { Allow: "POST, OPTIONS" }
3354
+ }, {
3355
+ category: "client-registration",
3356
+ reason: "method_not_allowed"
2538
3357
  });
2539
3358
  if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
2540
3359
  description: "Request payload too large, must be under 1 MiB",
2541
3360
  statusCode: 413
3361
+ }, {
3362
+ category: "client-registration",
3363
+ reason: "declared_payload_too_large"
2542
3364
  });
2543
3365
  const callbackRequest = request.clone();
2544
3366
  let parsedJson;
@@ -2547,12 +3369,18 @@ var OAuthProviderImpl = class {
2547
3369
  if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
2548
3370
  description: "Request payload too large, must be under 1 MiB",
2549
3371
  statusCode: 413
3372
+ }, {
3373
+ category: "client-registration",
3374
+ reason: "payload_too_large"
2550
3375
  });
2551
3376
  parsedJson = JSON.parse(text);
2552
3377
  } catch {
2553
3378
  return this.createErrorResponse("invalid_request", {
2554
3379
  description: "Invalid JSON payload",
2555
3380
  statusCode: 400
3381
+ }, {
3382
+ category: "client-registration",
3383
+ reason: "json_malformed"
2556
3384
  });
2557
3385
  }
2558
3386
  let clientMetadata;
@@ -2561,11 +3389,18 @@ var OAuthProviderImpl = class {
2561
3389
  clientMetadata = requireJsonObject(parsedJson);
2562
3390
  metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
2563
3391
  } catch (error) {
2564
- 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
+ });
2565
3397
  }
2566
3398
  const authMethod = metadata.tokenEndpointAuthMethod;
2567
3399
  const isPublicClient = authMethod === "none";
2568
- 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
+ });
2569
3404
  const clientId = generateRandomString(16);
2570
3405
  let clientSecret;
2571
3406
  let hashedSecret;
@@ -2602,16 +3437,22 @@ var OAuthProviderImpl = class {
2602
3437
  return this.createErrorResponse("server_error", {
2603
3438
  description: error instanceof Error ? error.message : "Client registration callback failed",
2604
3439
  statusCode: 500
3440
+ }, {
3441
+ category: "client-registration",
3442
+ reason: "callback_failed",
3443
+ detail: error
2605
3444
  });
2606
3445
  }
2607
3446
  if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
2608
3447
  description: callbackResult.description || "Client registration denied",
2609
3448
  statusCode: callbackResult.status ?? 400
3449
+ }, {
3450
+ category: "client-registration",
3451
+ reason: "callback_denied",
3452
+ detail: callbackResult
2610
3453
  });
2611
3454
  }
2612
- const clientKvOptions = {};
2613
- if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
2614
- await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
3455
+ await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
2615
3456
  const response = {
2616
3457
  client_id: clientInfo.clientId,
2617
3458
  redirect_uris: clientInfo.redirectUris,
@@ -2650,18 +3491,23 @@ var OAuthProviderImpl = class {
2650
3491
  * @param ctx - Cloudflare Worker execution context
2651
3492
  * @returns Response from the API handler or error
2652
3493
  */
2653
- async handleApiRequest(request, env, ctx) {
3494
+ async handleApiRequest(request, env, ctx, apiRoute) {
2654
3495
  const url = new URL(request.url);
2655
- 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);
2656
3501
  const authHeader = request.headers.get("Authorization");
2657
- 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, {
2658
3504
  status: 401,
2659
3505
  headers: {
2660
3506
  ...NO_CACHE_HEADERS,
2661
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
3507
+ "WWW-Authenticate": challenge()
2662
3508
  }
2663
3509
  });
2664
- const accessToken = authHeader.substring(7);
3510
+ const accessToken = bearerMatch[1];
2665
3511
  const parts = accessToken.split(":");
2666
3512
  const isPossiblyInternalFormat = parts.length === 3;
2667
3513
  let tokenData = null;
@@ -2672,75 +3518,104 @@ var OAuthProviderImpl = class {
2672
3518
  const id = await generateTokenId(accessToken);
2673
3519
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
2674
3520
  }
2675
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
3521
+ if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
2676
3522
  description: "Invalid access token",
2677
3523
  statusCode: 401,
2678
- 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"
2679
3528
  });
2680
3529
  if (tokenData) {
2681
- const configuredResource = this.options.resourceMetadata?.resource;
2682
- 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", {
2683
3532
  description: "Access token is not bound to the configured resource",
2684
3533
  statusCode: 401,
2685
- 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"
2686
3538
  });
2687
3539
  const now = Math.floor(Date.now() / 1e3);
2688
3540
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2689
3541
  description: "Access token expired",
2690
3542
  statusCode: 401,
2691
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3543
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3544
+ }, {
3545
+ category: "protected-resource",
3546
+ reason: "token_expired"
2692
3547
  });
2693
- if (tokenData.audience) {
3548
+ if (tokenAudience) {
2694
3549
  const requestUrl = new URL(request.url);
2695
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2696
- 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", {
2697
3552
  description: "Token audience does not match resource server",
2698
3553
  statusCode: 401,
2699
- 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"
2700
3558
  });
2701
3559
  }
2702
3560
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2703
- } 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) {
2704
3570
  let ext;
2705
3571
  try {
2706
- ext = await this.options.resolveExternalToken({
3572
+ ext = await externalTokenResolver({
2707
3573
  token: accessToken,
2708
3574
  request,
2709
3575
  env
2710
3576
  });
2711
3577
  } catch (error) {
2712
- const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
3578
+ const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
2713
3579
  if (response) return response;
2714
3580
  throw error;
2715
3581
  }
2716
3582
  if (!ext) return this.createErrorResponse("invalid_token", {
2717
3583
  description: "Invalid access token",
2718
3584
  statusCode: 401,
2719
- 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"
2720
3589
  });
2721
- const configuredResource = this.options.resourceMetadata?.resource;
2722
- 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", {
2723
3591
  description: "External access token is not bound to the configured resource",
2724
3592
  statusCode: 401,
2725
- 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"
2726
3597
  });
2727
3598
  if (ext.audience) {
2728
3599
  const requestUrl = new URL(request.url);
2729
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2730
- 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", {
2731
3602
  description: "Token audience does not match resource server",
2732
3603
  statusCode: 401,
2733
- 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"
2734
3608
  });
2735
3609
  }
2736
3610
  ctx.props = ext.props;
3611
+ ctx.auth = {
3612
+ token: accessToken,
3613
+ audience: configuredResource,
3614
+ scope: []
3615
+ };
2737
3616
  }
2738
3617
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
2739
- const apiHandler = this.findApiHandlerForUrl(url);
2740
- if (!apiHandler) return this.createErrorResponse("invalid_request", {
2741
- description: "No handler found for API route",
2742
- statusCode: 404
2743
- });
3618
+ const apiHandler = apiRoute.handler;
2744
3619
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
2745
3620
  else return new apiHandler.handler(ctx, env).fetch(request);
2746
3621
  }
@@ -2764,7 +3639,7 @@ var OAuthProviderImpl = class {
2764
3639
  const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
2765
3640
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
2766
3641
  try {
2767
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
3642
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
2768
3643
  } catch (error) {
2769
3644
  this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
2770
3645
  throw error;
@@ -2775,7 +3650,12 @@ var OAuthProviderImpl = class {
2775
3650
  throw new OAuthError("temporarily_unavailable", {
2776
3651
  description: "Token issuance is temporarily unavailable; retry shortly",
2777
3652
  statusCode: 429,
2778
- headers: { "Retry-After": "30" }
3653
+ headers: { "Retry-After": "30" },
3654
+ internal: {
3655
+ category: "token-issuance",
3656
+ reason: "kv_rate_limited",
3657
+ detail: error
3658
+ }
2779
3659
  });
2780
3660
  }
2781
3661
  isKvRateLimitError(error) {
@@ -2798,6 +3678,51 @@ var OAuthProviderImpl = class {
2798
3678
  * propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
2799
3679
  * upstream outage is distinguishable from an unregistered client.
2800
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
+ }
2801
3726
  async getClient(env, clientId) {
2802
3727
  if (this.isClientMetadataUrl(clientId)) {
2803
3728
  if (!this.options.clientIdMetadataDocumentEnabled) {
@@ -2815,33 +3740,140 @@ var OAuthProviderImpl = class {
2815
3740
  const clientKey = `client:${clientId}`;
2816
3741
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2817
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
+ }
2818
3828
  /**
2819
3829
  * Resolves an access-token audience from a token request and its authorization grant.
2820
- * A configured canonical resource is inherited when omitted but cannot be overridden.
2821
- * Without configuration, RFC 8707 downscoping is allowed, omission inherits a
2822
- * 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.
2823
3831
  */
2824
- resolveTokenResource(requestedResource, grantedResource) {
3832
+ resolveTokenResource(requestedResource, grant) {
3833
+ const grantedResource = grant.resource;
2825
3834
  const resourceWasProvided = requestedResource !== void 0;
2826
- const requestedAudience = parseResourceParameter(requestedResource);
2827
- if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2828
3835
  const grantResourceWasStored = grantedResource !== void 0;
2829
- const grantedAudience = parseResourceParameter(grantedResource);
2830
- if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2831
- const configuredResource = this.options.resourceMetadata?.resource;
2832
- if (configuredResource) {
2833
- if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2834
- if (isExactResource(grantedResource, configuredResource)) return configuredResource;
2835
- if (!grantResourceWasStored) return configuredResource;
2836
- throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
2837
- }
2838
- const originOnly = !!this.options.resourceMatchOriginOnly;
2839
- if (resourceWasProvided && grantResourceWasStored) {
2840
- const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2841
- const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2842
- 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
+ };
2843
3857
  }
2844
- 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
+ };
2845
3877
  }
2846
3878
  /**
2847
3879
  * Creates and stores an access token
@@ -2850,7 +3882,13 @@ var OAuthProviderImpl = class {
2850
3882
  */
2851
3883
  async createAccessToken(params) {
2852
3884
  const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
2853
- 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
+ });
2854
3892
  const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2855
3893
  const now = Math.floor(Date.now() / 1e3);
2856
3894
  const accessTokenId = await generateTokenId(accessToken);
@@ -2906,14 +3944,36 @@ var OAuthProviderImpl = class {
2906
3944
  /**
2907
3945
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2908
3946
  */
2909
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
2910
- 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}"`;
2911
3950
  if (error) header += `, error="${error}"`;
2912
- const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
3951
+ const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
2913
3952
  if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
2914
3953
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2915
3954
  return header;
2916
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
+ }
2917
3977
  /**
2918
3978
  * Helper function to create OAuth error responses.
2919
3979
  *
@@ -2934,21 +3994,22 @@ var OAuthProviderImpl = class {
2934
3994
  description,
2935
3995
  status: responseStatus,
2936
3996
  headers: responseHeaders,
2937
- ...internal ? { internal } : {},
3997
+ internal,
2938
3998
  ...request ? { request } : {}
2939
3999
  });
2940
- if (customErrorResponse) return customErrorResponse;
2941
4000
  const body = JSON.stringify({
2942
4001
  error: code,
2943
4002
  error_description: description
2944
4003
  });
2945
- return new Response(body, {
4004
+ const response = customErrorResponse ?? new Response(body, {
2946
4005
  status: responseStatus,
2947
4006
  headers: {
2948
4007
  "Content-Type": "application/json",
2949
4008
  ...responseHeaders
2950
4009
  }
2951
4010
  });
4011
+ this.errorResponses.add(response);
4012
+ return response;
2952
4013
  }
2953
4014
  };
2954
4015
  /**
@@ -3079,6 +4140,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
3079
4140
  * to clamp absolute expirations when writing grants back to KV.
3080
4141
  */
3081
4142
  const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
4143
+ function isValidAccessTokenTTL(value) {
4144
+ return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
4145
+ }
3082
4146
  /**
3083
4147
  * Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
3084
4148
  * absolute KV expiration. Absolute expirations are validated against KV's clock at the
@@ -3097,8 +4161,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
3097
4161
  */
3098
4162
  const MAX_KV_LIST_LIMIT = 1e3;
3099
4163
  /**
3100
- * Default batch size for paginating existing grants when revoking them
3101
- * 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.
3102
4167
  */
3103
4168
  const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
3104
4169
  function getRevokeExistingGrantsBatchSize(batchSize) {
@@ -3106,27 +4171,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
3106
4171
  if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
3107
4172
  return Math.min(batchSize, MAX_KV_LIST_LIMIT);
3108
4173
  }
4174
+ /** KV serialises key metadata as JSON and rejects a write carrying more than this. */
4175
+ const MAX_KV_METADATA_BYTES = 1024;
3109
4176
  /**
3110
- * 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.
3111
4181
  */
3112
- 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
+ }
3113
4202
  /**
3114
- * Validates a resource URI per RFC 8707 Section 2
3115
- * @param uri - The URI string to validate
3116
- * @returns true if valid, false otherwise
4203
+ * Length of generated token strings
3117
4204
  */
3118
- function validateResourceUri(uri) {
3119
- if (!uri || typeof uri !== "string") return false;
3120
- try {
3121
- const parsed = new URL(uri);
3122
- if (!parsed.protocol) return false;
3123
- if (parsed.hash) return false;
3124
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3125
- return true;
3126
- } catch {
3127
- return false;
3128
- }
3129
- }
4205
+ const TOKEN_LENGTH = 32;
3130
4206
  /**
3131
4207
  * Checks if a resource server matches an audience claim.
3132
4208
  * Uses origin comparison (case-insensitive hostname via URL normalization)
@@ -3140,42 +4216,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
3140
4216
  const resource = new URL(resourceServerUrl);
3141
4217
  const audience = new URL(audienceValue);
3142
4218
  if (resource.origin !== audience.origin) return false;
3143
- if (audience.search && resource.search !== audience.search) return false;
4219
+ if (!requestCarriesResourceQuery(resource, audience)) return false;
3144
4220
  if (audience.pathname === "/" || audience.pathname === "") return true;
3145
- 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);
3146
4223
  } catch {
3147
4224
  return false;
3148
4225
  }
3149
4226
  }
3150
- /**
3151
- * Parses and validates the resource parameter from a token request (RFC 8707)
3152
- * Handles single string or array of strings (from multiple form parameters)
3153
- * @param value - The resource parameter value from the request body
3154
- * @returns The validated value as string, string array, or undefined if validation fails
3155
- */
3156
- function parseResourceParameter(value) {
3157
- if (!value) return;
3158
- const uris = Array.isArray(value) ? value : [value];
3159
- if (uris.length === 0) return;
3160
- for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
3161
- 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(", "));
3162
4231
  }
3163
- /** Whether a request or audience names one exact configured resource. */
4232
+ /** Whether a request or audience names one canonical configured resource. */
3164
4233
  function isExactResource(value, configuredResource) {
3165
- 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);
3166
4236
  }
3167
- /**
3168
- * Checks if a requested resource matches a granted resource.
3169
- * When originOnly is true, compares only the origin (scheme + host + port),
3170
- * allowing path-aware resources to match origin-only grants.
3171
- */
3172
- function resourceMatches(requested, granted, originOnly) {
3173
- if (!originOnly) return requested === granted;
3174
- try {
3175
- return new URL(requested).origin === new URL(granted).origin;
3176
- } catch {
3177
- return requested === granted;
3178
- }
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}/`);
3179
4250
  }
3180
4251
  /**
3181
4252
  * Hashes a secret value using SHA-256
@@ -3246,11 +4317,7 @@ async function generateTokenId(token) {
3246
4317
  */
3247
4318
  function isLoopbackUri(uri) {
3248
4319
  try {
3249
- const host = new URL(uri).hostname;
3250
- if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
3251
- if (host === "::1" || host === "[::1]") return true;
3252
- if (host.toLowerCase() === "localhost") return true;
3253
- return false;
4320
+ return isLoopbackHostname(new URL(uri).hostname);
3254
4321
  } catch {
3255
4322
  return false;
3256
4323
  }
@@ -3480,6 +4547,7 @@ var OAuthHelpersImpl = class {
3480
4547
  */
3481
4548
  async parseAuthRequest(request) {
3482
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" });
3483
4551
  const responseType = url.searchParams.get("response_type") || "";
3484
4552
  const clientId = url.searchParams.get("client_id") || "";
3485
4553
  const redirectUri = url.searchParams.get("redirect_uri") || "";
@@ -3502,13 +4570,12 @@ var OAuthHelpersImpl = class {
3502
4570
  const withRedirect = (error) => {
3503
4571
  throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3504
4572
  };
3505
- const resourceWasProvided = resourceParam !== void 0;
3506
- let resource = parseResourceParameter(resourceParam);
3507
- if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3508
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3509
- if (configuredResource) {
3510
- if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
3511
- 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;
3512
4579
  }
3513
4580
  try {
3514
4581
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
@@ -3563,26 +4630,13 @@ var OAuthHelpersImpl = class {
3563
4630
  const clientInfo = await this.lookupClient(clientId);
3564
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.");
3565
4632
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3566
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3567
- const resourceWasProvided = options.request.resource !== void 0;
3568
- const parsedResource = parseResourceParameter(options.request.resource);
3569
- if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
3570
- if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
3571
- const effectiveResource = configuredResource ?? parsedResource;
4633
+ const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
3572
4634
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3573
4635
  let grantsToRevoke = [];
3574
4636
  if (options.revokeExistingGrants !== false) {
3575
4637
  const isCimdClient = this.provider.isClientMetadataUrl(clientId);
3576
- const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
3577
- let cursor;
3578
- do {
3579
- const page = await this.listUserGrants(options.userId, {
3580
- cursor,
3581
- limit: batchSize
3582
- });
3583
- for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
3584
- cursor = page.cursor;
3585
- } 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);
3586
4640
  }
3587
4641
  const grantId = generateRandomString(16);
3588
4642
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
@@ -3594,8 +4648,7 @@ var OAuthHelpersImpl = class {
3594
4648
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3595
4649
  const accessTokenExpiresAt = now + accessTokenTTL;
3596
4650
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
3597
- const audience = parseResourceParameter(effectiveResource);
3598
- if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
4651
+ const audience = effectiveResource;
3599
4652
  const grant = {
3600
4653
  id: grantId,
3601
4654
  clientId: options.request.clientId,
@@ -3608,7 +4661,7 @@ var OAuthHelpersImpl = class {
3608
4661
  redirectUri: options.request.redirectUri
3609
4662
  };
3610
4663
  const grantKey = `grant:${options.userId}:${grantId}`;
3611
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
4664
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
3612
4665
  const accessTokenData = {
3613
4666
  id: accessTokenId,
3614
4667
  grantId,
@@ -3631,6 +4684,7 @@ var OAuthHelpersImpl = class {
3631
4684
  fragment.set("token_type", "bearer");
3632
4685
  fragment.set("expires_in", accessTokenTTL.toString());
3633
4686
  fragment.set("scope", options.scope.join(" "));
4687
+ fragment.set("resource", effectiveResource);
3634
4688
  if (options.request.state) fragment.set("state", options.request.state);
3635
4689
  if (options.request.issuer) fragment.set("iss", options.request.issuer);
3636
4690
  redirectUrl.hash = fragment.toString();
@@ -3659,7 +4713,7 @@ var OAuthHelpersImpl = class {
3659
4713
  redirectUri: options.request.redirectUri
3660
4714
  };
3661
4715
  const grantKey = `grant:${options.userId}:${grantId}`;
3662
- 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 }));
3663
4717
  const redirectUrl = new URL(options.request.redirectUri);
3664
4718
  redirectUrl.searchParams.set("code", authCode);
3665
4719
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
@@ -3671,6 +4725,39 @@ var OAuthHelpersImpl = class {
3671
4725
  }
3672
4726
  }
3673
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
+ /**
3674
4761
  * Creates a new OAuth client
3675
4762
  * @param clientInfo - Partial client information to create the client with
3676
4763
  * @returns A Promise resolving to the created client info
@@ -3762,9 +4849,7 @@ var OAuthHelpersImpl = class {
3762
4849
  };
3763
4850
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
3764
4851
  else delete updatedClient.clientSecret;
3765
- const clientKvOptions = {};
3766
- if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
3767
- 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));
3768
4853
  const response = toPublicClientInfo(updatedClient);
3769
4854
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
3770
4855
  return response;
@@ -3814,7 +4899,8 @@ var OAuthHelpersImpl = class {
3814
4899
  metadata: grantData.metadata,
3815
4900
  createdAt: grantData.createdAt,
3816
4901
  expiresAt: grantData.expiresAt,
3817
- redirectUri: grantData.redirectUri
4902
+ redirectUri: grantData.redirectUri,
4903
+ resource: grantData.resource
3818
4904
  };
3819
4905
  grantSummaries.push(summary);
3820
4906
  }
@@ -3964,4 +5050,4 @@ var OAuthHelpersImpl = class {
3964
5050
  var oauth_provider_default = OAuthProvider;
3965
5051
 
3966
5052
  //#endregion
3967
- 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 };