@cloudflare/workers-oauth-provider 0.8.3 → 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
@@ -1016,55 +1122,50 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1016
1122
  });
1017
1123
  body[key] = allValues.length > 1 ? allValues : value;
1018
1124
  }
1019
- const authHeader = request.headers.get("Authorization");
1125
+ const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
1126
+ const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
1020
1127
  let clientId = "";
1021
1128
  let clientSecret = "";
1022
- if (authHeader && authHeader.startsWith("Basic ")) {
1023
- 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", {
1024
1131
  description: "Client must not use multiple authentication methods",
1025
1132
  statusCode: 400
1026
1133
  });
1027
- const credentials = atob(authHeader.substring(6));
1028
- const separatorIndex = credentials.indexOf(":");
1029
- if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
1030
- description: "Client authentication failed: invalid Basic credentials",
1031
- statusCode: 401
1032
- });
1033
- const id = credentials.substring(0, separatorIndex);
1034
- const secret = credentials.substring(separatorIndex + 1);
1035
- clientId = decodeFormUrlEncodedComponent(id);
1036
- 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;
1037
1137
  } else {
1038
1138
  clientId = body.client_id;
1039
1139
  clientSecret = body.client_secret || "";
1040
1140
  }
1041
- if (!clientId) return this.createErrorResponse("invalid_client", {
1042
- description: "Client ID is required",
1043
- statusCode: 401
1044
- });
1045
- const clientInfo = await this.getClient(env, clientId);
1046
- if (!clientInfo) return this.createErrorResponse("invalid_client", {
1047
- description: "Client not found",
1048
- statusCode: 401
1049
- });
1050
- if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
1051
- if (!clientSecret) return this.createErrorResponse("invalid_client", {
1052
- description: "Client authentication failed: missing client_secret",
1053
- statusCode: 401
1054
- });
1055
- if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1056
- description: "Client authentication failed: client has no registered secret",
1057
- statusCode: 401
1058
- });
1059
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1060
- description: "Client authentication failed: invalid client_secret",
1061
- statusCode: 401
1062
- });
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);
1063
1163
  }
1164
+ const isRevocationRequest = !body.grant_type && !!body.token;
1064
1165
  return {
1065
1166
  body,
1066
1167
  clientInfo,
1067
- isRevocationRequest: !body.grant_type && !!body.token
1168
+ isRevocationRequest
1068
1169
  };
1069
1170
  }
1070
1171
  /**
@@ -1130,6 +1231,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1130
1231
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
1131
1232
  newResponse.headers.set("Access-Control-Allow-Methods", "*");
1132
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(", "));
1133
1237
  newResponse.headers.set("Access-Control-Max-Age", "86400");
1134
1238
  return newResponse;
1135
1239
  }
@@ -1144,15 +1248,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1144
1248
  const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
1145
1249
  let registrationEndpoint = void 0;
1146
1250
  if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
1147
- const responseTypesSupported = ["code"];
1148
- if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
1149
- const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
1150
- if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
1151
- const authorizationGrantProfilesSupported = [];
1152
- if (this.options.enterpriseManagedAuthorization) {
1153
- grantTypesSupported.push(GrantType.JWT_BEARER);
1154
- authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
1155
- }
1251
+ const responseTypesSupported = this.serverCapabilities.responseTypes;
1252
+ const grantTypesSupported = this.serverCapabilities.grantTypes;
1253
+ const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
1156
1254
  const metadata = {
1157
1255
  issuer: new URL(tokenEndpoint).origin,
1158
1256
  authorization_endpoint: authorizeEndpoint,
@@ -1163,17 +1261,22 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1163
1261
  response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
1164
1262
  grant_types_supported: grantTypesSupported,
1165
1263
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
1166
- token_endpoint_auth_methods_supported: [
1167
- "client_secret_basic",
1168
- "client_secret_post",
1169
- "none"
1170
- ],
1264
+ token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
1171
1265
  revocation_endpoint: tokenEndpoint,
1172
- 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,
1173
1268
  client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
1174
1269
  };
1175
1270
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
1176
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
+ }
1177
1280
  /**
1178
1281
  * Handles the OAuth Protected Resource Metadata endpoint
1179
1282
  * Implements RFC 9728 for OAuth Protected Resource Metadata
@@ -1184,10 +1287,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1184
1287
  const rm = this.options.resourceMetadata;
1185
1288
  const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1186
1289
  const authServerOrigin = new URL(tokenEndpointUrl).origin;
1290
+ const resourceScopes = this.getProtectedResourceScopes();
1187
1291
  const metadata = {
1188
1292
  resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
1189
1293
  authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
1190
- scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
1294
+ ...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
1191
1295
  bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
1192
1296
  };
1193
1297
  if (rm?.resource_name) metadata.resource_name = rm.resource_name;
@@ -1216,20 +1320,44 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1216
1320
  }
1217
1321
  }
1218
1322
  /**
1219
- * Build a structured OAuth `/token` error response from an OAuth error.
1323
+ * Build a structured OAuth token-endpoint response from an OAuth error.
1220
1324
  *
1221
- * The supported form is throwing this package's exported `OAuthError`.
1222
- * Anything else is re-thrown so unexpected failures still surface as 500s.
1223
- *
1224
- * Use `headers['Retry-After']` for rate-limit / transient-failure backoff
1225
- * hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
1226
- * 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.
1227
1328
  */
1228
1329
  createOAuthErrorResponse(error) {
1229
1330
  if (!(error instanceof OAuthError)) return void 0;
1230
1331
  return this.createErrorResponse(error.code, error.options);
1231
1332
  }
1232
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
+ /**
1233
1361
  * Handles the authorization code grant type
1234
1362
  * Exchanges an authorization code for access and refresh tokens
1235
1363
  * @param body - The parsed request body
@@ -1257,6 +1385,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1257
1385
  } catch {}
1258
1386
  return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
1259
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
+ }
1260
1394
  const isPkceEnabled = !!grantData.codeChallenge;
1261
1395
  if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1262
1396
  if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
@@ -1264,7 +1398,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1264
1398
  if (isPkceEnabled) {
1265
1399
  if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
1266
1400
  let calculatedChallenge;
1267
- if (grantData.codeChallengeMethod === "S256") {
1401
+ if (codeChallengeMethod === "S256") {
1268
1402
  const data = new TextEncoder().encode(codeVerifier);
1269
1403
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1270
1404
  const hashArray = Array.from(new Uint8Array(hashBuffer));
@@ -1272,14 +1406,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1272
1406
  } else calculatedChallenge = codeVerifier;
1273
1407
  if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
1274
1408
  }
1275
- const originOnly = !!this.options.resourceMatchOriginOnly;
1276
- if (body.resource && grantData.resource) {
1277
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
1278
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1279
- 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" });
1280
- }
1281
- const audience = parseResourceParameter(body.resource || grantData.resource);
1282
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1409
+ const audience = this.resolveTokenResource(body.resource, grantData.resource);
1283
1410
  let accessTokenTTL = this.options.accessTokenTTL;
1284
1411
  let refreshTokenTTL = this.options.refreshTokenTTL;
1285
1412
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
@@ -1390,6 +1517,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1390
1517
  const now$1 = Math.floor(Date.now() / 1e3);
1391
1518
  if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
1392
1519
  }
1520
+ const audience = this.resolveTokenResource(body.resource, grantData.resource);
1393
1521
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1394
1522
  const accessTokenId = await generateTokenId(newAccessToken);
1395
1523
  let accessTokenTTL = this.options.accessTokenTTL;
@@ -1461,14 +1589,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1461
1589
  grantData.refreshTokenId = newRefreshTokenId;
1462
1590
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
1463
1591
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
1464
- const originOnly = !!this.options.resourceMatchOriginOnly;
1465
- if (body.resource && grantData.resource) {
1466
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
1467
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1468
- 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" });
1469
- }
1470
- const audience = parseResourceParameter(body.resource || grantData.resource);
1471
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1472
1592
  const accessTokenData = {
1473
1593
  id: accessTokenId,
1474
1594
  grantId,
@@ -1525,18 +1645,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1525
1645
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1526
1646
  if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
1527
1647
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
1528
- const originOnly = !!this.options.resourceMatchOriginOnly;
1529
- let newAudience = tokenSummary.audience;
1530
- if (requestedResource) {
1531
- if (grantData.resource) {
1532
- const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
1533
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
1534
- 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" });
1535
- }
1536
- const parsedResource = parseResourceParameter(requestedResource);
1537
- if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
1538
- newAudience = parsedResource;
1539
- }
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);
1540
1651
  const now = Math.floor(Date.now() / 1e3);
1541
1652
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
1542
1653
  if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
@@ -1943,7 +2054,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1943
2054
  statusCode: 400
1944
2055
  });
1945
2056
  }
1946
- 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
+ }
1947
2072
  const isPublicClient = authMethod === "none";
1948
2073
  if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
1949
2074
  const clientId = generateRandomString(16);
@@ -1969,12 +2094,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1969
2094
  jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
1970
2095
  i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
1971
2096
  contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
1972
- grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
1973
- GrantType.AUTHORIZATION_CODE,
1974
- GrantType.REFRESH_TOKEN,
1975
- ...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
1976
- ],
1977
- responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
2097
+ grantTypes,
2098
+ responseTypes,
1978
2099
  registrationDate: Math.floor(Date.now() / 1e3),
1979
2100
  tokenEndpointAuthMethod: authMethod
1980
2101
  };
@@ -2046,10 +2167,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2046
2167
  const url = new URL(request.url);
2047
2168
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
2048
2169
  const authHeader = request.headers.get("Authorization");
2049
- if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
2050
- description: "Missing or invalid access token",
2051
- statusCode: 401,
2052
- 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
+ }
2053
2176
  });
2054
2177
  const accessToken = authHeader.substring(7);
2055
2178
  const parts = accessToken.split(":");
@@ -2068,6 +2191,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2068
2191
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2069
2192
  });
2070
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
+ });
2071
2200
  const now = Math.floor(Date.now() / 1e3);
2072
2201
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2073
2202
  description: "Access token expired",
@@ -2076,7 +2205,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2076
2205
  });
2077
2206
  if (tokenData.audience) {
2078
2207
  const requestUrl = new URL(request.url);
2079
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2208
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2080
2209
  if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2081
2210
  description: "Token audience does not match resource server",
2082
2211
  statusCode: 401,
@@ -2085,19 +2214,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2085
2214
  }
2086
2215
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2087
2216
  } else if (this.options.resolveExternalToken) {
2088
- const ext = await this.options.resolveExternalToken({
2089
- token: accessToken,
2090
- request,
2091
- env
2092
- });
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
+ }
2093
2229
  if (!ext) return this.createErrorResponse("invalid_token", {
2094
2230
  description: "Invalid access token",
2095
2231
  statusCode: 401,
2096
2232
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2097
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
+ });
2098
2240
  if (ext.audience) {
2099
2241
  const requestUrl = new URL(request.url);
2100
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2242
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2101
2243
  if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2102
2244
  description: "Token audience does not match resource server",
2103
2245
  statusCode: 401,
@@ -2164,7 +2306,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2164
2306
  *
2165
2307
  * @param env - Cloudflare Worker environment variables
2166
2308
  * @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
2167
- * @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.
2168
2313
  */
2169
2314
  async getClient(env, clientId) {
2170
2315
  if (this.isClientMetadataUrl(clientId)) {
@@ -2177,13 +2322,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2177
2322
  return await this.fetchClientMetadataDocument(clientId);
2178
2323
  } catch (error) {
2179
2324
  console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
2180
- return null;
2325
+ throw new CimdFetchError(clientId, error);
2181
2326
  }
2182
2327
  }
2183
2328
  const clientKey = `client:${clientId}`;
2184
2329
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2185
2330
  }
2186
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
+ /**
2187
2353
  * Creates and stores an access token
2188
2354
  * @param params - Options for creating the access token
2189
2355
  * @returns The access token string
@@ -2255,7 +2421,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2255
2421
  this.CIMD_FETCH_TIMEOUT_MS = 1e4;
2256
2422
  }
2257
2423
  static {
2258
- this.CIMD_ALLOWED_AUTH_METHODS = ["none", "private_key_jwt"];
2424
+ this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
2259
2425
  }
2260
2426
  /**
2261
2427
  * Validates that a field is a string or undefined
@@ -2372,15 +2538,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2372
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})`);
2373
2539
  const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
2374
2540
  const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
2541
+ const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
2375
2542
  const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
2376
- 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);
2377
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");
2378
2548
  if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
2379
- 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
+ });
2380
2558
  return {
2381
2559
  clientId,
2382
2560
  redirectUris,
2383
- clientName: OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name"),
2561
+ clientName,
2384
2562
  clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
2385
2563
  logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
2386
2564
  policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
@@ -2388,9 +2566,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2388
2566
  jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
2389
2567
  i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
2390
2568
  contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
2391
- grantTypes: OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || ["authorization_code"],
2392
- responseTypes: OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"],
2393
- tokenEndpointAuthMethod: tokenEndpointAuthMethod || "none"
2569
+ grantTypes,
2570
+ responseTypes,
2571
+ tokenEndpointAuthMethod: effectiveAuthMethod
2394
2572
  };
2395
2573
  } finally {
2396
2574
  clearTimeout(timeoutId);
@@ -2434,8 +2612,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2434
2612
  /**
2435
2613
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2436
2614
  */
2437
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
2438
- 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(" ")}"`;
2439
2620
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2440
2621
  return header;
2441
2622
  }
@@ -2447,7 +2628,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2447
2628
  * so the public response stays RFC-compliant and free of information leak
2448
2629
  * while the deployer can still observe which check failed.
2449
2630
  */
2450
- createErrorResponse(code, options, internal) {
2631
+ createErrorResponse(code, options, internal, request) {
2451
2632
  const { description } = options;
2452
2633
  const responseStatus = options.statusCode ?? 400;
2453
2634
  const responseHeaders = {
@@ -2459,7 +2640,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2459
2640
  description,
2460
2641
  status: responseStatus,
2461
2642
  headers: responseHeaders,
2462
- ...internal ? { internal } : {}
2643
+ ...internal ? { internal } : {},
2644
+ ...request ? { request } : {}
2463
2645
  });
2464
2646
  if (customErrorResponse) return customErrorResponse;
2465
2647
  const body = JSON.stringify({
@@ -2476,11 +2658,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2476
2658
  }
2477
2659
  };
2478
2660
  /**
2479
- * Structured OAuth 2.0 error.
2661
+ * Structured OAuth 2.0 token-endpoint error.
2480
2662
  *
2481
- * Throw from a `tokenExchangeCallback` (or any code it calls — the error
2482
- * propagates naturally up through deep call stacks) to surface a standard
2483
- * `/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
2484
2665
  * generic `500 Internal Server Error`.
2485
2666
  *
2486
2667
  * Anything thrown that is **not** an `OAuthError` continues to surface as
@@ -2530,6 +2711,61 @@ var OAuthError = class extends Error {
2530
2711
  }
2531
2712
  };
2532
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
+ /**
2533
2769
  * Default expiration time for access tokens (1 hour in seconds)
2534
2770
  */
2535
2771
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
@@ -2581,10 +2817,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
2581
2817
  */
2582
2818
  const TOKEN_LENGTH = 32;
2583
2819
  /**
2584
- * RFC 6749 Section 3.3 scope-token grammar.
2585
- */
2586
- const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
2587
- /**
2588
2820
  * Validates a resource URI per RFC 8707 Section 2
2589
2821
  * @param uri - The URI string to validate
2590
2822
  * @returns true if valid, false otherwise
@@ -2614,6 +2846,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
2614
2846
  const resource = new URL(resourceServerUrl);
2615
2847
  const audience = new URL(audienceValue);
2616
2848
  if (resource.origin !== audience.origin) return false;
2849
+ if (audience.search && resource.search !== audience.search) return false;
2617
2850
  if (audience.pathname === "/" || audience.pathname === "") return true;
2618
2851
  return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
2619
2852
  } catch {
@@ -2632,6 +2865,10 @@ function parseResourceParameter(value) {
2632
2865
  for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
2633
2866
  return value;
2634
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
+ }
2635
2872
  /**
2636
2873
  * Checks if a requested resource matches a granted resource.
2637
2874
  * When originOnly is true, compares only the origin (scheme + host + port),
@@ -2654,6 +2891,29 @@ async function hashSecret(secret) {
2654
2891
  return generateTokenId(secret);
2655
2892
  }
2656
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
+ /**
2657
2917
  * Decodes an application/x-www-form-urlencoded component.
2658
2918
  * @param value - The encoded component value
2659
2919
  * @returns The decoded component value
@@ -2777,9 +3037,6 @@ function parseJwtJsonPart(encoded) {
2777
3037
  throw new Error("Malformed JWT part");
2778
3038
  }
2779
3039
  }
2780
- function isValidOAuthScopeToken(scopeToken) {
2781
- return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
2782
- }
2783
3040
  /**
2784
3041
  * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
2785
3042
  */
@@ -2951,6 +3208,8 @@ var OAuthHelpersImpl = class {
2951
3208
  * Parses an OAuth authorization request from the HTTP request
2952
3209
  * @param request - The HTTP request containing OAuth parameters
2953
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
2954
3213
  */
2955
3214
  async parseAuthRequest(request) {
2956
3215
  const url = new URL(request.url);
@@ -2960,20 +3219,26 @@ var OAuthHelpersImpl = class {
2960
3219
  const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
2961
3220
  const state = url.searchParams.get("state") || "";
2962
3221
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
2963
- 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);
2964
3224
  const resourceParams = url.searchParams.getAll("resource");
2965
3225
  const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
2966
3226
  validateRedirectUriScheme(redirectUri);
2967
- const resource = parseResourceParameter(resourceParam);
3227
+ let resource = parseResourceParameter(resourceParam);
2968
3228
  if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
2969
- if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
2970
- 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;
2971
3232
  if (clientId) {
2972
3233
  const clientInfo = await this.lookupClient(clientId);
2973
3234
  if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
2974
- if (clientInfo && redirectUri) {
2975
- if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
2976
- }
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);
2977
3242
  }
2978
3243
  return {
2979
3244
  responseType,
@@ -2983,13 +3248,20 @@ var OAuthHelpersImpl = class {
2983
3248
  state,
2984
3249
  codeChallenge,
2985
3250
  codeChallengeMethod,
2986
- resource
3251
+ resource,
3252
+ issuer
2987
3253
  };
2988
3254
  }
2989
3255
  /**
2990
3256
  * Looks up a client by its client ID
2991
3257
  * @param clientId - The client ID to look up
2992
- * @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.
2993
3265
  */
2994
3266
  async lookupClient(clientId) {
2995
3267
  return await this.provider.getClient(this.env, clientId);
@@ -3000,12 +3272,19 @@ var OAuthHelpersImpl = class {
3000
3272
  * - For implicit flow: generating an access token directly
3001
3273
  * @param options - Options specifying the grant details
3002
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
3003
3277
  */
3004
3278
  async completeAuthorization(options) {
3005
3279
  const { clientId, redirectUri } = options.request;
3006
3280
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
3007
3281
  const clientInfo = await this.lookupClient(clientId);
3008
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);
3009
3288
  let grantsToRevoke = [];
3010
3289
  if (options.revokeExistingGrants !== false) {
3011
3290
  const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
@@ -3029,8 +3308,8 @@ var OAuthHelpersImpl = class {
3029
3308
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3030
3309
  const accessTokenExpiresAt = now + accessTokenTTL;
3031
3310
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
3032
- const audience = parseResourceParameter(options.request.resource);
3033
- 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");
3034
3313
  const grant = {
3035
3314
  id: grantId,
3036
3315
  clientId: options.request.clientId,
@@ -3039,7 +3318,7 @@ var OAuthHelpersImpl = class {
3039
3318
  metadata: options.metadata,
3040
3319
  encryptedProps: encryptedData,
3041
3320
  createdAt: now,
3042
- resource: options.request.resource
3321
+ resource: effectiveResource
3043
3322
  };
3044
3323
  const grantKey = `grant:${options.userId}:${grantId}`;
3045
3324
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -3066,6 +3345,7 @@ var OAuthHelpersImpl = class {
3066
3345
  fragment.set("expires_in", accessTokenTTL.toString());
3067
3346
  fragment.set("scope", options.scope.join(" "));
3068
3347
  if (options.request.state) fragment.set("state", options.request.state);
3348
+ if (options.request.issuer) fragment.set("iss", options.request.issuer);
3069
3349
  redirectUrl.hash = fragment.toString();
3070
3350
  try {
3071
3351
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
@@ -3088,13 +3368,14 @@ var OAuthHelpersImpl = class {
3088
3368
  authCodeWrappedKey,
3089
3369
  codeChallenge: options.request.codeChallenge,
3090
3370
  codeChallengeMethod: options.request.codeChallengeMethod,
3091
- resource: options.request.resource
3371
+ resource: effectiveResource
3092
3372
  };
3093
3373
  const grantKey = `grant:${options.userId}:${grantId}`;
3094
3374
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
3095
3375
  const redirectUrl = new URL(options.request.redirectUri);
3096
3376
  redirectUrl.searchParams.set("code", authCode);
3097
3377
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
3378
+ if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
3098
3379
  try {
3099
3380
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
3100
3381
  } catch {}
@@ -3287,6 +3568,7 @@ var OAuthHelpersImpl = class {
3287
3568
  * Implements OAuth 2.0 Token Exchange (RFC 8693)
3288
3569
  * @param options - Options for token exchange including subject token and optional modifications
3289
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
3290
3572
  */
3291
3573
  async exchangeToken(options) {
3292
3574
  const tokenSummary = await this.unwrapToken(options.subjectToken);
@@ -3389,4 +3671,4 @@ var OAuthHelpersImpl = class {
3389
3671
  var oauth_provider_default = OAuthProvider;
3390
3672
 
3391
3673
  //#endregion
3392
- 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 };