@cloudflare/workers-oauth-provider 0.10.4 → 1.1.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.
@@ -678,7 +678,7 @@ function emaErrorToWire(e) {
678
678
  * only through the adapters' public interfaces.
679
679
  */
680
680
  /** SHA-256 a string and return its hex digest. */
681
- async function sha256Hex(input) {
681
+ async function sha256Hex$1(input) {
682
682
  const data = new TextEncoder().encode(input);
683
683
  const buffer = await crypto.subtle.digest("SHA-256", data);
684
684
  return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -701,7 +701,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
701
701
  function createKvJtiStore() {
702
702
  return { async markUsed({ issuer, jti, exp, now, env }) {
703
703
  const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
704
- const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
704
+ const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
705
705
  if (await env.OAUTH_KV.get(key)) return err({
706
706
  reason: "replayed",
707
707
  jti
@@ -911,6 +911,96 @@ async function verifyIdJagSignature(input) {
911
911
  }
912
912
  }
913
913
 
914
+ //#endregion
915
+ //#region src/oauth-resource.ts
916
+ /**
917
+ * Loopback hosts where a plain `http` scheme is accepted for local development.
918
+ * Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
919
+ */
920
+ function isLoopbackHostname(hostname) {
921
+ const host = hostname.toLowerCase();
922
+ if (host === "localhost" || host === "::1" || host === "[::1]") return true;
923
+ const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
924
+ return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
925
+ }
926
+ /**
927
+ * Whether a canonical resource, issuer, or endpoint URL uses an accepted
928
+ * scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
929
+ * http://localhost. Workers are always served over `https`, so there is no
930
+ * deployment in which cleartext OAuth traffic to a remote host is correct,
931
+ * and no option exists to allow it.
932
+ */
933
+ function hasAcceptedCanonicalScheme(url) {
934
+ return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
935
+ }
936
+ /** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
937
+ function validateResourceUri(uri) {
938
+ if (!uri || typeof uri !== "string") return false;
939
+ if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
940
+ try {
941
+ const parsed = new URL(uri);
942
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
943
+ } catch {
944
+ return false;
945
+ }
946
+ }
947
+ /**
948
+ * Whether a requested resource identifies a granted or configured resource.
949
+ * Only ASCII case in the URI scheme and host is ignored, and an empty path is
950
+ * equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
951
+ * `https://example.com` through a URL parser as `https://example.com/` still
952
+ * names it. Port, path, query, a trailing slash after a path segment, user
953
+ * information, and every other byte remain significant.
954
+ */
955
+ function resourceMatches(requested, granted) {
956
+ const foldedRequested = foldResourceSchemeAndHost(requested);
957
+ const foldedGranted = foldResourceSchemeAndHost(granted);
958
+ if (foldedRequested === void 0 || foldedGranted === void 0) return false;
959
+ return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
960
+ }
961
+ /**
962
+ * Fold only the URI components whose comparison is ASCII case-insensitive: the
963
+ * scheme and the host. A configured identifier is canonical when folding it is
964
+ * the identity.
965
+ */
966
+ function foldResourceSchemeAndHost(resource) {
967
+ const schemeSeparator = resource.indexOf("://");
968
+ if (schemeSeparator <= 0) return void 0;
969
+ const authorityStart = schemeSeparator + 3;
970
+ const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
971
+ const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
972
+ const authority = resource.slice(authorityStart, authorityEnd);
973
+ const hostStart = authority.lastIndexOf("@") + 1;
974
+ let hostEnd;
975
+ if (authority[hostStart] === "[") {
976
+ const closingBracket = authority.indexOf("]", hostStart + 1);
977
+ if (closingBracket === -1) return void 0;
978
+ hostEnd = closingBracket + 1;
979
+ } else {
980
+ const portSeparator = authority.indexOf(":", hostStart);
981
+ hostEnd = portSeparator === -1 ? authority.length : portSeparator;
982
+ }
983
+ const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
984
+ return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
985
+ }
986
+ /** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
987
+ function normalizeEmptyPath(resource) {
988
+ const authorityStart = resource.indexOf("://") + 3;
989
+ const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
990
+ if (pathOffset === -1) return `${resource}/`;
991
+ const pathStart = authorityStart + pathOffset;
992
+ return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
993
+ }
994
+ /**
995
+ * Whether a request carries every query parameter of a canonical resource. A
996
+ * query-bearing resource identifier names a more specific resource: a request
997
+ * may add parameters of its own but must preserve the resource's.
998
+ */
999
+ function requestCarriesResourceQuery(requestUrl, resourceUrl) {
1000
+ for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
1001
+ return true;
1002
+ }
1003
+
914
1004
  //#endregion
915
1005
  //#region src/ema/validators.ts
916
1006
  /**
@@ -1036,7 +1126,7 @@ function isWellFormedTrustedIssuer(issuer) {
1036
1126
  * - `scope` (if present) conforms to RFC 6749 §3.3 grammar
1037
1127
  */
1038
1128
  function validateIdJagClaims(input) {
1039
- const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource, matchOriginOnly } = input;
1129
+ const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource } = input;
1040
1130
  const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
1041
1131
  const iss = readRequiredString(rawClaims, "iss");
1042
1132
  if (!iss.ok) return iss;
@@ -1076,7 +1166,7 @@ function validateIdJagClaims(input) {
1076
1166
  reason: "resource_invalid",
1077
1167
  resource: resource.value
1078
1168
  });
1079
- if (!resourceMatches(resource.value, configuredResource, matchOriginOnly)) return err({
1169
+ if (!resourceMatches(resource.value, configuredResource)) return err({
1080
1170
  reason: "resource_mismatch",
1081
1171
  expected: configuredResource,
1082
1172
  got: resource.value
@@ -1228,6 +1318,548 @@ function readNumericDateClaim(claims, claimName) {
1228
1318
  return ok(value);
1229
1319
  }
1230
1320
 
1321
+ //#endregion
1322
+ //#region src/oauth-resource-server.ts
1323
+ const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 = "/.well-known/oauth-protected-resource";
1324
+ const NO_CACHE_HEADERS$1 = {
1325
+ "Cache-Control": "no-store",
1326
+ Pragma: "no-cache"
1327
+ };
1328
+ /**
1329
+ * The response for a valid token that lacks the scopes an operation needs (RFC 6750 §3.1,
1330
+ * MCP scope challenge handling): `403` with `WWW-Authenticate: Bearer error="insufficient_scope"`,
1331
+ * the `scope` the operation requires and the `resource_metadata` URL the client already knows.
1332
+ * Name every scope the operation needs in one response; clients treat the list as complete.
1333
+ *
1334
+ * ```ts
1335
+ * if (!ctx.auth.scope.includes('calendar:write')) return insufficientScope(ctx.auth, ['calendar:write']);
1336
+ * ```
1337
+ */
1338
+ function insufficientScope(auth, scope, description) {
1339
+ if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => !isValidScopeToken(value))) throw new TypeError("insufficientScope requires at least one valid OAuth scope token");
1340
+ let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
1341
+ challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
1342
+ if (description !== void 0) {
1343
+ const quotable = description.replace(/["\\]/g, "").replace(/[^\x21-\x7e]+/g, " ").trim();
1344
+ if (quotable) challenge += `, error_description="${quotable}"`;
1345
+ }
1346
+ return new Response(JSON.stringify({
1347
+ error: "insufficient_scope",
1348
+ ...description !== void 0 ? { error_description: description } : {}
1349
+ }), {
1350
+ status: 403,
1351
+ headers: {
1352
+ ...NO_CACHE_HEADERS$1,
1353
+ "Content-Type": "application/json",
1354
+ "WWW-Authenticate": challenge
1355
+ }
1356
+ });
1357
+ }
1358
+ /**
1359
+ * One OAuth protected resource, in the authorization server's Worker or its own. Publishes
1360
+ * RFC 9728 metadata, challenges unauthenticated requests, validates the bearer token through
1361
+ * the configured `validateToken`, enforces audience and expiry on what it returns, and routes
1362
+ * only the canonical resource and its descendants to the application handler.
1363
+ *
1364
+ * `export default new OAuthResourceServer({ … })`, or dispatch to `fetch()` from your own
1365
+ * router, exactly as with `OAuthAuthorizationServer`.
1366
+ */
1367
+ var OAuthResourceServer = class {
1368
+ #options;
1369
+ #validated;
1370
+ constructor(options) {
1371
+ this.#validated = validateOptions(options);
1372
+ this.#options = options;
1373
+ }
1374
+ async fetch(request, env, ctx) {
1375
+ const options = this.#options;
1376
+ const validated = this.#validated;
1377
+ const url = new URL(request.url);
1378
+ if (isProtectedResourceMetadataPath(url)) {
1379
+ if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1380
+ status: 204,
1381
+ headers: { "Content-Length": "0" }
1382
+ }), request);
1383
+ if (!isMetadataUrlRequest(url, validated.metadataUrl)) return addCorsHeaders(new Response(null, { status: 404 }), request);
1384
+ if (request.method !== "GET" && request.method !== "HEAD") return addCorsHeaders(new Response(null, {
1385
+ status: 405,
1386
+ headers: { Allow: "GET, HEAD, OPTIONS" }
1387
+ }), request);
1388
+ const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
1389
+ return addCorsHeaders(request.method === "HEAD" ? new Response(null, {
1390
+ status: 200,
1391
+ headers: metadata.headers
1392
+ }) : metadata, request);
1393
+ }
1394
+ if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
1395
+ if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
1396
+ status: 204,
1397
+ headers: { "Content-Length": "0" }
1398
+ }), request);
1399
+ const token = parseBearerToken(request.headers.get("Authorization"));
1400
+ if (!token) return addCorsHeaders(createBearerChallenge(url, validated, false), request);
1401
+ let validation;
1402
+ try {
1403
+ validation = await options.validateToken(env, request)(validated.resource, token);
1404
+ } catch {
1405
+ return addCorsHeaders(createValidationUnavailableResponse(), request);
1406
+ }
1407
+ if (!isValidTokenValidation(validation, validated.resource)) return addCorsHeaders(createBearerChallenge(url, validated, true), request);
1408
+ const context = ctx;
1409
+ context.props = validation.props;
1410
+ context.auth = {
1411
+ token,
1412
+ audience: validated.resource,
1413
+ ...validation.expiresAt !== void 0 ? { expiresAt: validation.expiresAt } : {},
1414
+ scope: [...validation.scope ?? []],
1415
+ ...validation.userId !== void 0 ? { userId: validation.userId } : {},
1416
+ ...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
1417
+ };
1418
+ const handler = options.handler;
1419
+ return addCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
1420
+ }
1421
+ };
1422
+ function validateOptions(options) {
1423
+ if (!options || typeof options !== "object") throw new TypeError("OAuth resource server options are required");
1424
+ if (!options.handler || !isEntrypointClass(options.handler) && typeof options.handler.fetch !== "function") throw new TypeError("handler must provide a fetch function or extend WorkerEntrypoint");
1425
+ if (typeof options.validateToken !== "function") throw new TypeError("validateToken must be a function");
1426
+ const resource = options.resourceMetadata?.resource;
1427
+ const resourceUrl = parseCanonicalUrl(resource);
1428
+ if (!resourceUrl) throw new TypeError("resourceMetadata.resource must be a canonical absolute HTTPS URI without a fragment (http is accepted only on a loopback host)");
1429
+ if (isProtectedResourceMetadataPath(resourceUrl)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1} namespace`);
1430
+ const authorizationServers = options.resourceMetadata.authorization_servers;
1431
+ if (!Array.isArray(authorizationServers) || authorizationServers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
1432
+ for (const issuer of authorizationServers) {
1433
+ const issuerUrl = parseCanonicalUrl(issuer);
1434
+ if (!issuerUrl || issuerUrl.search || issuerUrl.hash) throw new TypeError("resourceMetadata.authorization_servers must contain canonical HTTPS issuer URLs (http is accepted only on a loopback host)");
1435
+ }
1436
+ const bearerMethods = options.resourceMetadata.bearer_methods_supported;
1437
+ if (bearerMethods !== void 0 && (bearerMethods.length !== 1 || bearerMethods[0] !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
1438
+ const configuredScopes = options.resourceMetadata.scopes_supported ?? [];
1439
+ if (configuredScopes.some((scope) => !isValidScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
1440
+ const resourceScopes = [...new Set(configuredScopes)].filter((scope) => scope !== "offline_access");
1441
+ return {
1442
+ resource,
1443
+ resourceUrl,
1444
+ metadataUrl: new URL(getResourceMetadataUrl(resource)),
1445
+ metadata: {
1446
+ resource,
1447
+ authorization_servers: [...authorizationServers],
1448
+ ...resourceScopes.length ? { scopes_supported: resourceScopes } : {},
1449
+ bearer_methods_supported: bearerMethods ? [...bearerMethods] : ["header"],
1450
+ ...options.resourceMetadata.resource_name !== void 0 ? { resource_name: options.resourceMetadata.resource_name } : {}
1451
+ }
1452
+ };
1453
+ }
1454
+ function parseCanonicalUrl(value) {
1455
+ if (typeof value !== "string" || !validateResourceUri(value)) return null;
1456
+ let parsed;
1457
+ try {
1458
+ parsed = new URL(value);
1459
+ } catch {
1460
+ return null;
1461
+ }
1462
+ if (!hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.protocol !== parsed.protocol.toLowerCase() || parsed.hostname !== parsed.hostname.toLowerCase()) return null;
1463
+ if (parsed.href !== value && parsed.origin !== value) return null;
1464
+ return parsed;
1465
+ }
1466
+ function isValidScopeToken(scope) {
1467
+ return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
1468
+ }
1469
+ function getResourceMetadataUrl(resource) {
1470
+ const parsed = new URL(resource);
1471
+ const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
1472
+ return `${parsed.origin}${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}${suffix}${parsed.search}`;
1473
+ }
1474
+ /** The same test the combined provider applies to its handlers. */
1475
+ function isEntrypointClass(handler) {
1476
+ return typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint;
1477
+ }
1478
+ function isProtectedResourceMetadataPath(url) {
1479
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 || url.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}/`);
1480
+ }
1481
+ function isMetadataUrlRequest(requestUrl, metadataUrl) {
1482
+ return requestUrl.origin === metadataUrl.origin && requestUrl.pathname === metadataUrl.pathname && requestCarriesResourceQuery(requestUrl, metadataUrl);
1483
+ }
1484
+ function isCanonicalResourceRequest(requestUrl, resourceUrl) {
1485
+ if (requestUrl.origin !== resourceUrl.origin) return false;
1486
+ if (!requestCarriesResourceQuery(requestUrl, resourceUrl)) return false;
1487
+ const resourcePath = resourceUrl.pathname;
1488
+ if (requestUrl.pathname === resourcePath) return true;
1489
+ if (resourcePath === "/") return requestUrl.pathname.startsWith("/");
1490
+ const descendantPrefix = resourcePath.endsWith("/") ? resourcePath : `${resourcePath}/`;
1491
+ return requestUrl.pathname.startsWith(descendantPrefix);
1492
+ }
1493
+ function parseBearerToken(authorization) {
1494
+ if (!authorization) return null;
1495
+ return /^Bearer[\t ]+([^\s,]+)$/i.exec(authorization)?.[1] || null;
1496
+ }
1497
+ function isValidTokenValidation(validation, canonicalResource) {
1498
+ if (!validation || typeof validation !== "object") return false;
1499
+ if (typeof validation.audience !== "string" || !resourceMatches(validation.audience, canonicalResource)) return false;
1500
+ if (!Object.prototype.hasOwnProperty.call(validation, "props")) return false;
1501
+ if (validation.expiresAt !== void 0) {
1502
+ if (typeof validation.expiresAt !== "number" || !Number.isFinite(validation.expiresAt)) return false;
1503
+ if (validation.expiresAt <= Date.now() / 1e3) return false;
1504
+ }
1505
+ if (validation.scope !== void 0) {
1506
+ if (!Array.isArray(validation.scope)) return false;
1507
+ if (validation.scope.some((scope) => typeof scope !== "string" || !isValidScopeToken(scope))) return false;
1508
+ }
1509
+ if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
1510
+ if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
1511
+ return true;
1512
+ }
1513
+ function createBearerChallenge(requestUrl, validated, invalidToken) {
1514
+ let challenge = "Bearer realm=\"OAuth\"";
1515
+ if (isCanonicalResourceRequest(requestUrl, validated.resourceUrl)) challenge += `, resource_metadata="${validated.metadataUrl.href}"`;
1516
+ if (invalidToken) challenge += ", error=\"invalid_token\"";
1517
+ const scopes = validated.metadata.scopes_supported;
1518
+ if (scopes && scopes.length > 0) challenge += `, scope="${scopes.join(" ")}"`;
1519
+ return new Response(null, {
1520
+ status: 401,
1521
+ headers: {
1522
+ ...NO_CACHE_HEADERS$1,
1523
+ "WWW-Authenticate": challenge
1524
+ }
1525
+ });
1526
+ }
1527
+ function createValidationUnavailableResponse() {
1528
+ return new Response(null, {
1529
+ status: 503,
1530
+ headers: NO_CACHE_HEADERS$1
1531
+ });
1532
+ }
1533
+ function addCorsHeaders(response, request) {
1534
+ const origin = request.headers.get("Origin");
1535
+ if (!origin) return response;
1536
+ const withCors = new Response(response.body, response);
1537
+ withCors.headers.set("Access-Control-Allow-Origin", origin);
1538
+ withCors.headers.set("Access-Control-Allow-Methods", "*");
1539
+ withCors.headers.set("Access-Control-Allow-Headers", "Authorization, *");
1540
+ appendHeaderValue$1(withCors.headers, "Vary", "Origin");
1541
+ appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
1542
+ appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "Retry-After");
1543
+ withCors.headers.set("Access-Control-Max-Age", "86400");
1544
+ return withCors;
1545
+ }
1546
+ function appendHeaderValue$1(headers, name, value) {
1547
+ const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1548
+ if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
1549
+ headers.set(name, values.join(", "));
1550
+ }
1551
+
1552
+ //#endregion
1553
+ //#region src/oauth-consent.ts
1554
+ /**
1555
+ * Consent and upstream-state primitives for authorization servers that sign users in through a
1556
+ * third-party OAuth provider (an MCP "proxy" server). They implement the protections the MCP
1557
+ * 2026-07-28 security best practices require under Confused Deputy → Mitigation:
1558
+ *
1559
+ * - consent per client before any redirect to the third party, with CSRF protection and anti-framing
1560
+ * headers on the consent page;
1561
+ * - remembered consent, chosen per call, in a signed `__Host-` cookie bound to the client, its redirect
1562
+ * URI and resource (and the user, when the caller knows them), reused only for a subset of the
1563
+ * approved scopes;
1564
+ * - the approved scopes chosen on the consent page, from any the server supports;
1565
+ * - a random `state` stored server-side only after consent, bound to the browser by a `__Host-` cookie,
1566
+ * single-use and short-lived.
1567
+ *
1568
+ * A transaction handle is 256 random bits. KV stores the record under the handle's SHA-256, encrypted
1569
+ * with a key derived from the handle, so KV alone reveals neither the request nor deployer data such as
1570
+ * a PKCE verifier. Each transaction has its own binding cookie, named after its hash and holding the
1571
+ * full hash, so several authorizations can be in flight in one browser (two tabs) without replacing each
1572
+ * other. Single use is `get` then `delete`, which KV cannot make atomic: two concurrent requests from the
1573
+ * same browser with the same handle could both pass; the cookie binding confines that to the browser
1574
+ * that started the transaction.
1575
+ */
1576
+ const TRANSACTION_TTL_SECONDS = 600;
1577
+ /** Default for the provider's `cookiePrefix` option. */
1578
+ const DEFAULT_COOKIE_PREFIX = "__Host-oauth-";
1579
+ const DEFAULT_REMEMBER_SECONDS = 720 * 60 * 60;
1580
+ const MIN_SECRET_LENGTH = 32;
1581
+ const MAX_APPROVALS_COOKIE_BYTES = 3800;
1582
+ /** Throws a `TypeError` unless the prefix keeps the `__Host-` guarantees the MCP best practices require. */
1583
+ function consentCookies(prefix = DEFAULT_COOKIE_PREFIX) {
1584
+ if (typeof prefix !== "string" || !prefix.startsWith("__Host-") || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(prefix)) throw new TypeError("cookiePrefix must start with \"__Host-\" and contain only cookie-name characters");
1585
+ return {
1586
+ consent: `${prefix}consent`,
1587
+ upstream: `${prefix}upstream`,
1588
+ approvals: `${prefix}approvals`
1589
+ };
1590
+ }
1591
+ function validateRememberConsentOptions(options) {
1592
+ if (typeof options !== "object" || options === null) throw new TypeError("remember must be an object with a secret");
1593
+ if (typeof options.secret !== "string" || options.secret.length < MIN_SECRET_LENGTH) throw new TypeError(`remember.secret must be a string of at least ${MIN_SECRET_LENGTH} characters`);
1594
+ const maxAge = options.maxAgeSeconds;
1595
+ if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
1596
+ if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
1597
+ }
1598
+ /** Start a consent transaction for an authorization request that must be shown to the user. */
1599
+ async function beginConsent(kv, cookies, request) {
1600
+ const { handle, hash } = await createTransaction(kv, {
1601
+ kind: "consent",
1602
+ request
1603
+ });
1604
+ return {
1605
+ handle,
1606
+ headers: new Headers({
1607
+ "Set-Cookie": bindingCookie(transactionCookieName(cookies.consent, hash), hash, TRANSACTION_TTL_SECONDS),
1608
+ "Cache-Control": "no-store",
1609
+ "Content-Security-Policy": "frame-ancestors 'none'",
1610
+ "X-Frame-Options": "DENY"
1611
+ })
1612
+ };
1613
+ }
1614
+ /**
1615
+ * Consume a consent transaction the user approved. `scope`, when given, replaces the requested
1616
+ * scopes: the page may narrow them or offer more, but each must be one the server supports
1617
+ * (`supportedScopes`, from `scopesSupported`). `remember` stores the approval in a signed cookie.
1618
+ */
1619
+ async function approveConsent(kv, cookies, supportedScopes, request, handle, options = {}) {
1620
+ if (options.remember !== void 0) validateRememberConsentOptions(options.remember);
1621
+ const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1622
+ let approved = transaction.record.request;
1623
+ if (options.scope !== void 0) {
1624
+ const supported = supportedScopes ? new Set(supportedScopes) : void 0;
1625
+ const valid = (scope) => typeof scope === "string" && isValidOAuthScopeToken(scope) && (!supported || supported.has(scope));
1626
+ if (!Array.isArray(options.scope) || !options.scope.every(valid)) throw new AuthorizationError("invalid_scope", { description: "Approved scopes must be ones this server supports" });
1627
+ approved = {
1628
+ ...approved,
1629
+ scope: [...new Set(options.scope)]
1630
+ };
1631
+ }
1632
+ await transaction.consume();
1633
+ const headers = new Headers({ "Cache-Control": "no-store" });
1634
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1635
+ if (options.remember) headers.append("Set-Cookie", await rememberApproval(cookies, request, approved, options.remember));
1636
+ return {
1637
+ request: approved,
1638
+ headers
1639
+ };
1640
+ }
1641
+ /**
1642
+ * Consume a consent transaction the user declined, and build the OAuth error redirect back to the
1643
+ * client: `error=access_denied`, the client's `state`, and `iss`. The redirect URI comes from the
1644
+ * stored request, which `parseAuthRequest()` validated, never from the form.
1645
+ */
1646
+ async function denyConsent(kv, cookies, request, handle, options = {}) {
1647
+ const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1648
+ await transaction.consume();
1649
+ const record = transaction.record;
1650
+ const redirect = new URL(record.request.redirectUri);
1651
+ redirect.searchParams.set("error", "access_denied");
1652
+ if (options.description) redirect.searchParams.set("error_description", options.description);
1653
+ if (record.request.state) redirect.searchParams.set("state", record.request.state);
1654
+ if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
1655
+ const headers = new Headers({
1656
+ "Cache-Control": "no-store",
1657
+ Location: redirect.href
1658
+ });
1659
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1660
+ return {
1661
+ request: record.request,
1662
+ redirectTo: redirect.href,
1663
+ headers
1664
+ };
1665
+ }
1666
+ /** Whether a remembered approval covers this request: same client, redirect URI and resource, subset of scopes. */
1667
+ async function isConsentRemembered(cookies, request, authRequest, remember) {
1668
+ validateRememberConsentOptions(remember);
1669
+ const approvals = await readApprovals(cookies, request, remember.secret);
1670
+ const key = await approvalKey(authRequest, remember.subject);
1671
+ const now = Math.floor(Date.now() / 1e3);
1672
+ const approval = approvals.find((entry) => entry.k === key && entry.e > now);
1673
+ if (!approval) return false;
1674
+ const approvedScopes = new Set(approval.s);
1675
+ return authRequest.scope.every((scope) => approvedScopes.has(scope));
1676
+ }
1677
+ /**
1678
+ * Save an approved authorization request before redirecting to the third-party provider, and get
1679
+ * the `state` to send it. Call only after consent. Pass `headers` to add the binding cookie to
1680
+ * headers you are already sending, such as `approveConsent()`'s.
1681
+ */
1682
+ async function beginUpstream(kv, cookies, request, options = {}) {
1683
+ const { handle, hash } = await createTransaction(kv, {
1684
+ kind: "upstream",
1685
+ request,
1686
+ data: options.data
1687
+ });
1688
+ const headers = options.headers ?? new Headers();
1689
+ headers.append("Set-Cookie", bindingCookie(transactionCookieName(cookies.upstream, hash), hash, TRANSACTION_TTL_SECONDS));
1690
+ headers.set("Cache-Control", "no-store");
1691
+ return {
1692
+ state: handle,
1693
+ headers
1694
+ };
1695
+ }
1696
+ /** Recover the authorization request at the third-party provider's callback, from its `state` parameter. */
1697
+ async function finishUpstream(kv, cookies, request) {
1698
+ const state = new URL(request.url).searchParams.get("state");
1699
+ if (!state) throw new AuthorizationError("invalid_request", { description: "Missing state parameter" });
1700
+ const transaction = await openTransaction(kv, request, cookies.upstream, state, "upstream");
1701
+ await transaction.consume();
1702
+ const headers = new Headers({ "Cache-Control": "no-store" });
1703
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1704
+ return {
1705
+ request: transaction.record.request,
1706
+ data: transaction.record.data,
1707
+ headers
1708
+ };
1709
+ }
1710
+ async function createTransaction(kv, record) {
1711
+ const handle = base64url(crypto.getRandomValues(new Uint8Array(32)));
1712
+ const hash = await sha256Hex(handle);
1713
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1714
+ const sealed = await crypto.subtle.encrypt({
1715
+ name: "AES-GCM",
1716
+ iv
1717
+ }, await transactionKey(handle), new TextEncoder().encode(JSON.stringify(record)));
1718
+ await kv.put(`transaction:${hash}`, `${base64url(iv)}.${base64url(new Uint8Array(sealed))}`, { expirationTtl: TRANSACTION_TTL_SECONDS });
1719
+ return {
1720
+ handle,
1721
+ hash
1722
+ };
1723
+ }
1724
+ async function openTransaction(kv, request, cookieBase, handle, kind) {
1725
+ if (typeof handle !== "string" || handle.length === 0) throw new AuthorizationError("invalid_request", { description: "Missing transaction handle" });
1726
+ const hash = await sha256Hex(handle);
1727
+ const cookieName = transactionCookieName(cookieBase, hash);
1728
+ const bound = readCookie(request, cookieName);
1729
+ if (!bound) throw new AuthorizationError("invalid_request", { description: "This authorization was not started in this browser; start again" });
1730
+ if (!timingSafeEqual(hash, bound)) throw new AuthorizationError("invalid_request", { description: "This authorization belongs to a different browser session; start again" });
1731
+ const key = `transaction:${hash}`;
1732
+ const expired = new AuthorizationError("invalid_request", { description: "This authorization expired or was already used; start again" });
1733
+ const stored = await kv.get(key);
1734
+ if (!stored) throw expired;
1735
+ let record;
1736
+ try {
1737
+ const [iv, sealed] = stored.split(".");
1738
+ const plain = await crypto.subtle.decrypt({
1739
+ name: "AES-GCM",
1740
+ iv: fromBase64url(iv)
1741
+ }, await transactionKey(handle), fromBase64url(sealed));
1742
+ record = JSON.parse(new TextDecoder().decode(plain));
1743
+ } catch {
1744
+ throw expired;
1745
+ }
1746
+ if (record.kind !== kind) throw expired;
1747
+ return {
1748
+ record,
1749
+ cookieName,
1750
+ consume: () => kv.delete(key)
1751
+ };
1752
+ }
1753
+ /** Only the holder of the handle can decrypt its record; KV keeps the hash, not the handle. */
1754
+ async function transactionKey(handle) {
1755
+ const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`oauth-transaction-key:${handle}`));
1756
+ return crypto.subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]);
1757
+ }
1758
+ /** One binding cookie per transaction, so concurrent authorizations in one browser don't collide. */
1759
+ function transactionCookieName(base, hash) {
1760
+ return `${base}-${hash.slice(0, 16)}`;
1761
+ }
1762
+ async function rememberApproval(cookies, request, approved, remember) {
1763
+ const maxAge = remember.maxAgeSeconds ?? DEFAULT_REMEMBER_SECONDS;
1764
+ const now = Math.floor(Date.now() / 1e3);
1765
+ const key = await approvalKey(approved, remember.subject);
1766
+ const approvals = (await readApprovals(cookies, request, remember.secret)).filter((entry) => entry.e > now && entry.k !== key);
1767
+ approvals.push({
1768
+ k: key,
1769
+ s: approved.scope,
1770
+ e: now + maxAge
1771
+ });
1772
+ let value = await signApprovals(approvals, remember.secret);
1773
+ while (value.length > MAX_APPROVALS_COOKIE_BYTES && approvals.length > 1) {
1774
+ approvals.shift();
1775
+ value = await signApprovals(approvals, remember.secret);
1776
+ }
1777
+ const cookieMaxAge = Math.max(...approvals.map((entry) => entry.e)) - now;
1778
+ return `${cookies.approvals}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${cookieMaxAge}`;
1779
+ }
1780
+ async function readApprovals(cookies, request, secret) {
1781
+ const value = readCookie(request, cookies.approvals);
1782
+ if (!value) return [];
1783
+ const [payload, signature] = value.split(".");
1784
+ if (!payload || !signature) return [];
1785
+ const key = await hmacKey(secret);
1786
+ let valid = false;
1787
+ try {
1788
+ valid = await crypto.subtle.verify("HMAC", key, fromBase64url(signature), new TextEncoder().encode(payload));
1789
+ } catch {
1790
+ return [];
1791
+ }
1792
+ if (!valid) return [];
1793
+ try {
1794
+ const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
1795
+ return Array.isArray(parsed) ? parsed.filter(isApproval) : [];
1796
+ } catch {
1797
+ return [];
1798
+ }
1799
+ }
1800
+ async function signApprovals(approvals, secret) {
1801
+ const payload = base64url(new TextEncoder().encode(JSON.stringify(approvals)));
1802
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(payload));
1803
+ return `${payload}.${base64url(new Uint8Array(signature))}`;
1804
+ }
1805
+ function isApproval(value) {
1806
+ if (!value || typeof value !== "object") return false;
1807
+ const entry = value;
1808
+ return typeof entry.k === "string" && typeof entry.e === "number" && Array.isArray(entry.s) && entry.s.every((scope) => typeof scope === "string");
1809
+ }
1810
+ /** Approval is bound to the client, where its tokens go, which resource they are for, and the user if known. */
1811
+ async function approvalKey(request, subject) {
1812
+ const material = JSON.stringify([
1813
+ request.clientId,
1814
+ request.redirectUri,
1815
+ request.resource ?? null,
1816
+ subject ?? null
1817
+ ]);
1818
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
1819
+ return base64url(new Uint8Array(digest));
1820
+ }
1821
+ function hmacKey(secret) {
1822
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
1823
+ name: "HMAC",
1824
+ hash: "SHA-256"
1825
+ }, false, ["sign", "verify"]);
1826
+ }
1827
+ function bindingCookie(name, value, maxAge) {
1828
+ return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
1829
+ }
1830
+ function clearCookie(name) {
1831
+ return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
1832
+ }
1833
+ function readCookie(request, name) {
1834
+ const header = request.headers.get("Cookie");
1835
+ if (!header) return void 0;
1836
+ for (const part of header.split(";")) {
1837
+ const separator = part.indexOf("=");
1838
+ if (separator === -1) continue;
1839
+ if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
1840
+ }
1841
+ }
1842
+ async function sha256Hex(value) {
1843
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
1844
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1845
+ }
1846
+ function timingSafeEqual(a, b) {
1847
+ if (a.length !== b.length) return false;
1848
+ let difference = 0;
1849
+ for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
1850
+ return difference === 0;
1851
+ }
1852
+ function base64url(bytes) {
1853
+ let binary = "";
1854
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1855
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1856
+ }
1857
+ function fromBase64url(value) {
1858
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
1859
+ const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
1860
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
1861
+ }
1862
+
1231
1863
  //#endregion
1232
1864
  //#region src/oauth-provider.ts
1233
1865
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
@@ -1256,7 +1888,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
1256
1888
  return GrantType$1;
1257
1889
  }({});
1258
1890
  function toPublicClientInfo(client) {
1259
- const { authMethodExplicit: _explicit, ...publicClient } = client;
1891
+ const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
1260
1892
  return publicClient;
1261
1893
  }
1262
1894
  function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
@@ -1302,6 +1934,66 @@ var OAuthProvider = class {
1302
1934
  }
1303
1935
  };
1304
1936
  /**
1937
+ * The authorization-server role on its own. It serves discovery, token, revocation and
1938
+ * registration endpoints from `fetch()`, exposes the interactive authorization flow through
1939
+ * `getOAuthApi()`, and validates its access tokens for any declared resource through
1940
+ * `validateToken()`. Resources are hosted, in this Worker or another, by
1941
+ * `OAuthResourceServer`, whose `validateToken` points back here.
1942
+ */
1943
+ var OAuthAuthorizationServer = class {
1944
+ #impl;
1945
+ constructor(options) {
1946
+ const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
1947
+ this.#impl = new OAuthProviderImpl({
1948
+ ...commonOptions,
1949
+ defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
1950
+ authorizationServer: {
1951
+ issuer,
1952
+ resources,
1953
+ authorizeEndpoint,
1954
+ tokenEndpoint,
1955
+ clientRegistrationEndpoint,
1956
+ defaultResource,
1957
+ legacyGrantResource
1958
+ }
1959
+ });
1960
+ }
1961
+ /**
1962
+ * Validate an access token for one declared resource: the token's KV record is loaded, its
1963
+ * audience checked against `resource`, and its props decrypted. Resolves `null` for a token
1964
+ * that is not valid for that resource, and rejects for a resource this server does not
1965
+ * declare.
1966
+ *
1967
+ * This is what a resource server calls, whether it shares this Worker or reaches it over a
1968
+ * Service Binding. For the latter, expose it from a `WorkerEntrypoint`:
1969
+ *
1970
+ * ```ts
1971
+ * export default class AuthServer extends WorkerEntrypoint<Env> {
1972
+ * fetch(request: Request) {
1973
+ * return authorizationServer.fetch(request, this.env, this.ctx);
1974
+ * }
1975
+ * validateToken(resource: string, token: string) {
1976
+ * return authorizationServer.validateToken(resource, token, this.env);
1977
+ * }
1978
+ * }
1979
+ * ```
1980
+ */
1981
+ validateToken(resource, token, env) {
1982
+ return this.#impl.validateAccessToken(token, resource, env);
1983
+ }
1984
+ /** Serve protocol-owned authorization-server endpoints, excluding the application authorization UI. */
1985
+ fetch(request, env, ctx) {
1986
+ return this.#impl.fetchAuthorizationServer(request, env, ctx);
1987
+ }
1988
+ /** Obtain helpers for scheduled jobs or custom code outside a fetch dispatch. */
1989
+ getOAuthApi(env) {
1990
+ return this.#impl.createOAuthHelpers(env);
1991
+ }
1992
+ purgeExpiredData(env, options) {
1993
+ return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
1994
+ }
1995
+ };
1996
+ /**
1305
1997
  * Gets OAuthHelpers for the given environment
1306
1998
  * @param options - Configuration options for the OAuth provider
1307
1999
  * @param env - Cloudflare Worker environment variables
@@ -1324,37 +2016,96 @@ var OAuthProviderImpl = class {
1324
2016
  * @param options - Configuration options for the provider
1325
2017
  */
1326
2018
  constructor(options) {
2019
+ this.errorResponses = /* @__PURE__ */ new WeakSet();
1327
2020
  this.typedApiHandlers = [];
1328
- const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
1329
- const hasMultiHandlerConfig = !!options.apiHandlers;
1330
- if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
1331
- if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
1332
2021
  this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
1333
- if (hasSingleHandlerConfig) {
1334
- const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
1335
- if (Array.isArray(options.apiRoute)) options.apiRoute.forEach((route, index) => {
1336
- this.validateEndpoint(route, `apiRoute[${index}]`);
1337
- this.typedApiHandlers.push([route, apiHandler]);
1338
- });
1339
- else {
1340
- this.validateEndpoint(options.apiRoute, "apiRoute");
1341
- this.typedApiHandlers.push([options.apiRoute, apiHandler]);
1342
- }
1343
- } else for (const [route, handler] of Object.entries(options.apiHandlers)) {
1344
- this.validateEndpoint(route, `apiHandlers key: ${route}`);
1345
- this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
2022
+ const roleBased = "authorizationServer" in options;
2023
+ if (!roleBased && "resourceMatchOriginOnly" in options) throw new TypeError("resourceMatchOriginOnly was removed in 1.0. Configure resourceMetadata.resource; audiences are compared exactly against that canonical resource. See the migration guide.");
2024
+ let normalizedOptions;
2025
+ let configuredResourceServers;
2026
+ if (roleBased) {
2027
+ const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
2028
+ this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
2029
+ this.explicitIssuer = authorizationServer$1.issuer;
2030
+ normalizedOptions = {
2031
+ ...commonOptions,
2032
+ authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
2033
+ tokenEndpoint: authorizationServer$1.tokenEndpoint,
2034
+ clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
2035
+ };
2036
+ const declared = authorizationServer$1.resources;
2037
+ 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");
2038
+ const duplicate = declared.find((resource, index) => declared.slice(0, index).some((earlier) => isExactResource(earlier, resource)));
2039
+ if (duplicate !== void 0) throw new TypeError(`resources must be unique; duplicate ${duplicate}`);
2040
+ configuredResourceServers = declared.map((resource) => ({ resourceMetadata: { resource } }));
2041
+ } else {
2042
+ this.explicitIssuer = void 0;
2043
+ normalizedOptions = options;
2044
+ configuredResourceServers = [{
2045
+ resourceMetadata: options.resourceMetadata,
2046
+ resolveExternalToken: options.resolveExternalToken
2047
+ }];
1346
2048
  }
1347
- this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
1348
- this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
1349
- if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
1350
2049
  this.options = {
1351
2050
  accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
1352
2051
  refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
1353
2052
  clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
1354
2053
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
1355
- ...options
2054
+ ...normalizedOptions
1356
2055
  };
1357
- if (!Number.isInteger(this.options.accessTokenTTL) || this.options.accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
2056
+ this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
2057
+ this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
2058
+ this.consentCookies = consentCookies(this.options.cookiePrefix);
2059
+ if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
2060
+ if (roleBased) this.validateAuthorizationServerRouteIsolation();
2061
+ this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
2062
+ const metadata = this.snapshotResourceMetadata(resourceMetadata);
2063
+ this.validateResourceMetadataOptions(metadata);
2064
+ return {
2065
+ resourceMetadata: metadata,
2066
+ resolveExternalToken
2067
+ };
2068
+ });
2069
+ 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}`);
2070
+ if (!roleBased) {
2071
+ const legacyOptions = options;
2072
+ const hasSingleHandlerConfig = !!(legacyOptions.apiRoute && legacyOptions.apiHandler);
2073
+ const hasMultiHandlerConfig = !!legacyOptions.apiHandlers;
2074
+ if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
2075
+ if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
2076
+ const resourceServer = this.resourceServers[0];
2077
+ if (hasSingleHandlerConfig) {
2078
+ const handler = this.validateHandler(legacyOptions.apiHandler, "apiHandler");
2079
+ (Array.isArray(legacyOptions.apiRoute) ? legacyOptions.apiRoute : [legacyOptions.apiRoute]).forEach((route, index) => {
2080
+ this.validateEndpoint(route, Array.isArray(legacyOptions.apiRoute) ? `apiRoute[${index}]` : "apiRoute");
2081
+ this.typedApiHandlers.push({
2082
+ route,
2083
+ handler,
2084
+ resourceServer
2085
+ });
2086
+ });
2087
+ } else for (const [route, rawHandler] of Object.entries(legacyOptions.apiHandlers)) {
2088
+ this.validateEndpoint(route, `apiHandlers key: ${route}`);
2089
+ const handler = this.validateHandler(rawHandler, `apiHandlers[${route}]`);
2090
+ this.typedApiHandlers.push({
2091
+ route,
2092
+ handler,
2093
+ resourceServer
2094
+ });
2095
+ }
2096
+ for (const { route } of this.typedApiHandlers) this.assertRouteCoveredByResource(route, resourceServer.resourceMetadata.resource);
2097
+ }
2098
+ const resources = this.resourceServers.map((server) => server.resourceMetadata.resource);
2099
+ const soleResource = resources.length === 1 ? resources[0] : void 0;
2100
+ const authorizationServer = roleBased ? options.authorizationServer : void 0;
2101
+ this.configuredDefaultAuthorizationResource = authorizationServer?.defaultResource ?? soleResource;
2102
+ this.configuredLegacyGrantResource = authorizationServer?.legacyGrantResource ?? soleResource;
2103
+ if (roleBased) {
2104
+ this.getDefaultAuthorizationResource();
2105
+ this.getLegacyGrantResource();
2106
+ }
2107
+ if (!isValidAccessTokenTTL(this.options.accessTokenTTL)) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
2108
+ if (this.options.refreshTokenIdleTTL !== void 0 && !isValidAccessTokenTTL(this.options.refreshTokenIdleTTL)) throw new TypeError(`refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
1358
2109
  this.serverCapabilities = buildOAuthServerCapabilities({
1359
2110
  allowImplicitFlow: !!this.options.allowImplicitFlow,
1360
2111
  allowPlainPKCE: this.options.allowPlainPKCE === true,
@@ -1362,7 +2113,6 @@ var OAuthProviderImpl = class {
1362
2113
  enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
1363
2114
  });
1364
2115
  validateAuthorizationServerScopes(this.options.scopesSupported);
1365
- this.validateResourceMetadataOptions(this.options.resourceMetadata);
1366
2116
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
1367
2117
  if (this.options.enterpriseManagedAuthorization) {
1368
2118
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -1378,12 +2128,91 @@ var OAuthProviderImpl = class {
1378
2128
  validateEndpoint(endpoint, name) {
1379
2129
  if (this.isPath(endpoint)) {
1380
2130
  if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
1381
- } else try {
1382
- new URL(endpoint);
1383
- } catch (e) {
1384
- throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
2131
+ if (this.explicitIssuer && new URL(endpoint, new URL(this.explicitIssuer).origin).hash) throw new TypeError(`${name} must not contain a fragment`);
2132
+ } else {
2133
+ let parsed;
2134
+ try {
2135
+ parsed = new URL(endpoint);
2136
+ } catch (e) {
2137
+ throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
2138
+ }
2139
+ 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)`);
1385
2140
  }
1386
2141
  }
2142
+ /** Validate the explicit issuer used to host-gate the role-based AS. */
2143
+ validateAuthorizationServerIssuer(issuer) {
2144
+ let parsed;
2145
+ try {
2146
+ parsed = new URL(issuer);
2147
+ } catch {
2148
+ throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL");
2149
+ }
2150
+ if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.search || parsed.hash || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer) throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL (http is accepted only on a loopback host)");
2151
+ }
2152
+ /** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
2153
+ validateAuthorizationServerRouteIsolation() {
2154
+ const issuerUrl = new URL(this.explicitIssuer);
2155
+ const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
2156
+ const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
2157
+ if (this.matchEndpoint(tokenUrl, discoveryEndpoint)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
2158
+ if (!this.options.clientRegistrationEndpoint) return;
2159
+ const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
2160
+ if (this.matchEndpoint(registrationUrl, this.options.tokenEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
2161
+ if (this.matchEndpoint(registrationUrl, discoveryEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
2162
+ }
2163
+ /**
2164
+ * A protected route must be the canonical resource path or a path-boundary descendant of
2165
+ * it. Anything else can never validate a token, because every token is bound to the
2166
+ * canonical resource, so it would be a permanently 401 zone.
2167
+ */
2168
+ assertRouteCoveredByResource(route, resource) {
2169
+ const resourceUrl = new URL(resource);
2170
+ let routePath;
2171
+ if (this.isPath(route)) routePath = route.split("?")[0];
2172
+ else {
2173
+ const routeUrl = new URL(route);
2174
+ 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.`);
2175
+ for (const [name, value] of resourceUrl.searchParams) {
2176
+ const routeValues = routeUrl.searchParams.getAll(name);
2177
+ 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.`);
2178
+ }
2179
+ routePath = routeUrl.pathname;
2180
+ }
2181
+ if (isPathDescendant(routePath, resourceUrl.pathname)) return;
2182
+ 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.`);
2183
+ }
2184
+ /** Resolve a configured policy value to the registry's canonical spelling. */
2185
+ resolveConfiguredResourceDefault(resource, name) {
2186
+ if (resource === void 0) return void 0;
2187
+ const configured = this.findConfiguredResource(resource);
2188
+ if (!configured) throw new TypeError(`${name} must name one of the configured protected resources`);
2189
+ return configured;
2190
+ }
2191
+ getDefaultAuthorizationResource() {
2192
+ const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
2193
+ return this.resolveConfiguredResourceDefault(this.configuredDefaultAuthorizationResource ?? sole, "defaultResource");
2194
+ }
2195
+ getLegacyGrantResource() {
2196
+ const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
2197
+ return this.resolveConfiguredResourceDefault(this.configuredLegacyGrantResource ?? sole, "legacyGrantResource");
2198
+ }
2199
+ /**
2200
+ * The audience a stored access token is treated as bound to.
2201
+ *
2202
+ * A token issued before resource binding has no stored audience. It keeps
2203
+ * working at the server-selected migration resource (the sole resource, or
2204
+ * `legacyGrantResource` in a multi-resource deployment) until it expires;
2205
+ * refresh binds its grant and returns a bound replacement. The client never
2206
+ * chooses this destination. Without a migration resource the token is
2207
+ * treated as unbound and rejected.
2208
+ *
2209
+ * The destination is deployment policy, not an issuance-time claim: changing
2210
+ * `legacyGrantResource` re-targets every surviving unbound token and grant.
2211
+ * Keep it fixed for the length of the migration window.
2212
+ */
2213
+ resolveStoredTokenAudience(audience) {
2214
+ return audience === void 0 ? this.getLegacyGrantResource() : audience;
2215
+ }
1387
2216
  /**
1388
2217
  * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
1389
2218
  * @param handler - The handler to validate
@@ -1402,10 +2231,22 @@ var OAuthProviderImpl = class {
1402
2231
  };
1403
2232
  throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
1404
2233
  }
2234
+ /** Snapshot caller-owned metadata so later mutation cannot change routing or policy. */
2235
+ snapshotResourceMetadata(metadata) {
2236
+ return {
2237
+ ...metadata,
2238
+ ...metadata?.authorization_servers ? { authorization_servers: [...metadata.authorization_servers] } : {},
2239
+ ...metadata?.scopes_supported ? { scopes_supported: [...metadata.scopes_supported] } : {},
2240
+ ...metadata?.bearer_methods_supported ? { bearer_methods_supported: [...metadata.bearer_methods_supported] } : {}
2241
+ };
2242
+ }
1405
2243
  /** Validate configured RFC 9728 protected resource metadata. */
1406
2244
  validateResourceMetadataOptions(options) {
1407
- if (!options) return;
1408
- if (options.resource !== void 0 && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
2245
+ 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)");
2246
+ if (foldResourceSchemeAndHost(options.resource) !== options.resource) throw new TypeError("resourceMetadata.resource must use a lowercase scheme and lowercase host");
2247
+ const parsedResource = new URL(options.resource);
2248
+ 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");
2249
+ if (parsedResource.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || parsedResource.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX}/`)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX} namespace`);
1409
2250
  if (options.authorization_servers !== void 0) {
1410
2251
  if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
1411
2252
  for (const issuer of options.authorization_servers) {
@@ -1415,7 +2256,7 @@ var OAuthProviderImpl = class {
1415
2256
  } catch {
1416
2257
  throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
1417
2258
  }
1418
- if (parsed.protocol !== "https:" || parsed.search || parsed.hash) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
2259
+ if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer || issuer.includes("?") || issuer.includes("#")) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs (http is accepted only on a loopback host)");
1419
2260
  }
1420
2261
  }
1421
2262
  if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
@@ -1433,7 +2274,6 @@ var OAuthProviderImpl = class {
1433
2274
  if (!options) return;
1434
2275
  if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
1435
2276
  if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
1436
- if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
1437
2277
  if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
1438
2278
  if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
1439
2279
  if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
@@ -1447,20 +2287,38 @@ var OAuthProviderImpl = class {
1447
2287
  * @returns A Promise resolving to an HTTP Response
1448
2288
  */
1449
2289
  async fetch(request, env, ctx) {
2290
+ return this.fetchForRoles(request, env, ctx, "combined");
2291
+ }
2292
+ async fetchAuthorizationServer(request, env, ctx) {
2293
+ return this.fetchForRoles(request, env, ctx, "authorization-server");
2294
+ }
2295
+ async fetchForRoles(request, env, ctx, role) {
1450
2296
  const url = new URL(request.url);
2297
+ const servesProtectedResources = role === "combined";
2298
+ const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
2299
+ const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
1451
2300
  if (request.method === "OPTIONS") {
1452
- if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isProtectedResourceMetadataRequest(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
2301
+ if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
1453
2302
  status: 204,
1454
2303
  headers: { "Content-Length": "0" }
1455
2304
  }), request);
1456
2305
  }
1457
- if (url.pathname === "/.well-known/oauth-authorization-server") {
2306
+ if (this.isAuthorizationServerMetadataRequest(url)) {
2307
+ if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
2308
+ status: 405,
2309
+ headers: { Allow: "GET, HEAD, OPTIONS" }
2310
+ }), request);
1458
2311
  const response = await this.handleMetadataDiscovery(url);
1459
- return this.addCorsHeaders(response, request);
2312
+ return this.addCorsHeaders(withoutBodyForHead(request, response), request);
1460
2313
  }
1461
- if (this.isProtectedResourceMetadataRequest(url)) {
1462
- const response = this.handleProtectedResourceMetadata(url);
1463
- return this.addCorsHeaders(response, request);
2314
+ if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
2315
+ if (!metadataResourceServer) return this.addCorsHeaders(new Response(null, { status: 404 }), request);
2316
+ if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
2317
+ status: 405,
2318
+ headers: { Allow: "GET, HEAD, OPTIONS" }
2319
+ }), request);
2320
+ const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
2321
+ return this.addCorsHeaders(withoutBodyForHead(request, response), request);
1464
2322
  }
1465
2323
  if (this.isTokenEndpoint(url)) {
1466
2324
  const parsed = await this.parseTokenEndpointRequest(request, env);
@@ -1468,16 +2326,18 @@ var OAuthProviderImpl = class {
1468
2326
  let response;
1469
2327
  if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
1470
2328
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
2329
+ if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
1471
2330
  return this.addCorsHeaders(response, request);
1472
2331
  }
1473
2332
  if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
1474
2333
  const response = await this.handleClientRegistration(request, env);
1475
2334
  return this.addCorsHeaders(response, request);
1476
2335
  }
1477
- if (this.isApiRequest(url)) {
1478
- const response = await this.handleApiRequest(request, env, ctx);
2336
+ if (apiRoute) {
2337
+ const response = await this.handleApiRequest(request, env, ctx, apiRoute);
1479
2338
  return this.addCorsHeaders(response, request);
1480
2339
  }
2340
+ if (typeof role === "object") return new Response(null, { status: 404 });
1481
2341
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1482
2342
  if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
1483
2343
  return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
@@ -1514,6 +2374,20 @@ var OAuthProviderImpl = class {
1514
2374
  }
1515
2375
  };
1516
2376
  }
2377
+ async validateAccessToken(token, resource, env) {
2378
+ const configuredResource = this.findConfiguredResource(resource);
2379
+ if (!configuredResource) throw new TypeError("resource must name one registered protected resource");
2380
+ const summary = await this.unwrapToken(token, env);
2381
+ if (!summary || !isExactResource(this.resolveStoredTokenAudience(summary.audience), configuredResource)) return null;
2382
+ return {
2383
+ props: summary.grant.props,
2384
+ audience: configuredResource,
2385
+ expiresAt: summary.expiresAt,
2386
+ scope: summary.scope,
2387
+ userId: summary.userId,
2388
+ clientId: summary.grant.clientId
2389
+ };
2390
+ }
1517
2391
  /**
1518
2392
  * Determines if an endpoint configuration is a path or a full URL
1519
2393
  * @param endpoint - The endpoint configuration
@@ -1528,12 +2402,26 @@ var OAuthProviderImpl = class {
1528
2402
  * @param endpoint - The endpoint pattern (full URL or path)
1529
2403
  * @returns True if the URL matches the endpoint pattern
1530
2404
  */
1531
- matchEndpoint(url, endpoint) {
1532
- if (this.isPath(endpoint)) return url.pathname === endpoint;
1533
- else {
1534
- const endpointUrl = new URL(endpoint);
1535
- return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
2405
+ matchEndpoint(url, endpoint, allowAdditionalQuery = false) {
2406
+ if (!this.explicitIssuer) {
2407
+ if (this.isPath(endpoint)) return url.pathname === endpoint;
2408
+ const legacyEndpoint = new URL(endpoint);
2409
+ return url.hostname === legacyEndpoint.hostname && url.pathname === legacyEndpoint.pathname;
1536
2410
  }
2411
+ const endpointUrl = new URL(this.getFullEndpointUrl(endpoint, url));
2412
+ if (url.origin !== endpointUrl.origin || url.pathname !== endpointUrl.pathname) return false;
2413
+ const unmatchedActualQuery = [...url.searchParams.entries()];
2414
+ const configuredQueryNames = /* @__PURE__ */ new Set();
2415
+ for (const [name, value] of endpointUrl.searchParams) {
2416
+ configuredQueryNames.add(name);
2417
+ const match = unmatchedActualQuery.findIndex(([actualName, actualValue]) => {
2418
+ return actualName === name && actualValue === value;
2419
+ });
2420
+ if (match === -1) return false;
2421
+ unmatchedActualQuery.splice(match, 1);
2422
+ }
2423
+ if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
2424
+ return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
1537
2425
  }
1538
2426
  /**
1539
2427
  * Checks if a URL matches the configured token endpoint
@@ -1541,7 +2429,10 @@ var OAuthProviderImpl = class {
1541
2429
  * @returns True if the URL matches the token endpoint
1542
2430
  */
1543
2431
  isTokenEndpoint(url) {
1544
- return this.matchEndpoint(url, this.options.tokenEndpoint);
2432
+ return this.matchEndpoint(url, this.options.tokenEndpoint, true);
2433
+ }
2434
+ isAuthorizationEndpointRequest(url) {
2435
+ return this.matchEndpoint(url, this.options.authorizeEndpoint, true);
1545
2436
  }
1546
2437
  /**
1547
2438
  * Checks if a URL matches the configured client registration endpoint
@@ -1550,28 +2441,37 @@ var OAuthProviderImpl = class {
1550
2441
  */
1551
2442
  isClientRegistrationEndpoint(url) {
1552
2443
  if (!this.options.clientRegistrationEndpoint) return false;
1553
- return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
2444
+ return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
1554
2445
  }
1555
2446
  /**
1556
2447
  * Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
1557
- * Matches both the root well-known path and path-suffixed variants per RFC 9728 §3.1.
2448
+ * Only the well-known URL constructed from the configured canonical resource
2449
+ * may return its document; aliases would violate RFC 9728 §3.3.
1558
2450
  */
1559
- isProtectedResourceMetadataRequest(url) {
1560
- return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
2451
+ findResourceServerForMetadataUrl(url) {
2452
+ return this.resourceServers.find((server) => {
2453
+ const expected = new URL(this.getConfiguredResourceMetadataUrl(server.resourceMetadata.resource));
2454
+ return url.origin === expected.origin && url.pathname === expected.pathname && requestCarriesResourceQuery(url, expected);
2455
+ });
1561
2456
  }
1562
- /**
1563
- * Derives the resource identifier from a protected resource metadata well-known URL.
1564
- * Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
1565
- * so the resource identifier is reconstructed by removing the well-known prefix.
1566
- *
1567
- * Examples:
1568
- * /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
1569
- * /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
1570
- */
1571
- deriveResourceIdentifier(requestUrl) {
1572
- const suffix = requestUrl.pathname.slice(37);
1573
- if (!suffix || suffix === "/") return requestUrl.origin;
1574
- return `${requestUrl.origin}${suffix}`;
2457
+ /** Exact RFC 8414 discovery location for the configured issuer. */
2458
+ getAuthorizationServerMetadataUrl(requestUrl) {
2459
+ if (!this.explicitIssuer) return `${requestUrl.origin}/.well-known/oauth-authorization-server`;
2460
+ const issuer = new URL(this.explicitIssuer);
2461
+ const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
2462
+ return `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`;
2463
+ }
2464
+ isAuthorizationServerMetadataRequest(url) {
2465
+ const expected = new URL(this.getAuthorizationServerMetadataUrl(url));
2466
+ return url.origin === expected.origin && url.pathname === expected.pathname;
2467
+ }
2468
+ /** Whether this instance is the role-based authorization server with a fixed issuer. */
2469
+ get hasExplicitIssuer() {
2470
+ return this.explicitIssuer !== void 0;
2471
+ }
2472
+ /** Whether a URL is in the RFC 9728 protected-resource metadata namespace. */
2473
+ isProtectedResourceMetadataPath(url) {
2474
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
1575
2475
  }
1576
2476
  createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
1577
2477
  return this.createErrorResponse("invalid_client", {
@@ -1588,13 +2488,20 @@ var OAuthProviderImpl = class {
1588
2488
  async parseTokenEndpointRequest(request, env) {
1589
2489
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
1590
2490
  description: "Method not allowed",
1591
- statusCode: 405
2491
+ statusCode: 405,
2492
+ headers: { Allow: "POST, OPTIONS" }
2493
+ }, {
2494
+ category: "token-endpoint-request",
2495
+ reason: "method_not_allowed"
1592
2496
  });
1593
2497
  const contentType = request.headers.get("Content-Type") || "";
1594
2498
  let body = {};
1595
2499
  if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
1596
2500
  description: "Content-Type must be application/x-www-form-urlencoded",
1597
2501
  statusCode: 400
2502
+ }, {
2503
+ category: "token-endpoint-request",
2504
+ reason: "unsupported_content_type"
1598
2505
  });
1599
2506
  let formData;
1600
2507
  try {
@@ -1603,6 +2510,9 @@ var OAuthProviderImpl = class {
1603
2510
  return this.createErrorResponse("invalid_request", {
1604
2511
  description: "Request body must be valid application/x-www-form-urlencoded data",
1605
2512
  statusCode: 400
2513
+ }, {
2514
+ category: "token-endpoint-request",
2515
+ reason: "malformed_request_body"
1606
2516
  });
1607
2517
  }
1608
2518
  const processedKeys = /* @__PURE__ */ new Set();
@@ -1613,6 +2523,10 @@ var OAuthProviderImpl = class {
1613
2523
  if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
1614
2524
  description: `Request parameter "${key}" must not be repeated`,
1615
2525
  statusCode: 400
2526
+ }, {
2527
+ category: "token-endpoint-request",
2528
+ reason: "repeated_parameter",
2529
+ detail: { parameter: key }
1616
2530
  });
1617
2531
  body[key] = allValues.length > 1 ? allValues : value;
1618
2532
  }
@@ -1624,15 +2538,24 @@ var OAuthProviderImpl = class {
1624
2538
  if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
1625
2539
  description: "Client must not use multiple authentication methods",
1626
2540
  statusCode: 400
2541
+ }, {
2542
+ category: "client-authentication",
2543
+ reason: "multiple_authentication_methods"
2544
+ });
2545
+ if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted, {
2546
+ category: "client-authentication",
2547
+ reason: "malformed_basic_credentials"
1627
2548
  });
1628
- if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
1629
2549
  clientId = basicAuthorization.clientId;
1630
2550
  clientSecret = basicAuthorization.clientSecret;
1631
2551
  } else {
1632
2552
  clientId = body.client_id;
1633
2553
  clientSecret = body.client_secret || "";
1634
2554
  }
1635
- if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted);
2555
+ if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted, {
2556
+ category: "client-authentication",
2557
+ reason: "client_id_missing"
2558
+ });
1636
2559
  let clientInfo;
1637
2560
  try {
1638
2561
  clientInfo = await this.getClient(env, clientId);
@@ -1647,7 +2570,10 @@ var OAuthProviderImpl = class {
1647
2570
  }, request);
1648
2571
  throw error;
1649
2572
  }
1650
- if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
2573
+ if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
2574
+ category: "client-authentication",
2575
+ reason: "client_not_found"
2576
+ });
1651
2577
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
1652
2578
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
1653
2579
  if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
@@ -1660,9 +2586,18 @@ var OAuthProviderImpl = class {
1660
2586
  }
1661
2587
  });
1662
2588
  if (presentedAuthMethod !== "none") {
1663
- if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1664
- if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
1665
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted);
2589
+ if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
2590
+ category: "client-authentication",
2591
+ reason: "client_secret_missing"
2592
+ });
2593
+ if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted, {
2594
+ category: "client-authentication",
2595
+ reason: "client_secret_not_registered"
2596
+ });
2597
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted, {
2598
+ category: "client-authentication",
2599
+ reason: "client_secret_mismatch"
2600
+ });
1666
2601
  }
1667
2602
  const isRevocationRequest = !body.grant_type && !!body.token;
1668
2603
  return {
@@ -1678,30 +2613,24 @@ var OAuthProviderImpl = class {
1678
2613
  * @returns True if the URL matches the API route
1679
2614
  */
1680
2615
  matchApiRoute(url, route) {
1681
- if (this.isPath(route)) {
1682
- if (route === "/") return url.pathname === "/";
1683
- return url.pathname.startsWith(route);
1684
- } else {
2616
+ const pathMatches = (configuredPath, rootMatchesDescendants) => {
2617
+ if (configuredPath === "/") return rootMatchesDescendants || url.pathname === "/";
2618
+ const normalized = configuredPath.endsWith("/") ? configuredPath.slice(0, -1) : configuredPath;
2619
+ return url.pathname === normalized || url.pathname.startsWith(normalized + "/");
2620
+ };
2621
+ if (this.isPath(route)) return pathMatches(route, false);
2622
+ else {
1685
2623
  const apiUrl = new URL(route);
1686
- return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
2624
+ return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
1687
2625
  }
1688
2626
  }
1689
2627
  /**
1690
- * Checks if a URL is an API request based on the configured API route(s)
1691
- * @param url - The URL to check
1692
- * @returns True if the URL matches any of the API routes
1693
- */
1694
- isApiRequest(url) {
1695
- for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
1696
- return false;
1697
- }
1698
- /**
1699
2628
  * Finds the appropriate API handler for a URL
1700
2629
  * @param url - The URL to find a handler for
1701
2630
  * @returns The TypedHandler for the URL, or undefined if no handler matches
1702
2631
  */
1703
- findApiHandlerForUrl(url) {
1704
- for (const [route, handler] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return handler;
2632
+ findApiRouteForUrl(url) {
2633
+ return this.typedApiHandlers.filter(({ route }) => this.matchApiRoute(url, route)).sort((left, right) => new URL(right.route, url.origin).pathname.length - new URL(left.route, url.origin).pathname.length)[0];
1705
2634
  }
1706
2635
  /**
1707
2636
  * Gets the full URL for an endpoint, using the provided request URL's
@@ -1711,13 +2640,14 @@ var OAuthProviderImpl = class {
1711
2640
  * @returns The full URL for the endpoint
1712
2641
  */
1713
2642
  getFullEndpointUrl(endpoint, requestUrl) {
1714
- if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
2643
+ if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
1715
2644
  else return endpoint;
1716
2645
  }
1717
2646
  /**
1718
2647
  * Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
1719
2648
  */
1720
2649
  getAuthorizationServerIssuer(requestUrl) {
2650
+ if (this.explicitIssuer) return this.explicitIssuer;
1721
2651
  const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1722
2652
  return new URL(tokenEndpoint).origin;
1723
2653
  }
@@ -1734,6 +2664,7 @@ var OAuthProviderImpl = class {
1734
2664
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
1735
2665
  newResponse.headers.set("Access-Control-Allow-Methods", "*");
1736
2666
  newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
2667
+ appendHeaderValue(newResponse.headers, "Vary", "Origin");
1737
2668
  const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
1738
2669
  for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
1739
2670
  newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
@@ -1755,9 +2686,10 @@ var OAuthProviderImpl = class {
1755
2686
  const grantTypesSupported = this.serverCapabilities.grantTypes;
1756
2687
  const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
1757
2688
  const metadata = {
1758
- issuer: new URL(tokenEndpoint).origin,
2689
+ issuer: this.getAuthorizationServerIssuer(requestUrl),
1759
2690
  authorization_endpoint: authorizeEndpoint,
1760
2691
  token_endpoint: tokenEndpoint,
2692
+ ...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
1761
2693
  registration_endpoint: registrationEndpoint,
1762
2694
  scopes_supported: this.options.scopesSupported,
1763
2695
  response_types_supported: responseTypesSupported,
@@ -1773,8 +2705,8 @@ var OAuthProviderImpl = class {
1773
2705
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1774
2706
  }
1775
2707
  /** Scopes that are baseline requirements of the protected resource itself. */
1776
- getProtectedResourceScopes() {
1777
- return this.normalizeProtectedResourceScopes(this.options.resourceMetadata?.scopes_supported ?? []);
2708
+ getProtectedResourceScopes(resourceServer) {
2709
+ return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
1778
2710
  }
1779
2711
  /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
1780
2712
  normalizeProtectedResourceScopes(scopes) {
@@ -1786,18 +2718,17 @@ var OAuthProviderImpl = class {
1786
2718
  * @param requestUrl - The URL of the incoming request
1787
2719
  * @returns Response with protected resource metadata
1788
2720
  */
1789
- handleProtectedResourceMetadata(requestUrl) {
1790
- const rm = this.options.resourceMetadata;
1791
- const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1792
- const authServerOrigin = new URL(tokenEndpointUrl).origin;
1793
- const resourceScopes = this.getProtectedResourceScopes();
2721
+ handleProtectedResourceMetadata(requestUrl, resourceServer) {
2722
+ const rm = resourceServer.resourceMetadata;
2723
+ const authorizationServer = this.getAuthorizationServerIssuer(requestUrl);
2724
+ const resourceScopes = this.getProtectedResourceScopes(resourceServer);
1794
2725
  const metadata = {
1795
- resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
1796
- authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
2726
+ resource: rm.resource,
2727
+ authorization_servers: rm.authorization_servers ?? [authorizationServer],
1797
2728
  ...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
1798
- bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
2729
+ bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
1799
2730
  };
1800
- if (rm?.resource_name) metadata.resource_name = rm.resource_name;
2731
+ if (rm.resource_name) metadata.resource_name = rm.resource_name;
1801
2732
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1802
2733
  }
1803
2734
  /**
@@ -1811,11 +2742,23 @@ var OAuthProviderImpl = class {
1811
2742
  async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
1812
2743
  try {
1813
2744
  const grantType = body.grant_type;
2745
+ 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" }, {
2746
+ category: "token-endpoint-request",
2747
+ reason: "grant_type_not_supported"
2748
+ });
2749
+ if (grantType !== GrantType.JWT_BEARER && Array.isArray(clientInfo.grantTypes)) {
2750
+ const registered = clientInfo.grantTypes;
2751
+ 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" }, {
2752
+ category: "token-endpoint-request",
2753
+ reason: "grant_type_not_registered"
2754
+ });
2755
+ }
2756
+ this.validateTokenRequestResourceIndicator(body.resource);
1814
2757
  if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
1815
2758
  else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
1816
2759
  else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
1817
2760
  else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
1818
- else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
2761
+ throw new Error("Unreachable supported grant type");
1819
2762
  } catch (error) {
1820
2763
  const response = this.createOAuthErrorResponse(error);
1821
2764
  if (response) return response;
@@ -1829,9 +2772,31 @@ var OAuthProviderImpl = class {
1829
2772
  * token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
1830
2773
  * unexpected failures still surface as 500s.
1831
2774
  */
2775
+ /**
2776
+ * Run `tokenExchangeCallback`. An `invalid_grant` it throws means the grant can never work again
2777
+ * (RFC 6749 §5.2: invalid, expired, or revoked; transient failures are `temporarily_unavailable`),
2778
+ * so the grant it ran for is revoked, with its tokens, before the error answers. A failed
2779
+ * revocation is logged and the callback's error still answers the request.
2780
+ */
2781
+ async callTokenExchangeCallback(options, env) {
2782
+ try {
2783
+ return await Promise.resolve(this.options.tokenExchangeCallback(options));
2784
+ } catch (error) {
2785
+ if (error instanceof OAuthError && error.code === "invalid_grant") try {
2786
+ await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
2787
+ } catch (revokeError) {
2788
+ console.warn(`Failed to revoke grant ${options.grantId} after tokenExchangeCallback answered invalid_grant:`, revokeError);
2789
+ }
2790
+ throw error;
2791
+ }
2792
+ }
1832
2793
  createOAuthErrorResponse(error) {
1833
2794
  if (!(error instanceof OAuthError)) return void 0;
1834
- return this.createErrorResponse(error.code, error.options);
2795
+ return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
2796
+ category: "token-exchange-callback",
2797
+ reason: "callback_error",
2798
+ detail: error
2799
+ });
1835
2800
  }
1836
2801
  /**
1837
2802
  * Build a structured protected-resource response from an external-token error.
@@ -1840,7 +2805,7 @@ var OAuthProviderImpl = class {
1840
2805
  * bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
1841
2806
  * supplied one. Other errors retain the pre-existing behavior and are re-thrown.
1842
2807
  */
1843
- createExternalTokenErrorResponse(error, resourceMetadataUrl) {
2808
+ createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
1844
2809
  if (!(error instanceof ExternalTokenError)) return void 0;
1845
2810
  const headers = error.headers ?? {};
1846
2811
  const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
@@ -1851,13 +2816,17 @@ var OAuthProviderImpl = class {
1851
2816
  if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
1852
2817
  challengeHeaders = {
1853
2818
  ...headers,
1854
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
2819
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
1855
2820
  };
1856
2821
  }
1857
2822
  return this.createErrorResponse(error.code, {
1858
2823
  description: error.description,
1859
2824
  statusCode: error.statusCode,
1860
2825
  headers: challengeHeaders ?? headers
2826
+ }, {
2827
+ category: "protected-resource",
2828
+ reason: "resolver_rejected",
2829
+ detail: error
1861
2830
  });
1862
2831
  }
1863
2832
  /**
@@ -1872,34 +2841,72 @@ var OAuthProviderImpl = class {
1872
2841
  const code = body.code;
1873
2842
  const redirectUri = body.redirect_uri;
1874
2843
  const codeVerifier = body.code_verifier;
1875
- if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
2844
+ if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }, {
2845
+ category: "authorization-code-grant",
2846
+ reason: "code_missing"
2847
+ });
1876
2848
  const codeParts = code.split(":");
1877
- if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
2849
+ if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }, {
2850
+ category: "authorization-code-grant",
2851
+ reason: "code_malformed"
2852
+ });
1878
2853
  const [userId, grantId, _] = codeParts;
1879
2854
  const grantKey = `grant:${userId}:${grantId}`;
1880
2855
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1881
- if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
2856
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" }, {
2857
+ category: "authorization-code-grant",
2858
+ reason: "grant_not_found"
2859
+ });
1882
2860
  const codeHash = await hashSecret(code);
1883
- if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1884
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
2861
+ if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }, {
2862
+ category: "authorization-code-grant",
2863
+ reason: "code_mismatch"
2864
+ });
2865
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
2866
+ category: "authorization-code-grant",
2867
+ reason: "client_mismatch"
2868
+ });
1885
2869
  if (!grantData.authCodeWrappedKey) {
1886
2870
  try {
1887
2871
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1888
2872
  } catch {}
1889
- return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
2873
+ return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }, {
2874
+ category: "authorization-code-grant",
2875
+ reason: "code_replayed"
2876
+ });
1890
2877
  }
1891
2878
  let codeChallengeMethod;
1892
2879
  try {
1893
2880
  codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
1894
2881
  } catch (error) {
1895
- return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" });
2882
+ return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
2883
+ category: "authorization-code-grant",
2884
+ reason: "pkce_method_invalid",
2885
+ detail: error
2886
+ });
1896
2887
  }
1897
2888
  const isPkceEnabled = !!grantData.codeChallenge;
1898
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1899
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
1900
- if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
2889
+ if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }, {
2890
+ category: "authorization-code-grant",
2891
+ reason: "redirect_uri_missing"
2892
+ });
2893
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" }, {
2894
+ category: "authorization-code-grant",
2895
+ reason: "redirect_uri_invalid"
2896
+ });
2897
+ if (redirectUri && grantData.redirectUri !== void 0 && redirectUri !== grantData.redirectUri) return this.createErrorResponse("invalid_grant", { description: "redirect_uri does not match the authorization request" }, {
2898
+ category: "authorization-code-grant",
2899
+ reason: "redirect_uri_mismatch"
2900
+ });
2901
+ if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" }, {
2902
+ category: "authorization-code-grant",
2903
+ reason: "unexpected_code_verifier"
2904
+ });
1901
2905
  if (isPkceEnabled) {
1902
- if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
2906
+ if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }, {
2907
+ category: "authorization-code-grant",
2908
+ reason: "code_verifier_missing"
2909
+ });
1903
2910
  let calculatedChallenge;
1904
2911
  if (codeChallengeMethod === "S256") {
1905
2912
  const data = new TextEncoder().encode(codeVerifier);
@@ -1907,9 +2914,13 @@ var OAuthProviderImpl = class {
1907
2914
  const hashArray = Array.from(new Uint8Array(hashBuffer));
1908
2915
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
1909
2916
  } else calculatedChallenge = codeVerifier;
1910
- if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
2917
+ if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
2918
+ category: "authorization-code-grant",
2919
+ reason: "code_verifier_mismatch"
2920
+ });
1911
2921
  }
1912
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
2922
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
2923
+ const audience = resourceResolution.audience;
1913
2924
  let accessTokenTTL = this.options.accessTokenTTL;
1914
2925
  let refreshTokenTTL = this.options.refreshTokenTTL;
1915
2926
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
@@ -1924,13 +2935,15 @@ var OAuthProviderImpl = class {
1924
2935
  const callbackOptions = {
1925
2936
  grantType: GrantType.AUTHORIZATION_CODE,
1926
2937
  clientId: clientInfo.clientId,
2938
+ subjectClientId: grantData.clientId,
1927
2939
  userId,
1928
2940
  grantId,
1929
2941
  scope: grantData.scope,
1930
2942
  requestedScope: tokenScopes,
2943
+ resource: audience,
1931
2944
  props: decryptedProps
1932
2945
  };
1933
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
2946
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
1934
2947
  if (callbackResult) {
1935
2948
  if (callbackResult.newProps) {
1936
2949
  grantProps = callbackResult.newProps;
@@ -1939,6 +2952,10 @@ var OAuthProviderImpl = class {
1939
2952
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
1940
2953
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
1941
2954
  if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
2955
+ if (callbackResult.refreshTokenIdleTTL !== void 0) return this.createErrorResponse("invalid_request", { description: "refreshTokenIdleTTL is only honored during refresh token exchange" }, {
2956
+ category: "authorization-code-grant",
2957
+ reason: "refresh_token_idle_ttl_wrong_grant"
2958
+ });
1942
2959
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1943
2960
  }
1944
2961
  const grantResult = await encryptProps(grantProps);
@@ -1953,6 +2970,10 @@ var OAuthProviderImpl = class {
1953
2970
  accessTokenEncryptionKey = grantEncryptionKey;
1954
2971
  }
1955
2972
  }
2973
+ if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
2974
+ category: "authorization-code-grant",
2975
+ reason: "requested_ttl_too_short"
2976
+ });
1956
2977
  const now = Math.floor(Date.now() / 1e3);
1957
2978
  const useRefreshToken = refreshTokenTTL !== 0;
1958
2979
  delete grantData.codeChallenge;
@@ -1970,6 +2991,7 @@ var OAuthProviderImpl = class {
1970
2991
  grantData.previousRefreshTokenWrappedKey = void 0;
1971
2992
  grantData.expiresAt = expiresAt;
1972
2993
  }
2994
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
1973
2995
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1974
2996
  const tokenResponse = {
1975
2997
  access_token: await this.createAccessToken({
@@ -1985,10 +3007,10 @@ var OAuthProviderImpl = class {
1985
3007
  }),
1986
3008
  token_type: "bearer",
1987
3009
  expires_in: accessTokenTTL,
1988
- scope: tokenScopes.join(" ")
3010
+ scope: tokenScopes.join(" "),
3011
+ resource: audience
1989
3012
  };
1990
3013
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
1991
- if (audience) tokenResponse.resource = audience;
1992
3014
  return new Response(JSON.stringify(tokenResponse), { headers: {
1993
3015
  "Content-Type": "application/json",
1994
3016
  ...NO_CACHE_HEADERS
@@ -2004,26 +3026,46 @@ var OAuthProviderImpl = class {
2004
3026
  */
2005
3027
  async handleRefreshTokenGrant(body, clientInfo, env) {
2006
3028
  const refreshToken = body.refresh_token;
2007
- if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
3029
+ if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }, {
3030
+ category: "refresh-token-grant",
3031
+ reason: "refresh_token_missing"
3032
+ });
2008
3033
  const tokenParts = refreshToken.split(":");
2009
- if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
3034
+ if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }, {
3035
+ category: "refresh-token-grant",
3036
+ reason: "refresh_token_malformed"
3037
+ });
2010
3038
  const [userId, grantId, _] = tokenParts;
2011
3039
  const providedTokenHash = await generateTokenId(refreshToken);
2012
3040
  const grantKey = `grant:${userId}:${grantId}`;
2013
3041
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2014
- if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
3042
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }, {
3043
+ category: "refresh-token-grant",
3044
+ reason: "grant_not_found"
3045
+ });
2015
3046
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
2016
3047
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
2017
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
2018
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
3048
+ if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }, {
3049
+ category: "refresh-token-grant",
3050
+ reason: "refresh_token_mismatch"
3051
+ });
3052
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
3053
+ category: "refresh-token-grant",
3054
+ reason: "client_mismatch"
3055
+ });
2019
3056
  if (grantData.expiresAt !== void 0) {
2020
3057
  const now$1 = Math.floor(Date.now() / 1e3);
2021
- if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
3058
+ if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
3059
+ category: "refresh-token-grant",
3060
+ reason: "refresh_token_expired"
3061
+ });
2022
3062
  }
2023
- const audience = this.resolveTokenResource(body.resource, grantData.resource);
3063
+ const resourceResolution = this.resolveTokenResource(body.resource, grantData);
3064
+ const audience = resourceResolution.audience;
2024
3065
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2025
3066
  const accessTokenId = await generateTokenId(newAccessToken);
2026
3067
  let accessTokenTTL = this.options.accessTokenTTL;
3068
+ let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
2027
3069
  let wrappedKeyToUse;
2028
3070
  if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
2029
3071
  else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
@@ -2040,13 +3082,15 @@ var OAuthProviderImpl = class {
2040
3082
  const callbackOptions = {
2041
3083
  grantType: GrantType.REFRESH_TOKEN,
2042
3084
  clientId: clientInfo.clientId,
3085
+ subjectClientId: grantData.clientId,
2043
3086
  userId,
2044
3087
  grantId,
2045
3088
  scope: grantData.scope,
2046
3089
  requestedScope: tokenScopes,
3090
+ resource: audience,
2047
3091
  props: decryptedProps
2048
3092
  };
2049
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
3093
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
2050
3094
  if (callbackResult) {
2051
3095
  if (callbackResult.newProps) {
2052
3096
  grantProps = callbackResult.newProps;
@@ -2055,7 +3099,17 @@ var OAuthProviderImpl = class {
2055
3099
  }
2056
3100
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2057
3101
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
2058
- if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
3102
+ if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
3103
+ category: "refresh-token-grant",
3104
+ reason: "refresh_token_ttl_immutable"
3105
+ });
3106
+ if (callbackResult.refreshTokenIdleTTL !== void 0) {
3107
+ if (!isValidAccessTokenTTL(callbackResult.refreshTokenIdleTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
3108
+ category: "refresh-token-grant",
3109
+ reason: "refresh_token_idle_ttl_invalid"
3110
+ });
3111
+ refreshTokenIdleTTL = callbackResult.refreshTokenIdleTTL;
3112
+ }
2059
3113
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
2060
3114
  }
2061
3115
  if (grantPropsChanged) {
@@ -2076,12 +3130,19 @@ var OAuthProviderImpl = class {
2076
3130
  }
2077
3131
  }
2078
3132
  const now = Math.floor(Date.now() / 1e3);
2079
- if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
3133
+ if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
3134
+ category: "refresh-token-grant",
3135
+ reason: "refresh_token_expired"
3136
+ });
3137
+ if (refreshTokenIdleTTL !== void 0) grantData.expiresAt = now + refreshTokenIdleTTL;
2080
3138
  if (grantData.expiresAt !== void 0) {
2081
3139
  const remainingRefreshTokenLifetime = grantData.expiresAt - now;
2082
3140
  if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
2083
3141
  }
2084
- if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
3142
+ if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
3143
+ category: "refresh-token-grant",
3144
+ reason: "requested_ttl_too_short"
3145
+ });
2085
3146
  const accessTokenExpiresAt = now + accessTokenTTL;
2086
3147
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
2087
3148
  const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
@@ -2091,6 +3152,7 @@ var OAuthProviderImpl = class {
2091
3152
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
2092
3153
  grantData.refreshTokenId = newRefreshTokenId;
2093
3154
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
3155
+ if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
2094
3156
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
2095
3157
  const accessTokenData = {
2096
3158
  id: accessTokenId,
@@ -2118,9 +3180,9 @@ var OAuthProviderImpl = class {
2118
3180
  token_type: "bearer",
2119
3181
  expires_in: accessTokenTTL,
2120
3182
  refresh_token: newRefreshToken,
2121
- scope: tokenScopes.join(" ")
3183
+ scope: tokenScopes.join(" "),
3184
+ resource: audience
2122
3185
  };
2123
- if (audience) tokenResponse.resource = audience;
2124
3186
  return new Response(JSON.stringify(tokenResponse), { headers: {
2125
3187
  "Content-Type": "application/json",
2126
3188
  ...NO_CACHE_HEADERS
@@ -2134,7 +3196,7 @@ var OAuthProviderImpl = class {
2134
3196
  * module-private.
2135
3197
  * @param subjectToken - The subject token to exchange
2136
3198
  * @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
2137
- * @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
3199
+ * @param requestedResource - Optional resource/audience; when present, it must match the configured canonical resource
2138
3200
  * @param expiresIn - Optional TTL override in seconds
2139
3201
  * @param clientInfo - The client making the exchange request
2140
3202
  * @param env - Cloudflare Worker environment variables
@@ -2143,24 +3205,61 @@ var OAuthProviderImpl = class {
2143
3205
  */
2144
3206
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
2145
3207
  const tokenSummary = await this.unwrapToken(subjectToken, env);
2146
- if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
3208
+ if (!tokenSummary) throw new OAuthError("invalid_request", {
3209
+ description: "Invalid or expired subject token",
3210
+ internal: {
3211
+ category: "token-exchange-grant",
3212
+ reason: "subject_token_invalid"
3213
+ }
3214
+ });
2147
3215
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
2148
3216
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
2149
- if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
3217
+ if (!grantData) throw new OAuthError("invalid_request", {
3218
+ description: "Grant not found",
3219
+ internal: {
3220
+ category: "token-exchange-grant",
3221
+ reason: "grant_not_found"
3222
+ }
3223
+ });
3224
+ const crossClientExchange = grantData.clientId !== clientInfo.clientId;
3225
+ const crossClientRejection = () => new OAuthError("invalid_request", {
3226
+ description: "The subject token was issued to a different client",
3227
+ internal: {
3228
+ category: "token-exchange-grant",
3229
+ reason: "cross_client_subject_token"
3230
+ }
3231
+ });
3232
+ if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
2150
3233
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
2151
- const configuredResource = this.options.resourceMetadata?.resource;
2152
- if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
2153
- const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
3234
+ const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
2154
3235
  const now = Math.floor(Date.now() / 1e3);
2155
3236
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
2156
- if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
3237
+ if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", {
3238
+ description: "Subject token is too close to expiry to exchange",
3239
+ internal: {
3240
+ category: "token-exchange-grant",
3241
+ reason: "subject_token_near_expiry"
3242
+ }
3243
+ });
2157
3244
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
2158
3245
  if (expiresIn !== void 0) {
2159
- if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
3246
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", {
3247
+ description: "Invalid expires_in parameter",
3248
+ internal: {
3249
+ category: "token-exchange-grant",
3250
+ reason: "expires_in_invalid"
3251
+ }
3252
+ });
2160
3253
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
2161
3254
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
2162
3255
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
2163
- if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
3256
+ if (!subjectTokenData) throw new OAuthError("invalid_request", {
3257
+ description: "Subject token data not found",
3258
+ internal: {
3259
+ category: "token-exchange-grant",
3260
+ reason: "subject_token_data_missing"
3261
+ }
3262
+ });
2164
3263
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
2165
3264
  let accessTokenEncryptionKey = encryptionKey;
2166
3265
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -2169,13 +3268,16 @@ var OAuthProviderImpl = class {
2169
3268
  const callbackOptions = {
2170
3269
  grantType: GrantType.TOKEN_EXCHANGE,
2171
3270
  clientId: clientInfo.clientId,
3271
+ subjectClientId: grantData.clientId,
2172
3272
  userId: tokenSummary.userId,
2173
3273
  grantId: tokenSummary.grantId,
2174
3274
  scope: tokenSummary.grant.scope,
2175
3275
  requestedScope: tokenScopes,
3276
+ resource: newAudience,
2176
3277
  props: decryptedProps
2177
3278
  };
2178
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
3279
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
3280
+ if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
2179
3281
  if (callbackResult) {
2180
3282
  let accessTokenProps = decryptedProps;
2181
3283
  if (callbackResult.newProps) {
@@ -2183,6 +3285,13 @@ var OAuthProviderImpl = class {
2183
3285
  }
2184
3286
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
2185
3287
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
3288
+ if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
3289
+ description: "refreshTokenIdleTTL is only honored during refresh token exchange",
3290
+ internal: {
3291
+ category: "token-exchange-grant",
3292
+ reason: "refresh_token_idle_ttl_wrong_grant"
3293
+ }
3294
+ });
2186
3295
  if (accessTokenProps !== decryptedProps) {
2187
3296
  const tokenResult = await encryptProps(accessTokenProps);
2188
3297
  encryptedAccessTokenProps = tokenResult.encryptedData;
@@ -2191,8 +3300,14 @@ var OAuthProviderImpl = class {
2191
3300
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
2192
3301
  }
2193
3302
  }
2194
- if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
2195
- const tokenResponse = {
3303
+ if (!isValidAccessTokenTTL(accessTokenTTL)) throw new OAuthError("invalid_request", {
3304
+ description: "Requested token lifetime must be at least 60 seconds",
3305
+ internal: {
3306
+ category: "token-exchange-grant",
3307
+ reason: "requested_ttl_too_short"
3308
+ }
3309
+ });
3310
+ return {
2196
3311
  access_token: await this.createAccessToken({
2197
3312
  userId: tokenSummary.userId,
2198
3313
  grantId: tokenSummary.grantId,
@@ -2207,10 +3322,9 @@ var OAuthProviderImpl = class {
2207
3322
  issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
2208
3323
  token_type: "bearer",
2209
3324
  expires_in: accessTokenTTL,
2210
- scope: tokenScopes.join(" ")
3325
+ scope: tokenScopes.join(" "),
3326
+ resource: newAudience
2211
3327
  };
2212
- if (newAudience) tokenResponse.resource = newAudience;
2213
- return tokenResponse;
2214
3328
  }
2215
3329
  /**
2216
3330
  * Handles OAuth 2.0 token exchange requests (RFC 8693)
@@ -2226,18 +3340,36 @@ var OAuthProviderImpl = class {
2226
3340
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
2227
3341
  const requestedScope = body.scope;
2228
3342
  const requestedResource = body.resource;
2229
- if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
2230
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
2231
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
2232
- if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
3343
+ if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }, {
3344
+ category: "token-exchange-grant",
3345
+ reason: "subject_token_missing"
3346
+ });
3347
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" }, {
3348
+ category: "token-exchange-grant",
3349
+ reason: "subject_token_type_missing"
3350
+ });
3351
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" }, {
3352
+ category: "token-exchange-grant",
3353
+ reason: "subject_token_type_unsupported"
3354
+ });
3355
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" }, {
3356
+ category: "token-exchange-grant",
3357
+ reason: "requested_token_type_unsupported"
3358
+ });
2233
3359
  let requestedScopes;
2234
3360
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
2235
3361
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
2236
- else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
3362
+ else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }, {
3363
+ category: "token-exchange-grant",
3364
+ reason: "scope_malformed"
3365
+ });
2237
3366
  let expiresIn;
2238
3367
  if (body.expires_in !== void 0) {
2239
3368
  const requestedTTL = parseInt(body.expires_in, 10);
2240
- if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
3369
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }, {
3370
+ category: "token-exchange-grant",
3371
+ reason: "expires_in_invalid"
3372
+ });
2241
3373
  expiresIn = requestedTTL;
2242
3374
  }
2243
3375
  try {
@@ -2262,10 +3394,16 @@ var OAuthProviderImpl = class {
2262
3394
  */
2263
3395
  async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
2264
3396
  const enterpriseOptions = this.options.enterpriseManagedAuthorization;
2265
- if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
3397
+ if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
3398
+ category: "enterprise-managed-authorization",
3399
+ reason: "grant_disabled"
3400
+ });
2266
3401
  if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
2267
3402
  description: "Enterprise-managed authorization requires client authentication",
2268
3403
  statusCode: 401
3404
+ }, {
3405
+ category: "enterprise-managed-authorization",
3406
+ reason: "client_authentication_required"
2269
3407
  });
2270
3408
  const result = await this.runEmaPipeline({
2271
3409
  body,
@@ -2300,8 +3438,8 @@ var OAuthProviderImpl = class {
2300
3438
  async runEmaPipeline(args) {
2301
3439
  const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
2302
3440
  const { jwksProvider, jtiStore } = this;
2303
- const configuredResource = this.options.resourceMetadata?.resource;
2304
- if (!jwksProvider || !jtiStore || !configuredResource) throw new Error("EMA pipeline invoked without configured adapters");
3441
+ const configuredResource = this.resolveNewTokenResource(body.resource);
3442
+ if (!jwksProvider || !jtiStore) throw new Error("EMA pipeline invoked without configured adapters");
2305
3443
  const now = Math.floor(Date.now() / 1e3);
2306
3444
  const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
2307
3445
  if (!parsed.ok) return parsed;
@@ -2331,7 +3469,6 @@ var OAuthProviderImpl = class {
2331
3469
  expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
2332
3470
  clientId: clientInfo.clientId,
2333
3471
  configuredResource,
2334
- matchOriginOnly: !!this.options.resourceMatchOriginOnly,
2335
3472
  now,
2336
3473
  clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
2337
3474
  maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
@@ -2353,7 +3490,7 @@ var OAuthProviderImpl = class {
2353
3490
  mapperOutput = await enterpriseOptions.mapClaims({
2354
3491
  claims: claims.value.claims,
2355
3492
  clientInfo,
2356
- resource: claims.value.resource,
3493
+ resource: configuredResource,
2357
3494
  requestedScope: requestedScope.value,
2358
3495
  request: args.request,
2359
3496
  env
@@ -2379,7 +3516,7 @@ var OAuthProviderImpl = class {
2379
3516
  mapperProps: mapped.value.props,
2380
3517
  mapperMetadata: mapped.value.metadata,
2381
3518
  assertionScopes: claims.value.assertionScopes,
2382
- resource: claims.value.resource,
3519
+ resource: configuredResource,
2383
3520
  accessTokenTTLSeconds: ttl.value,
2384
3521
  env,
2385
3522
  now: issueNow
@@ -2478,7 +3615,10 @@ var OAuthProviderImpl = class {
2478
3615
  async revokeToken(body, clientInfo, env) {
2479
3616
  const token = body.token;
2480
3617
  const tokenTypeHint = body.token_type_hint;
2481
- if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
3618
+ if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }, {
3619
+ category: "token-revocation",
3620
+ reason: "token_missing"
3621
+ });
2482
3622
  const tokenParts = token.split(":");
2483
3623
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
2484
3624
  const [userId, grantId, _] = tokenParts;
@@ -2533,14 +3673,24 @@ var OAuthProviderImpl = class {
2533
3673
  if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
2534
3674
  description: "Client registration is not enabled",
2535
3675
  statusCode: 501
3676
+ }, {
3677
+ category: "client-registration",
3678
+ reason: "registration_disabled"
2536
3679
  });
2537
3680
  if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
2538
3681
  description: "Method not allowed",
2539
- statusCode: 405
3682
+ statusCode: 405,
3683
+ headers: { Allow: "POST, OPTIONS" }
3684
+ }, {
3685
+ category: "client-registration",
3686
+ reason: "method_not_allowed"
2540
3687
  });
2541
3688
  if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
2542
3689
  description: "Request payload too large, must be under 1 MiB",
2543
3690
  statusCode: 413
3691
+ }, {
3692
+ category: "client-registration",
3693
+ reason: "declared_payload_too_large"
2544
3694
  });
2545
3695
  const callbackRequest = request.clone();
2546
3696
  let parsedJson;
@@ -2549,12 +3699,18 @@ var OAuthProviderImpl = class {
2549
3699
  if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
2550
3700
  description: "Request payload too large, must be under 1 MiB",
2551
3701
  statusCode: 413
3702
+ }, {
3703
+ category: "client-registration",
3704
+ reason: "payload_too_large"
2552
3705
  });
2553
3706
  parsedJson = JSON.parse(text);
2554
3707
  } catch {
2555
3708
  return this.createErrorResponse("invalid_request", {
2556
3709
  description: "Invalid JSON payload",
2557
3710
  statusCode: 400
3711
+ }, {
3712
+ category: "client-registration",
3713
+ reason: "json_malformed"
2558
3714
  });
2559
3715
  }
2560
3716
  let clientMetadata;
@@ -2563,11 +3719,18 @@ var OAuthProviderImpl = class {
2563
3719
  clientMetadata = requireJsonObject(parsedJson);
2564
3720
  metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
2565
3721
  } catch (error) {
2566
- return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
3722
+ return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
3723
+ category: "client-registration",
3724
+ reason: "metadata_invalid",
3725
+ detail: error
3726
+ });
2567
3727
  }
2568
3728
  const authMethod = metadata.tokenEndpointAuthMethod;
2569
3729
  const isPublicClient = authMethod === "none";
2570
- if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
3730
+ if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
3731
+ category: "client-registration",
3732
+ reason: "public_client_forbidden"
3733
+ });
2571
3734
  const clientId = generateRandomString(16);
2572
3735
  let clientSecret;
2573
3736
  let hashedSecret;
@@ -2604,16 +3767,22 @@ var OAuthProviderImpl = class {
2604
3767
  return this.createErrorResponse("server_error", {
2605
3768
  description: error instanceof Error ? error.message : "Client registration callback failed",
2606
3769
  statusCode: 500
3770
+ }, {
3771
+ category: "client-registration",
3772
+ reason: "callback_failed",
3773
+ detail: error
2607
3774
  });
2608
3775
  }
2609
3776
  if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
2610
3777
  description: callbackResult.description || "Client registration denied",
2611
3778
  statusCode: callbackResult.status ?? 400
3779
+ }, {
3780
+ category: "client-registration",
3781
+ reason: "callback_denied",
3782
+ detail: callbackResult
2612
3783
  });
2613
3784
  }
2614
- const clientKvOptions = {};
2615
- if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
2616
- await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
3785
+ await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
2617
3786
  const response = {
2618
3787
  client_id: clientInfo.clientId,
2619
3788
  redirect_uris: clientInfo.redirectUris,
@@ -2652,18 +3821,23 @@ var OAuthProviderImpl = class {
2652
3821
  * @param ctx - Cloudflare Worker execution context
2653
3822
  * @returns Response from the API handler or error
2654
3823
  */
2655
- async handleApiRequest(request, env, ctx) {
3824
+ async handleApiRequest(request, env, ctx, apiRoute) {
2656
3825
  const url = new URL(request.url);
2657
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
3826
+ const { resourceServer } = apiRoute;
3827
+ const configuredResource = resourceServer.resourceMetadata.resource;
3828
+ const externalTokenResolver = resourceServer.resolveExternalToken;
3829
+ const resourceMetadataUrl = this.getResourceMetadataUrlForRequest(url, resourceServer);
3830
+ const challenge = (error, description, scopes = []) => this.buildWwwAuthenticateHeader(resourceMetadataUrl, error, description, scopes, resourceServer);
2658
3831
  const authHeader = request.headers.get("Authorization");
2659
- if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
3832
+ const bearerMatch = authHeader ? /^Bearer[\t ]+([^\s,]+)$/i.exec(authHeader) : null;
3833
+ if (!bearerMatch) return new Response(null, {
2660
3834
  status: 401,
2661
3835
  headers: {
2662
3836
  ...NO_CACHE_HEADERS,
2663
- "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
3837
+ "WWW-Authenticate": challenge()
2664
3838
  }
2665
3839
  });
2666
- const accessToken = authHeader.substring(7);
3840
+ const accessToken = bearerMatch[1];
2667
3841
  const parts = accessToken.split(":");
2668
3842
  const isPossiblyInternalFormat = parts.length === 3;
2669
3843
  let tokenData = null;
@@ -2674,75 +3848,104 @@ var OAuthProviderImpl = class {
2674
3848
  const id = await generateTokenId(accessToken);
2675
3849
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
2676
3850
  }
2677
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
3851
+ if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
2678
3852
  description: "Invalid access token",
2679
3853
  statusCode: 401,
2680
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3854
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3855
+ }, {
3856
+ category: "protected-resource",
3857
+ reason: "token_not_found"
2681
3858
  });
2682
3859
  if (tokenData) {
2683
- const configuredResource = this.options.resourceMetadata?.resource;
2684
- if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
3860
+ const tokenAudience = this.resolveStoredTokenAudience(tokenData.audience);
3861
+ if (!isExactResource(tokenAudience, configuredResource)) return this.createErrorResponse("invalid_token", {
2685
3862
  description: "Access token is not bound to the configured resource",
2686
3863
  statusCode: 401,
2687
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3864
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3865
+ }, {
3866
+ category: "protected-resource",
3867
+ reason: "token_audience_unbound"
2688
3868
  });
2689
3869
  const now = Math.floor(Date.now() / 1e3);
2690
3870
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2691
3871
  description: "Access token expired",
2692
3872
  statusCode: 401,
2693
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3873
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3874
+ }, {
3875
+ category: "protected-resource",
3876
+ reason: "token_expired"
2694
3877
  });
2695
- if (tokenData.audience) {
3878
+ if (tokenAudience) {
2696
3879
  const requestUrl = new URL(request.url);
2697
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2698
- if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
3880
+ const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
3881
+ if (!(Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
2699
3882
  description: "Token audience does not match resource server",
2700
3883
  statusCode: 401,
2701
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
3884
+ headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
3885
+ }, {
3886
+ category: "protected-resource",
3887
+ reason: "audience_mismatch"
2702
3888
  });
2703
3889
  }
2704
3890
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2705
- } else if (this.options.resolveExternalToken) {
3891
+ ctx.auth = {
3892
+ token: accessToken,
3893
+ audience: configuredResource,
3894
+ expiresAt: tokenData.expiresAt,
3895
+ scope: [...tokenData.scope || tokenData.grant.scope],
3896
+ userId: tokenData.userId,
3897
+ clientId: tokenData.grant.clientId
3898
+ };
3899
+ } else if (externalTokenResolver) {
2706
3900
  let ext;
2707
3901
  try {
2708
- ext = await this.options.resolveExternalToken({
3902
+ ext = await externalTokenResolver({
2709
3903
  token: accessToken,
2710
3904
  request,
2711
3905
  env
2712
3906
  });
2713
3907
  } catch (error) {
2714
- const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
3908
+ const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
2715
3909
  if (response) return response;
2716
3910
  throw error;
2717
3911
  }
2718
3912
  if (!ext) return this.createErrorResponse("invalid_token", {
2719
3913
  description: "Invalid access token",
2720
3914
  statusCode: 401,
2721
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3915
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3916
+ }, {
3917
+ category: "protected-resource",
3918
+ reason: "external_token_rejected"
2722
3919
  });
2723
- const configuredResource = this.options.resourceMetadata?.resource;
2724
- if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
3920
+ if (typeof ext.audience !== "string" || !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2725
3921
  description: "External access token is not bound to the configured resource",
2726
3922
  statusCode: 401,
2727
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
3923
+ headers: { "WWW-Authenticate": challenge("invalid_token") }
3924
+ }, {
3925
+ category: "protected-resource",
3926
+ reason: "external_audience_unbound"
2728
3927
  });
2729
3928
  if (ext.audience) {
2730
3929
  const requestUrl = new URL(request.url);
2731
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2732
- if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
3930
+ const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
3931
+ if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
2733
3932
  description: "Token audience does not match resource server",
2734
3933
  statusCode: 401,
2735
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
3934
+ headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
3935
+ }, {
3936
+ category: "protected-resource",
3937
+ reason: "external_audience_mismatch"
2736
3938
  });
2737
3939
  }
2738
3940
  ctx.props = ext.props;
3941
+ ctx.auth = {
3942
+ token: accessToken,
3943
+ audience: configuredResource,
3944
+ scope: []
3945
+ };
2739
3946
  }
2740
3947
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
2741
- const apiHandler = this.findApiHandlerForUrl(url);
2742
- if (!apiHandler) return this.createErrorResponse("invalid_request", {
2743
- description: "No handler found for API route",
2744
- statusCode: 404
2745
- });
3948
+ const apiHandler = apiRoute.handler;
2746
3949
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
2747
3950
  else return new apiHandler.handler(ctx, env).fetch(request);
2748
3951
  }
@@ -2766,7 +3969,7 @@ var OAuthProviderImpl = class {
2766
3969
  const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
2767
3970
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
2768
3971
  try {
2769
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
3972
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
2770
3973
  } catch (error) {
2771
3974
  this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
2772
3975
  throw error;
@@ -2777,7 +3980,12 @@ var OAuthProviderImpl = class {
2777
3980
  throw new OAuthError("temporarily_unavailable", {
2778
3981
  description: "Token issuance is temporarily unavailable; retry shortly",
2779
3982
  statusCode: 429,
2780
- headers: { "Retry-After": "30" }
3983
+ headers: { "Retry-After": "30" },
3984
+ internal: {
3985
+ category: "token-issuance",
3986
+ reason: "kv_rate_limited",
3987
+ detail: error
3988
+ }
2781
3989
  });
2782
3990
  }
2783
3991
  isKvRateLimitError(error) {
@@ -2800,6 +4008,51 @@ var OAuthProviderImpl = class {
2800
4008
  * propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
2801
4009
  * upstream outage is distinguishable from an unregistered client.
2802
4010
  */
4011
+ /**
4012
+ * Write a client record that is subject to `clientRegistrationTTL`. The record carries
4013
+ * the expiry it was written with, so a later token request can tell a registration the
4014
+ * provider may renew from a permanent one without listing KV. With the TTL unset the
4015
+ * record is written to last, and any stamp from an earlier configuration is dropped.
4016
+ */
4017
+ async putRegisteredClient(env, client, now) {
4018
+ const ttl = this.options.clientRegistrationTTL;
4019
+ const { registrationExpiresAt: _previous, ...record } = client;
4020
+ if (ttl === void 0) {
4021
+ await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify(record));
4022
+ return;
4023
+ }
4024
+ await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify({
4025
+ ...record,
4026
+ registrationExpiresAt: now + ttl
4027
+ }), { expirationTtl: ttl });
4028
+ }
4029
+ /**
4030
+ * Renew a dynamically registered client whose registration has passed half of
4031
+ * `clientRegistrationTTL`, so a client still exchanging tokens does not vanish from
4032
+ * under its live grants. Without this, a grant that outlives its registration fails
4033
+ * every refresh with `invalid_client` although nothing was revoked. Only records
4034
+ * stamped with `registrationExpiresAt` qualify: CIMD clients are never stored,
4035
+ * `createClient()` records never expire, and a record from before the stamp existed
4036
+ * cannot be told from a permanent one, so it keeps its original schedule and
4037
+ * re-registers once. The half-life rule bounds this to one write per client per half
4038
+ * TTL. Best effort: the token response it follows has already been produced.
4039
+ *
4040
+ * KV has no compare-and-set, so a `deleteClient()` that lands between the read below and
4041
+ * the write is undone by it, as it would be by a concurrent `updateClient()`. The window
4042
+ * is one KV round trip, once per client per half TTL; closing it needs the serialised
4043
+ * storage of #237.
4044
+ */
4045
+ async renewClientRegistrationIfDue(env, client, now) {
4046
+ const ttl = this.options.clientRegistrationTTL;
4047
+ if (ttl === void 0 || client.registrationExpiresAt === void 0) return;
4048
+ if (client.registrationExpiresAt - now > ttl / 2) return;
4049
+ try {
4050
+ const current = await env.OAUTH_KV.get(`client:${client.clientId}`, { type: "json" });
4051
+ if (current) await this.putRegisteredClient(env, current, now);
4052
+ } catch (error) {
4053
+ console.warn(`Failed to renew client registration ${client.clientId}:`, error);
4054
+ }
4055
+ }
2803
4056
  async getClient(env, clientId) {
2804
4057
  if (this.isClientMetadataUrl(clientId)) {
2805
4058
  if (!this.options.clientIdMetadataDocumentEnabled) {
@@ -2817,33 +4070,140 @@ var OAuthProviderImpl = class {
2817
4070
  const clientKey = `client:${clientId}`;
2818
4071
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2819
4072
  }
4073
+ /** Resolve a value to the registry's canonical spelling, requiring one value. */
4074
+ /**
4075
+ * Resolve a stored grant or token resource. A 0.x record may hold an array; it resolves
4076
+ * when exactly one configured resource appears in it, so multi-audience grants for two
4077
+ * resources this server still hosts are ambiguous and fail closed.
4078
+ */
4079
+ findStoredConfiguredResource(value) {
4080
+ if (!Array.isArray(value)) return this.findConfiguredResource(value);
4081
+ const matches = /* @__PURE__ */ new Set();
4082
+ for (const entry of value) {
4083
+ const configured = this.findConfiguredResource(entry);
4084
+ if (configured) matches.add(configured);
4085
+ }
4086
+ return matches.size === 1 ? [...matches][0] : void 0;
4087
+ }
4088
+ findConfiguredResource(value) {
4089
+ const distinct = Array.isArray(value) ? [...new Set(value)] : [value];
4090
+ const singular = distinct.length === 1 ? distinct[0] : void 0;
4091
+ if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
4092
+ return this.resourceServers.map((server) => server.resourceMetadata.resource).find((configured) => isExactResource(singular, configured));
4093
+ }
4094
+ /** Select the audience for a new interactive authorization. */
4095
+ resolveAuthorizationRequestResource(requestedResource) {
4096
+ if (requestedResource === void 0) {
4097
+ const defaultResource = this.getDefaultAuthorizationResource();
4098
+ if (defaultResource) return defaultResource;
4099
+ throw new AuthorizationError("invalid_target", { description: "The resource parameter is required when the authorization server has multiple resources" });
4100
+ }
4101
+ const configured = this.findConfiguredResource(requestedResource);
4102
+ if (!configured) throw new AuthorizationError("invalid_target", { description: "The resource parameter must name exactly one configured protected resource" });
4103
+ return configured;
4104
+ }
4105
+ /** Whether an existing grant belongs to the replacement bucket for a resource. */
4106
+ shouldReplaceGrantForResource(grantResource, resource) {
4107
+ if (grantResource === void 0) return this.getLegacyGrantResource() === resource;
4108
+ return isExactResource(grantResource, resource);
4109
+ }
4110
+ /** Select the audience for a new non-interactive grant such as EMA. */
4111
+ resolveNewTokenResource(requestedResource) {
4112
+ try {
4113
+ return this.resolveAuthorizationRequestResource(requestedResource);
4114
+ } catch (error) {
4115
+ if (error instanceof AuthorizationError) throw new OAuthError("invalid_target", {
4116
+ description: error.description,
4117
+ internal: {
4118
+ category: "resource-indicator",
4119
+ reason: "resource_invalid",
4120
+ detail: error
4121
+ }
4122
+ });
4123
+ throw error;
4124
+ }
4125
+ }
4126
+ /** Validate explicit resource syntax and configured-resource policy. */
4127
+ validateTokenRequestResourceIndicator(requestedResource) {
4128
+ if (requestedResource === void 0) return;
4129
+ if (!this.findConfiguredResource(requestedResource)) throw new OAuthError("invalid_target", {
4130
+ description: "The resource parameter must name exactly one configured protected resource",
4131
+ internal: {
4132
+ category: "resource-indicator",
4133
+ reason: "resource_not_configured"
4134
+ }
4135
+ });
4136
+ }
4137
+ /** Resolve token exchange strictly within the subject token's audience ceiling. */
4138
+ resolveTokenExchangeResource(requestedResource, subjectResource) {
4139
+ const subjectAudience = this.findStoredConfiguredResource(this.resolveStoredTokenAudience(subjectResource));
4140
+ if (!subjectAudience) throw new OAuthError("invalid_target", {
4141
+ description: "Subject token is not bound to a configured resource",
4142
+ internal: {
4143
+ category: "resource-indicator",
4144
+ reason: "subject_token_audience_unbound"
4145
+ }
4146
+ });
4147
+ const resourceWasProvided = requestedResource !== void 0;
4148
+ const requestedAudience = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
4149
+ if (resourceWasProvided && (!requestedAudience || requestedAudience !== subjectAudience)) throw new OAuthError("invalid_target", {
4150
+ description: "The requested resource must exactly match the subject token audience",
4151
+ internal: {
4152
+ category: "resource-indicator",
4153
+ reason: "resource_subject_token_mismatch"
4154
+ }
4155
+ });
4156
+ return subjectAudience;
4157
+ }
2820
4158
  /**
2821
4159
  * Resolves an access-token audience from a token request and its authorization grant.
2822
- * A configured canonical resource is inherited when omitted but cannot be overridden.
2823
- * Without configuration, RFC 8707 downscoping is allowed, omission inherits a
2824
- * bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
4160
+ * The configured canonical resource is inherited when omitted and cannot be overridden.
2825
4161
  */
2826
- resolveTokenResource(requestedResource, grantedResource) {
4162
+ resolveTokenResource(requestedResource, grant) {
4163
+ const grantedResource = grant.resource;
2827
4164
  const resourceWasProvided = requestedResource !== void 0;
2828
- const requestedAudience = parseResourceParameter(requestedResource);
2829
- if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2830
4165
  const grantResourceWasStored = grantedResource !== void 0;
2831
- const grantedAudience = parseResourceParameter(grantedResource);
2832
- if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2833
- const configuredResource = this.options.resourceMetadata?.resource;
2834
- if (configuredResource) {
2835
- if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2836
- if (isExactResource(grantedResource, configuredResource)) return configuredResource;
2837
- if (!grantResourceWasStored) return configuredResource;
2838
- throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
2839
- }
2840
- const originOnly = !!this.options.resourceMatchOriginOnly;
2841
- if (resourceWasProvided && grantResourceWasStored) {
2842
- const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2843
- const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2844
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
4166
+ const canonicalGrantResource = this.findStoredConfiguredResource(grantedResource);
4167
+ const canonicalRequestedResource = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
4168
+ if (grantResourceWasStored && !canonicalGrantResource) throw new OAuthError("invalid_grant", {
4169
+ description: "The authorization grant is not bound to a configured resource",
4170
+ internal: {
4171
+ category: "resource-indicator",
4172
+ reason: "grant_audience_unbound"
4173
+ }
4174
+ });
4175
+ if (canonicalGrantResource) {
4176
+ if (resourceWasProvided && canonicalRequestedResource !== canonicalGrantResource) throw new OAuthError("invalid_target", {
4177
+ description: "The requested resource does not match the authorization grant",
4178
+ internal: {
4179
+ category: "resource-indicator",
4180
+ reason: "resource_grant_mismatch"
4181
+ }
4182
+ });
4183
+ return {
4184
+ audience: canonicalGrantResource,
4185
+ ...grantedResource === canonicalGrantResource ? {} : { grantResourceBackfill: canonicalGrantResource }
4186
+ };
2845
4187
  }
2846
- return requestedAudience ?? grantedAudience;
4188
+ const legacyGrantResource = this.getLegacyGrantResource();
4189
+ if (!legacyGrantResource) throw new OAuthError("invalid_grant", {
4190
+ description: "This legacy authorization grant has no resource binding and must be reauthorized",
4191
+ internal: {
4192
+ category: "resource-indicator",
4193
+ reason: "legacy_grant_unbound"
4194
+ }
4195
+ });
4196
+ if (resourceWasProvided && canonicalRequestedResource !== legacyGrantResource) throw new OAuthError("invalid_target", {
4197
+ description: "The requested resource does not match the server legacy-grant migration policy",
4198
+ internal: {
4199
+ category: "resource-indicator",
4200
+ reason: "legacy_grant_resource_mismatch"
4201
+ }
4202
+ });
4203
+ return {
4204
+ audience: legacyGrantResource,
4205
+ grantResourceBackfill: legacyGrantResource
4206
+ };
2847
4207
  }
2848
4208
  /**
2849
4209
  * Creates and stores an access token
@@ -2852,7 +4212,13 @@ var OAuthProviderImpl = class {
2852
4212
  */
2853
4213
  async createAccessToken(params) {
2854
4214
  const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
2855
- if (expiresIn < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
4215
+ if (!isValidAccessTokenTTL(expiresIn)) throw new OAuthError("invalid_request", {
4216
+ description: "Requested token lifetime must be at least 60 seconds",
4217
+ internal: {
4218
+ category: "token-issuance",
4219
+ reason: "requested_ttl_too_short"
4220
+ }
4221
+ });
2856
4222
  const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2857
4223
  const now = Math.floor(Date.now() / 1e3);
2858
4224
  const accessTokenId = await generateTokenId(accessToken);
@@ -2908,14 +4274,36 @@ var OAuthProviderImpl = class {
2908
4274
  /**
2909
4275
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2910
4276
  */
2911
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
2912
- let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
4277
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
4278
+ let header = "Bearer realm=\"OAuth\"";
4279
+ if (resourceMetadataUrl) header += `, resource_metadata="${resourceMetadataUrl}"`;
2913
4280
  if (error) header += `, error="${error}"`;
2914
- const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
4281
+ const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
2915
4282
  if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
2916
4283
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2917
4284
  return header;
2918
4285
  }
4286
+ /** Build the RFC 9728 well-known URL for the configured canonical resource. */
4287
+ getConfiguredResourceMetadataUrl(resource) {
4288
+ const authorityStart = resource.indexOf("://") + 3;
4289
+ const suffixOffset = resource.slice(authorityStart).search(/[/?]/);
4290
+ const suffixStart = suffixOffset === -1 ? resource.length : authorityStart + suffixOffset;
4291
+ let suffix = resource.slice(suffixStart);
4292
+ if (suffix === "/") suffix = "";
4293
+ if (suffix.startsWith("/?")) suffix = suffix.slice(1);
4294
+ return resource.slice(0, suffixStart) + PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + suffix;
4295
+ }
4296
+ /**
4297
+ * RFC 9728 §3.3 requires metadata fetched from a challenge to identify the
4298
+ * original protected-resource URL. Descendant and alias routes can still use
4299
+ * a base audience, but must not advertise a document for a different URL.
4300
+ */
4301
+ getResourceMetadataUrlForRequest(requestUrl, resourceServer) {
4302
+ const configuredResource = resourceServer.resourceMetadata.resource;
4303
+ if (isExactResource(requestUrl.href, configuredResource)) return this.getConfiguredResourceMetadataUrl(configuredResource);
4304
+ const resourceUrl = new URL(configuredResource);
4305
+ if (requestUrl.origin === resourceUrl.origin && requestCarriesResourceQuery(requestUrl, resourceUrl) && isPathDescendant(requestUrl.pathname, resourceUrl.pathname)) return this.getConfiguredResourceMetadataUrl(configuredResource);
4306
+ }
2919
4307
  /**
2920
4308
  * Helper function to create OAuth error responses.
2921
4309
  *
@@ -2936,21 +4324,22 @@ var OAuthProviderImpl = class {
2936
4324
  description,
2937
4325
  status: responseStatus,
2938
4326
  headers: responseHeaders,
2939
- ...internal ? { internal } : {},
4327
+ internal,
2940
4328
  ...request ? { request } : {}
2941
4329
  });
2942
- if (customErrorResponse) return customErrorResponse;
2943
4330
  const body = JSON.stringify({
2944
4331
  error: code,
2945
4332
  error_description: description
2946
4333
  });
2947
- return new Response(body, {
4334
+ const response = customErrorResponse ?? new Response(body, {
2948
4335
  status: responseStatus,
2949
4336
  headers: {
2950
4337
  "Content-Type": "application/json",
2951
4338
  ...responseHeaders
2952
4339
  }
2953
4340
  });
4341
+ this.errorResponses.add(response);
4342
+ return response;
2954
4343
  }
2955
4344
  };
2956
4345
  /**
@@ -2978,6 +4367,7 @@ var OAuthProviderImpl = class {
2978
4367
  * async function refreshUpstream(props) {
2979
4368
  * const res = await fetch(...);
2980
4369
  * if (res.status === 401) {
4370
+ * // invalid_grant can never recover: the provider also revokes this grant and its tokens.
2981
4371
  * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
2982
4372
  * }
2983
4373
  * if (res.status === 429) {
@@ -3081,6 +4471,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
3081
4471
  * to clamp absolute expirations when writing grants back to KV.
3082
4472
  */
3083
4473
  const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
4474
+ function isValidAccessTokenTTL(value) {
4475
+ return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
4476
+ }
3084
4477
  /**
3085
4478
  * Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
3086
4479
  * absolute KV expiration. Absolute expirations are validated against KV's clock at the
@@ -3099,8 +4492,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
3099
4492
  */
3100
4493
  const MAX_KV_LIST_LIMIT = 1e3;
3101
4494
  /**
3102
- * Default batch size for paginating existing grants when revoking them
3103
- * during completeAuthorization. Conservative for each KV list page.
4495
+ * How many grants written without key metadata `completeAuthorization()` reads at
4496
+ * once while looking for grants to replace. Grants written with key metadata are
4497
+ * matched from `list()` alone and never read.
3104
4498
  */
3105
4499
  const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
3106
4500
  function getRevokeExistingGrantsBatchSize(batchSize) {
@@ -3108,27 +4502,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
3108
4502
  if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
3109
4503
  return Math.min(batchSize, MAX_KV_LIST_LIMIT);
3110
4504
  }
4505
+ /** KV serialises key metadata as JSON and rejects a write carrying more than this. */
4506
+ const MAX_KV_METADATA_BYTES = 1024;
3111
4507
  /**
3112
- * Length of generated token strings
4508
+ * Key metadata for a grant record, or `undefined` when it would not fit. A grant
4509
+ * written without metadata is read individually when scanned, exactly like a grant
4510
+ * written before metadata existed; that only happens when a `client_id` and two URLs
4511
+ * together exceed a kilobyte.
3113
4512
  */
3114
- const TOKEN_LENGTH = 32;
4513
+ function grantKeyMetadata(grant) {
4514
+ const metadata = { clientId: grant.clientId };
4515
+ if (grant.resource !== void 0) metadata.resource = grant.resource;
4516
+ if (grant.redirectUri !== void 0) metadata.redirectUri = grant.redirectUri;
4517
+ return new TextEncoder().encode(JSON.stringify(metadata)).byteLength <= MAX_KV_METADATA_BYTES ? metadata : void 0;
4518
+ }
4519
+ /** Every grant write goes through here so the key metadata cannot drift from the record. */
4520
+ function grantPutOptions(grant, expiry = {}) {
4521
+ const metadata = grantKeyMetadata(grant);
4522
+ return metadata ? {
4523
+ ...expiry,
4524
+ metadata
4525
+ } : expiry;
4526
+ }
4527
+ /** Whether a listed key carries metadata this version can match a grant on. */
4528
+ function isGrantKeyMetadata(value) {
4529
+ if (typeof value !== "object" || value === null) return false;
4530
+ const { clientId, resource, redirectUri } = value;
4531
+ return typeof clientId === "string" && (resource === void 0 || typeof resource === "string" || Array.isArray(resource)) && (redirectUri === void 0 || typeof redirectUri === "string");
4532
+ }
3115
4533
  /**
3116
- * Validates a resource URI per RFC 8707 Section 2
3117
- * @param uri - The URI string to validate
3118
- * @returns true if valid, false otherwise
4534
+ * Length of generated token strings
3119
4535
  */
3120
- function validateResourceUri(uri) {
3121
- if (!uri || typeof uri !== "string") return false;
3122
- try {
3123
- const parsed = new URL(uri);
3124
- if (!parsed.protocol) return false;
3125
- if (parsed.hash) return false;
3126
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3127
- return true;
3128
- } catch {
3129
- return false;
3130
- }
3131
- }
4536
+ const TOKEN_LENGTH = 32;
3132
4537
  /**
3133
4538
  * Checks if a resource server matches an audience claim.
3134
4539
  * Uses origin comparison (case-insensitive hostname via URL normalization)
@@ -3142,42 +4547,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
3142
4547
  const resource = new URL(resourceServerUrl);
3143
4548
  const audience = new URL(audienceValue);
3144
4549
  if (resource.origin !== audience.origin) return false;
3145
- if (audience.search && resource.search !== audience.search) return false;
4550
+ if (!requestCarriesResourceQuery(resource, audience)) return false;
3146
4551
  if (audience.pathname === "/" || audience.pathname === "") return true;
3147
- return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
4552
+ const descendantPrefix = audience.pathname.endsWith("/") ? audience.pathname : audience.pathname + "/";
4553
+ return resource.pathname === audience.pathname || resource.pathname.startsWith(descendantPrefix);
3148
4554
  } catch {
3149
4555
  return false;
3150
4556
  }
3151
4557
  }
3152
- /**
3153
- * Parses and validates the resource parameter from a token request (RFC 8707)
3154
- * Handles single string or array of strings (from multiple form parameters)
3155
- * @param value - The resource parameter value from the request body
3156
- * @returns The validated value as string, string array, or undefined if validation fails
3157
- */
3158
- function parseResourceParameter(value) {
3159
- if (!value) return;
3160
- const uris = Array.isArray(value) ? value : [value];
3161
- if (uris.length === 0) return;
3162
- for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
3163
- return value;
4558
+ function appendHeaderValue(headers, name, value) {
4559
+ const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
4560
+ if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
4561
+ headers.set(name, values.join(", "));
3164
4562
  }
3165
- /** Whether a request or audience names one exact configured resource. */
4563
+ /** Whether a request or audience names one canonical configured resource. */
3166
4564
  function isExactResource(value, configuredResource) {
3167
- return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
4565
+ if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
4566
+ return typeof value === "string" && resourceMatches(value, configuredResource);
3168
4567
  }
3169
- /**
3170
- * Checks if a requested resource matches a granted resource.
3171
- * When originOnly is true, compares only the origin (scheme + host + port),
3172
- * allowing path-aware resources to match origin-only grants.
3173
- */
3174
- function resourceMatches(requested, granted, originOnly) {
3175
- if (!originOnly) return requested === granted;
3176
- try {
3177
- return new URL(requested).origin === new URL(granted).origin;
3178
- } catch {
3179
- return requested === granted;
3180
- }
4568
+ /** RFC 9110 §9.3.2: a HEAD response carries the GET headers and no body. */
4569
+ function withoutBodyForHead(request, response) {
4570
+ if (request.method !== "HEAD") return response;
4571
+ return new Response(null, {
4572
+ status: response.status,
4573
+ headers: response.headers
4574
+ });
4575
+ }
4576
+ /** Whether `candidate` is `base` or a path-boundary descendant of it (trailing slashes ignored). */
4577
+ function isPathDescendant(candidate, base) {
4578
+ if (base === "" || base === "/") return true;
4579
+ if (base.endsWith("/")) return candidate.startsWith(base);
4580
+ return candidate === base || candidate.startsWith(`${base}/`);
3181
4581
  }
3182
4582
  /**
3183
4583
  * Hashes a secret value using SHA-256
@@ -3248,11 +4648,7 @@ async function generateTokenId(token) {
3248
4648
  */
3249
4649
  function isLoopbackUri(uri) {
3250
4650
  try {
3251
- const host = new URL(uri).hostname;
3252
- if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
3253
- if (host === "::1" || host === "[::1]") return true;
3254
- if (host.toLowerCase() === "localhost") return true;
3255
- return false;
4651
+ return isLoopbackHostname(new URL(uri).hostname);
3256
4652
  } catch {
3257
4653
  return false;
3258
4654
  }
@@ -3431,11 +4827,11 @@ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
3431
4827
  */
3432
4828
  async function deriveKeyFromToken(tokenStr) {
3433
4829
  const encoder = new TextEncoder();
3434
- const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
4830
+ const hmacKey$1 = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
3435
4831
  name: "HMAC",
3436
4832
  hash: "SHA-256"
3437
4833
  }, false, ["sign"]);
3438
- const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
4834
+ const hmacResult = await crypto.subtle.sign("HMAC", hmacKey$1, encoder.encode(tokenStr));
3439
4835
  return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
3440
4836
  }
3441
4837
  /**
@@ -3482,6 +4878,7 @@ var OAuthHelpersImpl = class {
3482
4878
  */
3483
4879
  async parseAuthRequest(request) {
3484
4880
  const url = new URL(request.url);
4881
+ if (this.provider.hasExplicitIssuer && !this.provider.isAuthorizationEndpointRequest(url)) throw new AuthorizationError("invalid_request", { description: "Authorization request was sent to an unconfigured endpoint" });
3485
4882
  const responseType = url.searchParams.get("response_type") || "";
3486
4883
  const clientId = url.searchParams.get("client_id") || "";
3487
4884
  const redirectUri = url.searchParams.get("redirect_uri") || "";
@@ -3504,13 +4901,12 @@ var OAuthHelpersImpl = class {
3504
4901
  const withRedirect = (error) => {
3505
4902
  throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3506
4903
  };
3507
- const resourceWasProvided = resourceParam !== void 0;
3508
- let resource = parseResourceParameter(resourceParam);
3509
- if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3510
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3511
- if (configuredResource) {
3512
- if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
3513
- resource = configuredResource;
4904
+ let resource;
4905
+ try {
4906
+ resource = this.provider.resolveAuthorizationRequestResource(resourceParam);
4907
+ } catch (error) {
4908
+ if (error instanceof AuthorizationError) withRedirect(error);
4909
+ throw error;
3514
4910
  }
3515
4911
  try {
3516
4912
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
@@ -3565,26 +4961,13 @@ var OAuthHelpersImpl = class {
3565
4961
  const clientInfo = await this.lookupClient(clientId);
3566
4962
  if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
3567
4963
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3568
- const configuredResource = this.provider.options.resourceMetadata?.resource;
3569
- const resourceWasProvided = options.request.resource !== void 0;
3570
- const parsedResource = parseResourceParameter(options.request.resource);
3571
- if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
3572
- if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
3573
- const effectiveResource = configuredResource ?? parsedResource;
4964
+ const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
3574
4965
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3575
4966
  let grantsToRevoke = [];
3576
4967
  if (options.revokeExistingGrants !== false) {
3577
4968
  const isCimdClient = this.provider.isClientMetadataUrl(clientId);
3578
- const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
3579
- let cursor;
3580
- do {
3581
- const page = await this.listUserGrants(options.userId, {
3582
- cursor,
3583
- limit: batchSize
3584
- });
3585
- for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
3586
- cursor = page.cursor;
3587
- } while (cursor);
4969
+ const readConcurrency = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
4970
+ grantsToRevoke = await this.findGrantIds(options.userId, (grant) => grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant.resource, effectiveResource), readConcurrency);
3588
4971
  }
3589
4972
  const grantId = generateRandomString(16);
3590
4973
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
@@ -3596,8 +4979,7 @@ var OAuthHelpersImpl = class {
3596
4979
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3597
4980
  const accessTokenExpiresAt = now + accessTokenTTL;
3598
4981
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
3599
- const audience = parseResourceParameter(effectiveResource);
3600
- if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
4982
+ const audience = effectiveResource;
3601
4983
  const grant = {
3602
4984
  id: grantId,
3603
4985
  clientId: options.request.clientId,
@@ -3610,7 +4992,7 @@ var OAuthHelpersImpl = class {
3610
4992
  redirectUri: options.request.redirectUri
3611
4993
  };
3612
4994
  const grantKey = `grant:${options.userId}:${grantId}`;
3613
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
4995
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
3614
4996
  const accessTokenData = {
3615
4997
  id: accessTokenId,
3616
4998
  grantId,
@@ -3633,6 +5015,7 @@ var OAuthHelpersImpl = class {
3633
5015
  fragment.set("token_type", "bearer");
3634
5016
  fragment.set("expires_in", accessTokenTTL.toString());
3635
5017
  fragment.set("scope", options.scope.join(" "));
5018
+ fragment.set("resource", effectiveResource);
3636
5019
  if (options.request.state) fragment.set("state", options.request.state);
3637
5020
  if (options.request.issuer) fragment.set("iss", options.request.issuer);
3638
5021
  redirectUrl.hash = fragment.toString();
@@ -3661,7 +5044,7 @@ var OAuthHelpersImpl = class {
3661
5044
  redirectUri: options.request.redirectUri
3662
5045
  };
3663
5046
  const grantKey = `grant:${options.userId}:${grantId}`;
3664
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
5047
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
3665
5048
  const redirectUrl = new URL(options.request.redirectUri);
3666
5049
  redirectUrl.searchParams.set("code", authCode);
3667
5050
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
@@ -3673,6 +5056,39 @@ var OAuthHelpersImpl = class {
3673
5056
  }
3674
5057
  }
3675
5058
  /**
5059
+ * IDs of the user's grants that satisfy `matches`, judged from KV key metadata
5060
+ * wherever a grant carries it. Only grants written without metadata, by a version
5061
+ * before 1.0 or because theirs would not fit, are read, `readConcurrency` at a time.
5062
+ *
5063
+ * This runs on the interactive authorization path, where the pre-metadata cost of one
5064
+ * `get()` per grant broke re-authorization for a user with more grants than a Worker
5065
+ * has subrequests. The cost is now one `list()` per thousand grants plus one `get()`
5066
+ * per legacy grant, and every refresh rewrites its grant with metadata, so the legacy
5067
+ * share shrinks on its own.
5068
+ */
5069
+ async findGrantIds(userId, matches, readConcurrency) {
5070
+ const prefix = `grant:${userId}:`;
5071
+ const found = [];
5072
+ let cursor;
5073
+ do {
5074
+ const page = await this.env.OAUTH_KV.list({
5075
+ prefix,
5076
+ limit: MAX_KV_LIST_LIMIT,
5077
+ cursor
5078
+ });
5079
+ const toRead = [];
5080
+ for (const key of page.keys) if (isGrantKeyMetadata(key.metadata)) {
5081
+ if (matches(key.metadata)) found.push(key.name.slice(prefix.length));
5082
+ } else toRead.push(key.name);
5083
+ for (let start = 0; start < toRead.length; start += readConcurrency) {
5084
+ const grants = await Promise.all(toRead.slice(start, start + readConcurrency).map((name) => this.env.OAUTH_KV.get(name, { type: "json" })));
5085
+ for (const grant of grants) if (grant && matches(grant)) found.push(grant.id);
5086
+ }
5087
+ cursor = page.list_complete ? void 0 : page.cursor;
5088
+ } while (cursor);
5089
+ return found;
5090
+ }
5091
+ /**
3676
5092
  * Creates a new OAuth client
3677
5093
  * @param clientInfo - Partial client information to create the client with
3678
5094
  * @returns A Promise resolving to the created client info
@@ -3764,9 +5180,8 @@ var OAuthHelpersImpl = class {
3764
5180
  };
3765
5181
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
3766
5182
  else delete updatedClient.clientSecret;
3767
- const clientKvOptions = {};
3768
- if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
3769
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
5183
+ if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
5184
+ else await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
3770
5185
  const response = toPublicClientInfo(updatedClient);
3771
5186
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
3772
5187
  return response;
@@ -3816,7 +5231,8 @@ var OAuthHelpersImpl = class {
3816
5231
  metadata: grantData.metadata,
3817
5232
  createdAt: grantData.createdAt,
3818
5233
  expiresAt: grantData.expiresAt,
3819
- redirectUri: grantData.redirectUri
5234
+ redirectUri: grantData.redirectUri,
5235
+ resource: grantData.resource
3820
5236
  };
3821
5237
  grantSummaries.push(summary);
3822
5238
  }
@@ -3833,6 +5249,30 @@ var OAuthHelpersImpl = class {
3833
5249
  * @param userId - The ID of the user who owns the grant
3834
5250
  * @returns A Promise resolving when the revocation is confirmed.
3835
5251
  */
5252
+ /** {@inheritDoc OAuthHelpers.isConsentRemembered} */
5253
+ isConsentRemembered(request, authRequest, remember) {
5254
+ return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
5255
+ }
5256
+ /** {@inheritDoc OAuthHelpers.beginConsent} */
5257
+ beginConsent(authRequest) {
5258
+ return beginConsent(this.env.OAUTH_KV, this.provider.consentCookies, authRequest);
5259
+ }
5260
+ /** {@inheritDoc OAuthHelpers.approveConsent} */
5261
+ approveConsent(request, handle, options) {
5262
+ return approveConsent(this.env.OAUTH_KV, this.provider.consentCookies, this.provider.options.scopesSupported, request, handle, options);
5263
+ }
5264
+ /** {@inheritDoc OAuthHelpers.denyConsent} */
5265
+ denyConsent(request, handle, options) {
5266
+ return denyConsent(this.env.OAUTH_KV, this.provider.consentCookies, request, handle, options);
5267
+ }
5268
+ /** {@inheritDoc OAuthHelpers.beginUpstream} */
5269
+ beginUpstream(authRequest, options) {
5270
+ return beginUpstream(this.env.OAUTH_KV, this.provider.consentCookies, authRequest, options);
5271
+ }
5272
+ /** {@inheritDoc OAuthHelpers.finishUpstream} */
5273
+ finishUpstream(request) {
5274
+ return finishUpstream(this.env.OAUTH_KV, this.provider.consentCookies, request);
5275
+ }
3836
5276
  async revokeGrant(grantId, userId) {
3837
5277
  const grantKey = `grant:${userId}:${grantId}`;
3838
5278
  const tokenPrefix = `token:${userId}:${grantId}:`;
@@ -3966,4 +5406,4 @@ var OAuthHelpersImpl = class {
3966
5406
  var oauth_provider_default = OAuthProvider;
3967
5407
 
3968
5408
  //#endregion
3969
- export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
5409
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };