@cloudflare/workers-oauth-provider 0.8.2 → 0.9.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.
@@ -1,5 +1,70 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
+ //#region src/oauth-capabilities.ts
4
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
5
+ function buildOAuthServerCapabilities(options) {
6
+ return {
7
+ grantTypes: [
8
+ "authorization_code",
9
+ "refresh_token",
10
+ ...options.allowImplicitFlow ? ["implicit"] : [],
11
+ ...options.allowTokenExchangeGrant ? ["urn:ietf:params:oauth:grant-type:token-exchange"] : [],
12
+ ...options.enterpriseManagedAuthorization ? ["urn:ietf:params:oauth:grant-type:jwt-bearer"] : []
13
+ ],
14
+ responseTypes: options.allowImplicitFlow ? ["code", "token"] : ["code"],
15
+ tokenEndpointAuthMethods: [
16
+ "client_secret_basic",
17
+ "client_secret_post",
18
+ "none"
19
+ ],
20
+ codeChallengeMethods: options.allowPlainPKCE ? ["plain", "S256"] : ["S256"]
21
+ };
22
+ }
23
+ function validateClientCapabilities(server, client) {
24
+ if (!server.tokenEndpointAuthMethods.includes(client.tokenEndpointAuthMethod)) throw new Error(`Unsupported token_endpoint_auth_method: ${client.tokenEndpointAuthMethod}`);
25
+ const unsupportedGrant = client.grantTypes.find((grantType) => !server.grantTypes.includes(grantType));
26
+ if (unsupportedGrant) throw new Error(`Unsupported grant_type: ${unsupportedGrant}`);
27
+ const unsupportedResponse = client.responseTypes.find((responseType) => !server.responseTypes.includes(responseType));
28
+ if (unsupportedResponse) throw new Error(`Unsupported response_type: ${unsupportedResponse}`);
29
+ if (client.grantTypes.includes("authorization_code") !== client.responseTypes.includes("code")) throw new Error("grant_types authorization_code and response_types code must be registered together");
30
+ if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
31
+ }
32
+ function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
33
+ if (!responseType) throw new Error("invalid_request: response_type is required");
34
+ if (!server.responseTypes.includes(responseType)) throw new Error(`unsupported_response_type: the authorization server does not support ${responseType}`);
35
+ if (!(clientResponseTypes ?? ["code"]).includes(responseType)) throw new Error(`unauthorized_client: the client is not registered for response_type ${responseType}`);
36
+ }
37
+ /** Parse a PKCE method, applying RFC 7636's default of `plain`. */
38
+ function normalizePkceCodeChallengeMethod(method) {
39
+ const effectiveMethod = method ?? "plain";
40
+ if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new Error(`Unsupported PKCE code_challenge_method: ${effectiveMethod}`);
41
+ return effectiveMethod;
42
+ }
43
+ /** Require a syntactically valid PKCE method that the server advertises. */
44
+ function validatePkceCodeChallengeMethod(server, method) {
45
+ const effectiveMethod = normalizePkceCodeChallengeMethod(method);
46
+ if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
47
+ return effectiveMethod;
48
+ }
49
+ /** Validate authorization-request PKCE against server and client capabilities. */
50
+ function validateAuthorizationPkce(server, request, client) {
51
+ if (request.codeChallenge) {
52
+ validatePkceCodeChallengeMethod(server, request.codeChallengeMethod);
53
+ return;
54
+ }
55
+ if (request.codeChallengeMethod) throw new Error("PKCE code_challenge is required when code_challenge_method is provided.");
56
+ if (request.responseType === "code" && client.tokenEndpointAuthMethod === "none") throw new Error("Public clients must use PKCE with the authorization code flow.");
57
+ }
58
+ function validateAuthorizationServerScopes(scopes) {
59
+ if (!scopes) return;
60
+ if (scopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("scopesSupported must contain valid OAuth scope tokens");
61
+ if (new Set(scopes).size !== scopes.length) throw new TypeError("scopesSupported must not contain duplicate values");
62
+ }
63
+ function isValidOAuthScopeToken(scopeToken) {
64
+ return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
65
+ }
66
+
67
+ //#endregion
3
68
  //#region src/ema/constants.ts
4
69
  /**
5
70
  * Constants for MCP Enterprise-Managed Authorization (EMA).
@@ -105,6 +170,7 @@ function emaErrorToWire(e) {
105
170
  case "jwks_fetch_failed":
106
171
  case "invalid_claim":
107
172
  case "aud_mismatch":
173
+ case "unsupported_claim":
108
174
  case "expired":
109
175
  case "iat_in_future":
110
176
  case "nbf_in_future":
@@ -473,7 +539,8 @@ function isWellFormedTrustedIssuer(issuer) {
473
539
  *
474
540
  * Enforces (in order):
475
541
  * - presence + type of `iss`, `sub`, `aud`, `client_id`, `jti`, `exp`, `iat`
476
- * - `aud` contains the AS's expected audience
542
+ * - `aud` is the AS's expected audience, as a string or single-element array
543
+ * - unsupported authorization-bearing claims fail closed
477
544
  * - `client_id` matches the authenticated client
478
545
  * - optional `resource` is a valid RFC 8707 URI and matches the AS's configured resource
479
546
  * - the configured resource is used when `resource` is omitted
@@ -496,6 +563,10 @@ function validateIdJagClaims(input) {
496
563
  if (!sub.ok) return sub;
497
564
  const aud = readAudienceClaim(rawClaims);
498
565
  if (!aud.ok) return aud;
566
+ for (const claim of ["authorization_details", "cnf"]) if (rawClaims[claim] !== void 0) return err({
567
+ reason: "unsupported_claim",
568
+ claim
569
+ });
499
570
  const resource = rawClaims.resource === void 0 ? ok(configuredResource) : readRequiredString(rawClaims, "resource");
500
571
  if (!resource.ok) return resource;
501
572
  const claimClientId = readRequiredString(rawClaims, "client_id");
@@ -506,7 +577,7 @@ function validateIdJagClaims(input) {
506
577
  if (!exp.ok) return exp;
507
578
  const iat = readNumericDateClaim(rawClaims, "iat");
508
579
  if (!iat.ok) return iat;
509
- if (!(Array.isArray(aud.value) ? aud.value : [aud.value]).includes(expectedAudience)) return err({
580
+ if ((Array.isArray(aud.value) ? aud.value[0] : aud.value) !== expectedAudience) return err({
510
581
  reason: "aud_mismatch",
511
582
  expected: expectedAudience,
512
583
  got: aud.value
@@ -657,7 +728,7 @@ function readRequiredString(claims, claimName) {
657
728
  function readAudienceClaim(claims) {
658
729
  const aud = claims.aud;
659
730
  if (typeof aud === "string" && aud.length > 0) return ok(aud);
660
- if (Array.isArray(aud) && aud.length > 0 && aud.every((v) => typeof v === "string" && v.length > 0)) return ok(aud);
731
+ if (Array.isArray(aud) && aud.length === 1 && typeof aud[0] === "string" && aud[0].length > 0) return ok(aud);
661
732
  return err({
662
733
  reason: "invalid_claim",
663
734
  claim: "aud"
@@ -679,6 +750,7 @@ const NO_CACHE_HEADERS = {
679
750
  "Cache-Control": "no-store",
680
751
  Pragma: "no-cache"
681
752
  };
753
+ const BASIC_AUTH_CHALLENGE = "Basic realm=\"OAuth\"";
682
754
  if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
683
755
  /**
684
756
  * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
@@ -789,6 +861,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
789
861
  ...options
790
862
  };
791
863
  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).`);
864
+ this.serverCapabilities = buildOAuthServerCapabilities({
865
+ allowImplicitFlow: !!this.options.allowImplicitFlow,
866
+ allowPlainPKCE: this.options.allowPlainPKCE === true,
867
+ allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
868
+ enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
869
+ });
870
+ validateAuthorizationServerScopes(this.options.scopesSupported);
871
+ this.validateResourceMetadataOptions(this.options.resourceMetadata);
792
872
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
793
873
  if (this.options.enterpriseManagedAuthorization) {
794
874
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -828,6 +908,25 @@ var OAuthProviderImpl = class OAuthProviderImpl {
828
908
  };
829
909
  throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
830
910
  }
911
+ /** Validate configured RFC 9728 protected resource metadata. */
912
+ validateResourceMetadataOptions(options) {
913
+ if (!options) return;
914
+ if (options.resource && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
915
+ if (options.authorization_servers !== void 0) {
916
+ if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
917
+ for (const issuer of options.authorization_servers) {
918
+ let parsed;
919
+ try {
920
+ parsed = new URL(issuer);
921
+ } catch {
922
+ throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
923
+ }
924
+ if (parsed.protocol !== "https:" || parsed.search || parsed.hash) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
925
+ }
926
+ }
927
+ if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
928
+ if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
929
+ }
831
930
  /**
832
931
  * Validates MCP Enterprise-Managed Authorization configuration at construction time.
833
932
  *
@@ -887,7 +986,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
887
986
  }
888
987
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
889
988
  if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
890
- else return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
989
+ return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
891
990
  }
892
991
  /**
893
992
  * Decodes a token and returns token data with decrypted props
@@ -980,6 +1079,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
980
1079
  if (!suffix || suffix === "/") return requestUrl.origin;
981
1080
  return `${requestUrl.origin}${suffix}`;
982
1081
  }
1082
+ createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
1083
+ return this.createErrorResponse("invalid_client", {
1084
+ description,
1085
+ statusCode: 401,
1086
+ ...basicAuthenticationAttempted ? { headers: { "WWW-Authenticate": BASIC_AUTH_CHALLENGE } } : {}
1087
+ }, internal, request);
1088
+ }
983
1089
  /**
984
1090
  * Parses and validates a token endpoint request (used for both token exchange and revocation)
985
1091
  * @param request - The HTTP request to parse
@@ -990,13 +1096,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
990
1096
  description: "Method not allowed",
991
1097
  statusCode: 405
992
1098
  });
993
- let contentType = request.headers.get("Content-Type") || "";
1099
+ const contentType = request.headers.get("Content-Type") || "";
994
1100
  let body = {};
995
- if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", {
1101
+ if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
996
1102
  description: "Content-Type must be application/x-www-form-urlencoded",
997
1103
  statusCode: 400
998
1104
  });
999
- const formData = await request.formData();
1105
+ let formData;
1106
+ try {
1107
+ formData = await request.formData();
1108
+ } catch {
1109
+ return this.createErrorResponse("invalid_request", {
1110
+ description: "Request body must be valid application/x-www-form-urlencoded data",
1111
+ statusCode: 400
1112
+ });
1113
+ }
1000
1114
  const processedKeys = /* @__PURE__ */ new Set();
1001
1115
  for (const [key, value] of formData.entries()) {
1002
1116
  if (processedKeys.has(key)) continue;
@@ -1008,55 +1122,50 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1008
1122
  });
1009
1123
  body[key] = allValues.length > 1 ? allValues : value;
1010
1124
  }
1011
- const authHeader = request.headers.get("Authorization");
1125
+ const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
1126
+ const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
1012
1127
  let clientId = "";
1013
1128
  let clientSecret = "";
1014
- if (authHeader && authHeader.startsWith("Basic ")) {
1015
- if (body.client_id || body.client_secret) return this.createErrorResponse("invalid_request", {
1129
+ if (basicAuthenticationAttempted) {
1130
+ if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
1016
1131
  description: "Client must not use multiple authentication methods",
1017
1132
  statusCode: 400
1018
1133
  });
1019
- const credentials = atob(authHeader.substring(6));
1020
- const separatorIndex = credentials.indexOf(":");
1021
- if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
1022
- description: "Client authentication failed: invalid Basic credentials",
1023
- statusCode: 401
1024
- });
1025
- const id = credentials.substring(0, separatorIndex);
1026
- const secret = credentials.substring(separatorIndex + 1);
1027
- clientId = decodeFormUrlEncodedComponent(id);
1028
- clientSecret = decodeFormUrlEncodedComponent(secret);
1134
+ if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
1135
+ clientId = basicAuthorization.clientId;
1136
+ clientSecret = basicAuthorization.clientSecret;
1029
1137
  } else {
1030
1138
  clientId = body.client_id;
1031
1139
  clientSecret = body.client_secret || "";
1032
1140
  }
1033
- if (!clientId) return this.createErrorResponse("invalid_client", {
1034
- description: "Client ID is required",
1035
- statusCode: 401
1036
- });
1037
- const clientInfo = await this.getClient(env, clientId);
1038
- if (!clientInfo) return this.createErrorResponse("invalid_client", {
1039
- description: "Client not found",
1040
- statusCode: 401
1041
- });
1042
- if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
1043
- if (!clientSecret) return this.createErrorResponse("invalid_client", {
1044
- description: "Client authentication failed: missing client_secret",
1045
- statusCode: 401
1046
- });
1047
- if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1048
- description: "Client authentication failed: client has no registered secret",
1049
- statusCode: 401
1050
- });
1051
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1052
- description: "Client authentication failed: invalid client_secret",
1053
- statusCode: 401
1054
- });
1141
+ if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted);
1142
+ let clientInfo;
1143
+ try {
1144
+ clientInfo = await this.getClient(env, clientId);
1145
+ } catch (error) {
1146
+ if (error instanceof CimdFetchError) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
1147
+ category: "client-id-metadata-document",
1148
+ reason: error.reason,
1149
+ detail: {
1150
+ metadataUrl: error.metadataUrl,
1151
+ message: error.detail
1152
+ }
1153
+ }, request);
1154
+ throw error;
1155
+ }
1156
+ if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
1157
+ const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
1158
+ if (presentedAuthMethod !== clientInfo.tokenEndpointAuthMethod) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted);
1159
+ if (presentedAuthMethod !== "none") {
1160
+ if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1161
+ if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
1162
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted);
1055
1163
  }
1164
+ const isRevocationRequest = !body.grant_type && !!body.token;
1056
1165
  return {
1057
1166
  body,
1058
1167
  clientInfo,
1059
- isRevocationRequest: !body.grant_type && !!body.token
1168
+ isRevocationRequest
1060
1169
  };
1061
1170
  }
1062
1171
  /**
@@ -1122,6 +1231,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1122
1231
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
1123
1232
  newResponse.headers.set("Access-Control-Allow-Methods", "*");
1124
1233
  newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
1234
+ const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
1235
+ for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
1236
+ newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
1125
1237
  newResponse.headers.set("Access-Control-Max-Age", "86400");
1126
1238
  return newResponse;
1127
1239
  }
@@ -1136,15 +1248,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1136
1248
  const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
1137
1249
  let registrationEndpoint = void 0;
1138
1250
  if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
1139
- const responseTypesSupported = ["code"];
1140
- if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
1141
- const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
1142
- if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
1143
- const authorizationGrantProfilesSupported = [];
1144
- if (this.options.enterpriseManagedAuthorization) {
1145
- grantTypesSupported.push(GrantType.JWT_BEARER);
1146
- authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
1147
- }
1251
+ const responseTypesSupported = this.serverCapabilities.responseTypes;
1252
+ const grantTypesSupported = this.serverCapabilities.grantTypes;
1253
+ const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
1148
1254
  const metadata = {
1149
1255
  issuer: new URL(tokenEndpoint).origin,
1150
1256
  authorization_endpoint: authorizeEndpoint,
@@ -1155,17 +1261,22 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1155
1261
  response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
1156
1262
  grant_types_supported: grantTypesSupported,
1157
1263
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
1158
- token_endpoint_auth_methods_supported: [
1159
- "client_secret_basic",
1160
- "client_secret_post",
1161
- "none"
1162
- ],
1264
+ token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
1163
1265
  revocation_endpoint: tokenEndpoint,
1164
- code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
1266
+ code_challenge_methods_supported: this.serverCapabilities.codeChallengeMethods,
1267
+ authorization_response_iss_parameter_supported: true,
1165
1268
  client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
1166
1269
  };
1167
1270
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1168
1271
  }
1272
+ /** Scopes that are baseline requirements of the protected resource itself. */
1273
+ getProtectedResourceScopes() {
1274
+ return this.normalizeProtectedResourceScopes(this.options.resourceMetadata?.scopes_supported ?? []);
1275
+ }
1276
+ /** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
1277
+ normalizeProtectedResourceScopes(scopes) {
1278
+ return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
1279
+ }
1169
1280
  /**
1170
1281
  * Handles the OAuth Protected Resource Metadata endpoint
1171
1282
  * Implements RFC 9728 for OAuth Protected Resource Metadata
@@ -1176,10 +1287,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1176
1287
  const rm = this.options.resourceMetadata;
1177
1288
  const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1178
1289
  const authServerOrigin = new URL(tokenEndpointUrl).origin;
1290
+ const resourceScopes = this.getProtectedResourceScopes();
1179
1291
  const metadata = {
1180
1292
  resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
1181
1293
  authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
1182
- scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
1294
+ ...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
1183
1295
  bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
1184
1296
  };
1185
1297
  if (rm?.resource_name) metadata.resource_name = rm.resource_name;
@@ -1208,20 +1320,44 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1208
1320
  }
1209
1321
  }
1210
1322
  /**
1211
- * Build a structured OAuth `/token` error response from an OAuth error.
1212
- *
1213
- * The supported form is throwing this package's exported `OAuthError`.
1214
- * Anything else is re-thrown so unexpected failures still surface as 500s.
1323
+ * Build a structured OAuth token-endpoint response from an OAuth error.
1215
1324
  *
1216
- * Use `headers['Retry-After']` for rate-limit / transient-failure backoff
1217
- * hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
1218
- * HTTP-date is allowed).
1325
+ * The supported form is throwing this package's exported `OAuthError` from
1326
+ * token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
1327
+ * unexpected failures still surface as 500s.
1219
1328
  */
1220
1329
  createOAuthErrorResponse(error) {
1221
1330
  if (!(error instanceof OAuthError)) return void 0;
1222
1331
  return this.createErrorResponse(error.code, error.options);
1223
1332
  }
1224
1333
  /**
1334
+ * Build a structured protected-resource response from an external-token error.
1335
+ *
1336
+ * Only this package's exported `ExternalTokenError` is converted. Standard
1337
+ * bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
1338
+ * supplied one. Other errors retain the pre-existing behavior and are re-thrown.
1339
+ */
1340
+ createExternalTokenErrorResponse(error, resourceMetadataUrl) {
1341
+ if (!(error instanceof ExternalTokenError)) return void 0;
1342
+ const headers = error.headers ?? {};
1343
+ const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
1344
+ const isBearerError = error.code === "invalid_token" && error.statusCode === 401 || error.code === "insufficient_scope" && error.statusCode === 403;
1345
+ let challengeHeaders;
1346
+ if (isBearerError && !hasChallenge) {
1347
+ const requiredScopes = [...new Set(error.requiredScopes ?? [])];
1348
+ if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
1349
+ challengeHeaders = {
1350
+ ...headers,
1351
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
1352
+ };
1353
+ }
1354
+ return this.createErrorResponse(error.code, {
1355
+ description: error.description,
1356
+ statusCode: error.statusCode,
1357
+ headers: challengeHeaders ?? headers
1358
+ });
1359
+ }
1360
+ /**
1225
1361
  * Handles the authorization code grant type
1226
1362
  * Exchanges an authorization code for access and refresh tokens
1227
1363
  * @param body - The parsed request body
@@ -1249,6 +1385,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1249
1385
  } catch {}
1250
1386
  return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
1251
1387
  }
1388
+ let codeChallengeMethod;
1389
+ try {
1390
+ codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
1391
+ } catch (error) {
1392
+ return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" });
1393
+ }
1252
1394
  const isPkceEnabled = !!grantData.codeChallenge;
1253
1395
  if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1254
1396
  if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
@@ -1256,7 +1398,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1256
1398
  if (isPkceEnabled) {
1257
1399
  if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
1258
1400
  let calculatedChallenge;
1259
- if (grantData.codeChallengeMethod === "S256") {
1401
+ if (codeChallengeMethod === "S256") {
1260
1402
  const data = new TextEncoder().encode(codeVerifier);
1261
1403
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1262
1404
  const hashArray = Array.from(new Uint8Array(hashBuffer));
@@ -1264,6 +1406,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1264
1406
  } else calculatedChallenge = codeVerifier;
1265
1407
  if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
1266
1408
  }
1409
+ const audience = this.resolveTokenResource(body.resource, grantData.resource);
1267
1410
  let accessTokenTTL = this.options.accessTokenTTL;
1268
1411
  let refreshTokenTTL = this.options.refreshTokenTTL;
1269
1412
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
@@ -1325,14 +1468,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1325
1468
  grantData.expiresAt = expiresAt;
1326
1469
  }
1327
1470
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1328
- const originOnly = !!this.options.resourceMatchOriginOnly;
1329
- if (body.resource && grantData.resource) {
1330
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
1331
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1332
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
1333
- }
1334
- const audience = parseResourceParameter(body.resource || grantData.resource);
1335
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1336
1471
  const tokenResponse = {
1337
1472
  access_token: await this.createAccessToken({
1338
1473
  userId,
@@ -1382,6 +1517,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1382
1517
  const now$1 = Math.floor(Date.now() / 1e3);
1383
1518
  if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
1384
1519
  }
1520
+ const audience = this.resolveTokenResource(body.resource, grantData.resource);
1385
1521
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1386
1522
  const accessTokenId = await generateTokenId(newAccessToken);
1387
1523
  let accessTokenTTL = this.options.accessTokenTTL;
@@ -1453,14 +1589,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1453
1589
  grantData.refreshTokenId = newRefreshTokenId;
1454
1590
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
1455
1591
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1456
- const originOnly = !!this.options.resourceMatchOriginOnly;
1457
- if (body.resource && grantData.resource) {
1458
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
1459
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1460
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
1461
- }
1462
- const audience = parseResourceParameter(body.resource || grantData.resource);
1463
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1464
1592
  const accessTokenData = {
1465
1593
  id: accessTokenId,
1466
1594
  grantId,
@@ -1502,7 +1630,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1502
1630
  * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
1503
1631
  * module-private.
1504
1632
  * @param subjectToken - The subject token to exchange
1505
- * @param requestedScopes - Optional narrowed scopes (must be subset of original)
1633
+ * @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
1506
1634
  * @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
1507
1635
  * @param expiresIn - Optional TTL override in seconds
1508
1636
  * @param clientInfo - The client making the exchange request
@@ -1516,19 +1644,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1516
1644
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
1517
1645
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1518
1646
  if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
1519
- let tokenScopes = this.downscope(requestedScopes, grantData.scope);
1520
- const originOnly = !!this.options.resourceMatchOriginOnly;
1521
- let newAudience = tokenSummary.audience;
1522
- if (requestedResource) {
1523
- if (grantData.resource) {
1524
- const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
1525
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1526
- 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" });
1527
- }
1528
- const parsedResource = parseResourceParameter(requestedResource);
1529
- if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1530
- newAudience = parsedResource;
1531
- }
1647
+ let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
1648
+ const configuredResource = this.options.resourceMetadata?.resource;
1649
+ if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
1650
+ const newAudience = this.resolveTokenResource(requestedResource, grantData.resource);
1532
1651
  const now = Math.floor(Date.now() / 1e3);
1533
1652
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
1534
1653
  if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
@@ -1566,7 +1685,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1566
1685
  encryptedAccessTokenProps = tokenResult.encryptedData;
1567
1686
  accessTokenEncryptionKey = tokenResult.key;
1568
1687
  }
1569
- if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1688
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
1570
1689
  }
1571
1690
  }
1572
1691
  if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
@@ -1935,7 +2054,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1935
2054
  statusCode: 400
1936
2055
  });
1937
2056
  }
1938
- const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
2057
+ let authMethod;
2058
+ let grantTypes;
2059
+ let responseTypes;
2060
+ try {
2061
+ authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
2062
+ grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2063
+ responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
2064
+ validateClientCapabilities(this.serverCapabilities, {
2065
+ tokenEndpointAuthMethod: authMethod,
2066
+ grantTypes,
2067
+ responseTypes
2068
+ });
2069
+ } catch (error) {
2070
+ return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
2071
+ }
1939
2072
  const isPublicClient = authMethod === "none";
1940
2073
  if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
1941
2074
  const clientId = generateRandomString(16);
@@ -1961,12 +2094,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1961
2094
  jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
1962
2095
  i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
1963
2096
  contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
1964
- grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
1965
- GrantType.AUTHORIZATION_CODE,
1966
- GrantType.REFRESH_TOKEN,
1967
- ...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
1968
- ],
1969
- responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
2097
+ grantTypes,
2098
+ responseTypes,
1970
2099
  registrationDate: Math.floor(Date.now() / 1e3),
1971
2100
  tokenEndpointAuthMethod: authMethod
1972
2101
  };
@@ -2038,10 +2167,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2038
2167
  const url = new URL(request.url);
2039
2168
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
2040
2169
  const authHeader = request.headers.get("Authorization");
2041
- if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
2042
- description: "Missing or invalid access token",
2043
- statusCode: 401,
2044
- headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") }
2170
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
2171
+ status: 401,
2172
+ headers: {
2173
+ ...NO_CACHE_HEADERS,
2174
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
2175
+ }
2045
2176
  });
2046
2177
  const accessToken = authHeader.substring(7);
2047
2178
  const parts = accessToken.split(":");
@@ -2060,6 +2191,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2060
2191
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2061
2192
  });
2062
2193
  if (tokenData) {
2194
+ const configuredResource = this.options.resourceMetadata?.resource;
2195
+ if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2196
+ description: "Access token is not bound to the configured resource",
2197
+ statusCode: 401,
2198
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2199
+ });
2063
2200
  const now = Math.floor(Date.now() / 1e3);
2064
2201
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2065
2202
  description: "Access token expired",
@@ -2068,7 +2205,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2068
2205
  });
2069
2206
  if (tokenData.audience) {
2070
2207
  const requestUrl = new URL(request.url);
2071
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2208
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2072
2209
  if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2073
2210
  description: "Token audience does not match resource server",
2074
2211
  statusCode: 401,
@@ -2077,19 +2214,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2077
2214
  }
2078
2215
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2079
2216
  } else if (this.options.resolveExternalToken) {
2080
- const ext = await this.options.resolveExternalToken({
2081
- token: accessToken,
2082
- request,
2083
- env
2084
- });
2217
+ let ext;
2218
+ try {
2219
+ ext = await this.options.resolveExternalToken({
2220
+ token: accessToken,
2221
+ request,
2222
+ env
2223
+ });
2224
+ } catch (error) {
2225
+ const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
2226
+ if (response) return response;
2227
+ throw error;
2228
+ }
2085
2229
  if (!ext) return this.createErrorResponse("invalid_token", {
2086
2230
  description: "Invalid access token",
2087
2231
  statusCode: 401,
2088
2232
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2089
2233
  });
2234
+ const configuredResource = this.options.resourceMetadata?.resource;
2235
+ if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2236
+ description: "External access token is not bound to the configured resource",
2237
+ statusCode: 401,
2238
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2239
+ });
2090
2240
  if (ext.audience) {
2091
2241
  const requestUrl = new URL(request.url);
2092
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2242
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2093
2243
  if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2094
2244
  description: "Token audience does not match resource server",
2095
2245
  statusCode: 401,
@@ -2156,7 +2306,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2156
2306
  *
2157
2307
  * @param env - Cloudflare Worker environment variables
2158
2308
  * @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
2159
- * @returns The client information, or null if not found
2309
+ * @returns The client information, or null if the client does not exist. Null means
2310
+ * definitive absence; failures to determine the answer throw instead (KV errors
2311
+ * propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
2312
+ * upstream outage is distinguishable from an unregistered client.
2160
2313
  */
2161
2314
  async getClient(env, clientId) {
2162
2315
  if (this.isClientMetadataUrl(clientId)) {
@@ -2169,13 +2322,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2169
2322
  return await this.fetchClientMetadataDocument(clientId);
2170
2323
  } catch (error) {
2171
2324
  console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
2172
- return null;
2325
+ throw new CimdFetchError(clientId, error);
2173
2326
  }
2174
2327
  }
2175
2328
  const clientKey = `client:${clientId}`;
2176
2329
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2177
2330
  }
2178
2331
  /**
2332
+ * Resolves an access-token audience from a token request and its authorization grant.
2333
+ * Explicit resource configuration requires one exact value in both places. Without
2334
+ * configuration, RFC 8707 downscoping is allowed and omission inherits the grant.
2335
+ */
2336
+ resolveTokenResource(requestedResource, grantedResource) {
2337
+ const requestedAudience = parseResourceParameter(requestedResource);
2338
+ if (requestedResource && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2339
+ const grantedAudience = parseResourceParameter(grantedResource);
2340
+ if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2341
+ const configuredResource = this.options.resourceMetadata?.resource;
2342
+ if (configuredResource && (!isExactResource(grantedResource, configuredResource) || !isExactResource(requestedResource, configuredResource))) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2343
+ if (!configuredResource && requestedResource && !grantedResource) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
2344
+ const originOnly = configuredResource ? false : !!this.options.resourceMatchOriginOnly;
2345
+ if (requestedResource && grantedResource) {
2346
+ const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2347
+ const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2348
+ 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" });
2349
+ }
2350
+ return requestedAudience ?? grantedAudience;
2351
+ }
2352
+ /**
2179
2353
  * Creates and stores an access token
2180
2354
  * @param params - Options for creating the access token
2181
2355
  * @returns The access token string
@@ -2210,15 +2384,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2210
2384
  return accessToken;
2211
2385
  }
2212
2386
  /**
2213
- * Downscopes requested scopes to only include those that are in the grant
2214
- * Filters out any requested scopes that are not in the granted scopes
2387
+ * Restricts requested scopes to the scopes available for the current flow.
2388
+ * If no scope is requested, all available scopes are returned.
2215
2389
  * @param requestedScope - The scope parameter from the request (string or array)
2216
- * @param grantedScopes - The scopes that were granted in the authorization
2217
- * @returns The filtered scopes that are a subset of the granted scopes
2390
+ * @param allowedScopes - The maximum scopes available for the current flow
2391
+ * @returns The requested scopes that are included in the allowed scopes
2218
2392
  */
2219
- downscope(requestedScope, grantedScopes) {
2220
- if (!requestedScope) return grantedScopes;
2221
- return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
2393
+ downscope(requestedScope, allowedScopes) {
2394
+ if (!requestedScope) return allowedScopes;
2395
+ return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => allowedScopes.includes(scope));
2222
2396
  }
2223
2397
  /**
2224
2398
  * Checks if the global_fetch_strictly_public compatibility flag is enabled.
@@ -2247,7 +2421,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2247
2421
  this.CIMD_FETCH_TIMEOUT_MS = 1e4;
2248
2422
  }
2249
2423
  static {
2250
- this.CIMD_ALLOWED_AUTH_METHODS = ["none", "private_key_jwt"];
2424
+ this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
2251
2425
  }
2252
2426
  /**
2253
2427
  * Validates that a field is a string or undefined
@@ -2364,15 +2538,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2364
2538
  if (contentLength && parseInt(contentLength, 10) > OAuthProviderImpl.CIMD_MAX_SIZE_BYTES) throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${OAuthProviderImpl.CIMD_MAX_SIZE_BYTES})`);
2365
2539
  const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
2366
2540
  const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
2541
+ const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
2367
2542
  const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
2368
- const tokenEndpointAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2543
+ const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2544
+ const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
2545
+ const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
2369
2546
  if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
2547
+ if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
2370
2548
  if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
2371
- if (tokenEndpointAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(tokenEndpointAuthMethod)) throw new Error(`token_endpoint_auth_method "${tokenEndpointAuthMethod}" is not allowed for CIMD clients. Allowed methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
2549
+ if (declaredAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(declaredAuthMethod) || authMethodChoices && !authMethodChoices.some((method) => OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(method))) throw new Error(`CIMD client does not support an accepted token endpoint authentication method. Supported methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
2550
+ const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2551
+ const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
2552
+ const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
2553
+ validateClientCapabilities(this.serverCapabilities, {
2554
+ tokenEndpointAuthMethod: effectiveAuthMethod,
2555
+ grantTypes,
2556
+ responseTypes
2557
+ });
2372
2558
  return {
2373
2559
  clientId,
2374
2560
  redirectUris,
2375
- clientName: OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name"),
2561
+ clientName,
2376
2562
  clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
2377
2563
  logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
2378
2564
  policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
@@ -2380,9 +2566,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2380
2566
  jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
2381
2567
  i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
2382
2568
  contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
2383
- grantTypes: OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || ["authorization_code"],
2384
- responseTypes: OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"],
2385
- tokenEndpointAuthMethod: tokenEndpointAuthMethod || "none"
2569
+ grantTypes,
2570
+ responseTypes,
2571
+ tokenEndpointAuthMethod: effectiveAuthMethod
2386
2572
  };
2387
2573
  } finally {
2388
2574
  clearTimeout(timeoutId);
@@ -2426,8 +2612,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2426
2612
  /**
2427
2613
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2428
2614
  */
2429
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
2430
- let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
2615
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
2616
+ let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
2617
+ if (error) header += `, error="${error}"`;
2618
+ const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
2619
+ if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
2431
2620
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2432
2621
  return header;
2433
2622
  }
@@ -2439,7 +2628,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2439
2628
  * so the public response stays RFC-compliant and free of information leak
2440
2629
  * while the deployer can still observe which check failed.
2441
2630
  */
2442
- createErrorResponse(code, options, internal) {
2631
+ createErrorResponse(code, options, internal, request) {
2443
2632
  const { description } = options;
2444
2633
  const responseStatus = options.statusCode ?? 400;
2445
2634
  const responseHeaders = {
@@ -2451,7 +2640,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2451
2640
  description,
2452
2641
  status: responseStatus,
2453
2642
  headers: responseHeaders,
2454
- ...internal ? { internal } : {}
2643
+ ...internal ? { internal } : {},
2644
+ ...request ? { request } : {}
2455
2645
  });
2456
2646
  if (customErrorResponse) return customErrorResponse;
2457
2647
  const body = JSON.stringify({
@@ -2468,11 +2658,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2468
2658
  }
2469
2659
  };
2470
2660
  /**
2471
- * Structured OAuth 2.0 error.
2661
+ * Structured OAuth 2.0 token-endpoint error.
2472
2662
  *
2473
- * Throw from a `tokenExchangeCallback` (or any code it calls — the error
2474
- * propagates naturally up through deep call stacks) to surface a standard
2475
- * `/token` error response (`{ error, error_description }`) instead of a
2663
+ * Throw from a `tokenExchangeCallback` or any code it calls to surface a
2664
+ * standard OAuth token response (`{ error, error_description }`) instead of a
2476
2665
  * generic `500 Internal Server Error`.
2477
2666
  *
2478
2667
  * Anything thrown that is **not** an `OAuthError` continues to surface as
@@ -2522,6 +2711,61 @@ var OAuthError = class extends Error {
2522
2711
  }
2523
2712
  };
2524
2713
  /**
2714
+ * Intentional public error from an external bearer-token validator.
2715
+ *
2716
+ * Throw only from `resolveExternalToken` when an expected validation outcome
2717
+ * should become a structured protected-resource response. Ordinary errors and
2718
+ * {@link OAuthError} retain their pre-existing behavior and propagate as
2719
+ * unexpected failures.
2720
+ */
2721
+ var ExternalTokenError = class extends Error {
2722
+ /**
2723
+ * Creates an intentional external-token validation error.
2724
+ * @param code - Standard OAuth error code to return
2725
+ * @param options - Public response details
2726
+ */
2727
+ constructor(code, options) {
2728
+ super(options.description);
2729
+ this.name = "ExternalTokenError";
2730
+ this.code = code;
2731
+ this.description = options.description;
2732
+ this.statusCode = options.statusCode;
2733
+ this.headers = options.headers;
2734
+ this.requiredScopes = options.requiredScopes;
2735
+ }
2736
+ };
2737
+ /**
2738
+ * Thrown when fetching a Client ID Metadata Document (CIMD) fails — the
2739
+ * server-to-server fetch errored, timed out, or returned an invalid document.
2740
+ * Distinct from a client that simply does not exist, which is reported as a
2741
+ * null client lookup result.
2742
+ *
2743
+ * At the token endpoint the provider handles this itself: the wire response
2744
+ * stays a generic `invalid_client` / "Client not found", and the failure is
2745
+ * reported through the `onError` hook's `internal` field (category
2746
+ * `client-id-metadata-document`) together with the originating `request`.
2747
+ *
2748
+ * `OAuthHelpers` methods that look up clients (`lookupClient`, and methods
2749
+ * built on it such as `exchangeToken`) let this error propagate to the
2750
+ * caller. Callers that previously relied on a `null` result for these
2751
+ * failures should catch it to preserve their error contract.
2752
+ */
2753
+ var CimdFetchError = class extends Error {
2754
+ /**
2755
+ * Creates an error for a failed CIMD fetch or validation.
2756
+ * @param metadataUrl - The CIMD URL that could not be resolved
2757
+ * @param cause - The underlying fetch or validation failure
2758
+ */
2759
+ constructor(metadataUrl, cause) {
2760
+ const detail = cause instanceof Error ? cause.message : String(cause);
2761
+ super(`CIMD fetch failed for ${metadataUrl}: ${detail}`);
2762
+ this.reason = "metadata_resolution_failed";
2763
+ this.name = "CimdFetchError";
2764
+ this.metadataUrl = metadataUrl;
2765
+ this.detail = detail;
2766
+ }
2767
+ };
2768
+ /**
2525
2769
  * Default expiration time for access tokens (1 hour in seconds)
2526
2770
  */
2527
2771
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
@@ -2573,10 +2817,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
2573
2817
  */
2574
2818
  const TOKEN_LENGTH = 32;
2575
2819
  /**
2576
- * RFC 6749 Section 3.3 scope-token grammar.
2577
- */
2578
- const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
2579
- /**
2580
2820
  * Validates a resource URI per RFC 8707 Section 2
2581
2821
  * @param uri - The URI string to validate
2582
2822
  * @returns true if valid, false otherwise
@@ -2606,6 +2846,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
2606
2846
  const resource = new URL(resourceServerUrl);
2607
2847
  const audience = new URL(audienceValue);
2608
2848
  if (resource.origin !== audience.origin) return false;
2849
+ if (audience.search && resource.search !== audience.search) return false;
2609
2850
  if (audience.pathname === "/" || audience.pathname === "") return true;
2610
2851
  return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
2611
2852
  } catch {
@@ -2624,6 +2865,10 @@ function parseResourceParameter(value) {
2624
2865
  for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
2625
2866
  return value;
2626
2867
  }
2868
+ /** Whether a request or audience names one exact configured resource. */
2869
+ function isExactResource(value, configuredResource) {
2870
+ return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
2871
+ }
2627
2872
  /**
2628
2873
  * Checks if a requested resource matches a granted resource.
2629
2874
  * When originOnly is true, compares only the origin (scheme + host + port),
@@ -2646,6 +2891,29 @@ async function hashSecret(secret) {
2646
2891
  return generateTokenId(secret);
2647
2892
  }
2648
2893
  /**
2894
+ * Parses RFC 6749 HTTP Basic client credentials.
2895
+ */
2896
+ function parseBasicAuthorizationHeader(header) {
2897
+ if (!header) return { kind: "not-basic" };
2898
+ const schemeEnd = header.search(/[ \t]/);
2899
+ if ((schemeEnd === -1 ? header : header.slice(0, schemeEnd)).toLowerCase() !== "basic") return { kind: "not-basic" };
2900
+ if (schemeEnd === -1) return { kind: "malformed" };
2901
+ const encodedCredentials = header.slice(schemeEnd).trim();
2902
+ if (!encodedCredentials || /[ \t]/.test(encodedCredentials)) return { kind: "malformed" };
2903
+ try {
2904
+ const credentials = atob(encodedCredentials);
2905
+ const separatorIndex = credentials.indexOf(":");
2906
+ if (separatorIndex === -1) return { kind: "malformed" };
2907
+ return {
2908
+ kind: "credentials",
2909
+ clientId: decodeFormUrlEncodedComponent(credentials.slice(0, separatorIndex)),
2910
+ clientSecret: decodeFormUrlEncodedComponent(credentials.slice(separatorIndex + 1))
2911
+ };
2912
+ } catch {
2913
+ return { kind: "malformed" };
2914
+ }
2915
+ }
2916
+ /**
2649
2917
  * Decodes an application/x-www-form-urlencoded component.
2650
2918
  * @param value - The encoded component value
2651
2919
  * @returns The decoded component value
@@ -2769,9 +3037,6 @@ function parseJwtJsonPart(encoded) {
2769
3037
  throw new Error("Malformed JWT part");
2770
3038
  }
2771
3039
  }
2772
- function isValidOAuthScopeToken(scopeToken) {
2773
- return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
2774
- }
2775
3040
  /**
2776
3041
  * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
2777
3042
  */
@@ -2943,6 +3208,8 @@ var OAuthHelpersImpl = class {
2943
3208
  * Parses an OAuth authorization request from the HTTP request
2944
3209
  * @param request - The HTTP request containing OAuth parameters
2945
3210
  * @returns The parsed authorization request parameters
3211
+ * @throws Error when the response type is missing, unsupported, or not registered for the client
3212
+ * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
2946
3213
  */
2947
3214
  async parseAuthRequest(request) {
2948
3215
  const url = new URL(request.url);
@@ -2952,20 +3219,26 @@ var OAuthHelpersImpl = class {
2952
3219
  const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
2953
3220
  const state = url.searchParams.get("state") || "";
2954
3221
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
2955
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
3222
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || void 0;
3223
+ const issuer = this.provider.getAuthorizationServerIssuer(url);
2956
3224
  const resourceParams = url.searchParams.getAll("resource");
2957
3225
  const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
2958
3226
  validateRedirectUriScheme(redirectUri);
2959
- const resource = parseResourceParameter(resourceParam);
3227
+ let resource = parseResourceParameter(resourceParam);
2960
3228
  if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
2961
- if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
2962
- if (codeChallengeMethod === "plain" && this.provider.options.allowPlainPKCE === false) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
3229
+ const configuredResource = this.provider.options.resourceMetadata?.resource;
3230
+ if (configuredResource && !isExactResource(resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3231
+ resource ??= url.origin;
2963
3232
  if (clientId) {
2964
3233
  const clientInfo = await this.lookupClient(clientId);
2965
3234
  if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
2966
- if (clientInfo && redirectUri) {
2967
- if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
2968
- }
3235
+ if (!redirectUri || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
3236
+ validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
3237
+ validateAuthorizationPkce(this.provider.serverCapabilities, {
3238
+ responseType,
3239
+ codeChallenge,
3240
+ codeChallengeMethod
3241
+ }, clientInfo);
2969
3242
  }
2970
3243
  return {
2971
3244
  responseType,
@@ -2975,13 +3248,20 @@ var OAuthHelpersImpl = class {
2975
3248
  state,
2976
3249
  codeChallenge,
2977
3250
  codeChallengeMethod,
2978
- resource
3251
+ resource,
3252
+ issuer
2979
3253
  };
2980
3254
  }
2981
3255
  /**
2982
3256
  * Looks up a client by its client ID
2983
3257
  * @param clientId - The client ID to look up
2984
- * @returns A Promise resolving to the client info, or null if not found
3258
+ * @returns A Promise resolving to the client info, or null if the client does not
3259
+ * exist. Null means definitive absence; failures to determine the answer throw
3260
+ * instead (KV errors propagate, and a CIMD metadata fetch failure throws
3261
+ * `CimdFetchError`), so an upstream outage cannot masquerade as an unregistered
3262
+ * client.
3263
+ * @throws CimdFetchError when the client ID is a CIMD URL and fetching or
3264
+ * validating the metadata document fails.
2985
3265
  */
2986
3266
  async lookupClient(clientId) {
2987
3267
  return await this.provider.getClient(this.env, clientId);
@@ -2992,12 +3272,19 @@ var OAuthHelpersImpl = class {
2992
3272
  * - For implicit flow: generating an access token directly
2993
3273
  * @param options - Options specifying the grant details
2994
3274
  * @returns A Promise resolving to an object containing the redirect URL
3275
+ * @throws Error when the request's response type is not permitted
3276
+ * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
2995
3277
  */
2996
3278
  async completeAuthorization(options) {
2997
3279
  const { clientId, redirectUri } = options.request;
2998
3280
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
2999
3281
  const clientInfo = await this.lookupClient(clientId);
3000
3282
  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.");
3283
+ validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3284
+ const configuredResource = this.provider.options.resourceMetadata?.resource;
3285
+ if (configuredResource && !isExactResource(options.request.resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3286
+ const effectiveResource = options.request.resource ?? options.request.issuer;
3287
+ validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3001
3288
  let grantsToRevoke = [];
3002
3289
  if (options.revokeExistingGrants !== false) {
3003
3290
  const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
@@ -3021,8 +3308,8 @@ var OAuthHelpersImpl = class {
3021
3308
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3022
3309
  const accessTokenExpiresAt = now + accessTokenTTL;
3023
3310
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
3024
- const audience = parseResourceParameter(options.request.resource);
3025
- if (options.request.resource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
3311
+ const audience = parseResourceParameter(effectiveResource);
3312
+ if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
3026
3313
  const grant = {
3027
3314
  id: grantId,
3028
3315
  clientId: options.request.clientId,
@@ -3031,7 +3318,7 @@ var OAuthHelpersImpl = class {
3031
3318
  metadata: options.metadata,
3032
3319
  encryptedProps: encryptedData,
3033
3320
  createdAt: now,
3034
- resource: options.request.resource
3321
+ resource: effectiveResource
3035
3322
  };
3036
3323
  const grantKey = `grant:${options.userId}:${grantId}`;
3037
3324
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -3058,6 +3345,7 @@ var OAuthHelpersImpl = class {
3058
3345
  fragment.set("expires_in", accessTokenTTL.toString());
3059
3346
  fragment.set("scope", options.scope.join(" "));
3060
3347
  if (options.request.state) fragment.set("state", options.request.state);
3348
+ if (options.request.issuer) fragment.set("iss", options.request.issuer);
3061
3349
  redirectUrl.hash = fragment.toString();
3062
3350
  try {
3063
3351
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
@@ -3080,13 +3368,14 @@ var OAuthHelpersImpl = class {
3080
3368
  authCodeWrappedKey,
3081
3369
  codeChallenge: options.request.codeChallenge,
3082
3370
  codeChallengeMethod: options.request.codeChallengeMethod,
3083
- resource: options.request.resource
3371
+ resource: effectiveResource
3084
3372
  };
3085
3373
  const grantKey = `grant:${options.userId}:${grantId}`;
3086
3374
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
3087
3375
  const redirectUrl = new URL(options.request.redirectUri);
3088
3376
  redirectUrl.searchParams.set("code", authCode);
3089
3377
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
3378
+ if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
3090
3379
  try {
3091
3380
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
3092
3381
  } catch {}
@@ -3279,6 +3568,7 @@ var OAuthHelpersImpl = class {
3279
3568
  * Implements OAuth 2.0 Token Exchange (RFC 8693)
3280
3569
  * @param options - Options for token exchange including subject token and optional modifications
3281
3570
  * @returns Promise resolving to token response with new access token
3571
+ * @throws CimdFetchError when the grant's client ID is a CIMD URL whose document cannot be resolved
3282
3572
  */
3283
3573
  async exchangeToken(options) {
3284
3574
  const tokenSummary = await this.unwrapToken(options.subjectToken);
@@ -3381,4 +3671,4 @@ var OAuthHelpersImpl = class {
3381
3671
  var oauth_provider_default = OAuthProvider;
3382
3672
 
3383
3673
  //#endregion
3384
- export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
3674
+ export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };