@cloudflare/workers-oauth-provider 0.8.3 → 0.9.1

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
  };
@@ -2016,7 +2137,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2016
2137
  grant_types: clientInfo.grantTypes,
2017
2138
  response_types: clientInfo.responseTypes,
2018
2139
  token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
2019
- registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
2020
2140
  client_id_issued_at: clientInfo.registrationDate
2021
2141
  };
2022
2142
  if (clientInfo.i18n) {
@@ -2046,10 +2166,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2046
2166
  const url = new URL(request.url);
2047
2167
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
2048
2168
  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") }
2169
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
2170
+ status: 401,
2171
+ headers: {
2172
+ ...NO_CACHE_HEADERS,
2173
+ "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
2174
+ }
2053
2175
  });
2054
2176
  const accessToken = authHeader.substring(7);
2055
2177
  const parts = accessToken.split(":");
@@ -2068,6 +2190,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2068
2190
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2069
2191
  });
2070
2192
  if (tokenData) {
2193
+ const configuredResource = this.options.resourceMetadata?.resource;
2194
+ if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2195
+ description: "Access token is not bound to the configured resource",
2196
+ statusCode: 401,
2197
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2198
+ });
2071
2199
  const now = Math.floor(Date.now() / 1e3);
2072
2200
  if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2073
2201
  description: "Access token expired",
@@ -2076,7 +2204,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2076
2204
  });
2077
2205
  if (tokenData.audience) {
2078
2206
  const requestUrl = new URL(request.url);
2079
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2207
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2080
2208
  if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2081
2209
  description: "Token audience does not match resource server",
2082
2210
  statusCode: 401,
@@ -2085,19 +2213,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2085
2213
  }
2086
2214
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
2087
2215
  } else if (this.options.resolveExternalToken) {
2088
- const ext = await this.options.resolveExternalToken({
2089
- token: accessToken,
2090
- request,
2091
- env
2092
- });
2216
+ let ext;
2217
+ try {
2218
+ ext = await this.options.resolveExternalToken({
2219
+ token: accessToken,
2220
+ request,
2221
+ env
2222
+ });
2223
+ } catch (error) {
2224
+ const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
2225
+ if (response) return response;
2226
+ throw error;
2227
+ }
2093
2228
  if (!ext) return this.createErrorResponse("invalid_token", {
2094
2229
  description: "Invalid access token",
2095
2230
  statusCode: 401,
2096
2231
  headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2097
2232
  });
2233
+ const configuredResource = this.options.resourceMetadata?.resource;
2234
+ if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
2235
+ description: "External access token is not bound to the configured resource",
2236
+ statusCode: 401,
2237
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2238
+ });
2098
2239
  if (ext.audience) {
2099
2240
  const requestUrl = new URL(request.url);
2100
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
2241
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
2101
2242
  if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2102
2243
  description: "Token audience does not match resource server",
2103
2244
  statusCode: 401,
@@ -2164,7 +2305,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2164
2305
  *
2165
2306
  * @param env - Cloudflare Worker environment variables
2166
2307
  * @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
2308
+ * @returns The client information, or null if the client does not exist. Null means
2309
+ * definitive absence; failures to determine the answer throw instead (KV errors
2310
+ * propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
2311
+ * upstream outage is distinguishable from an unregistered client.
2168
2312
  */
2169
2313
  async getClient(env, clientId) {
2170
2314
  if (this.isClientMetadataUrl(clientId)) {
@@ -2177,13 +2321,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2177
2321
  return await this.fetchClientMetadataDocument(clientId);
2178
2322
  } catch (error) {
2179
2323
  console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
2180
- return null;
2324
+ throw new CimdFetchError(clientId, error);
2181
2325
  }
2182
2326
  }
2183
2327
  const clientKey = `client:${clientId}`;
2184
2328
  return env.OAUTH_KV.get(clientKey, { type: "json" });
2185
2329
  }
2186
2330
  /**
2331
+ * Resolves an access-token audience from a token request and its authorization grant.
2332
+ * Explicit resource configuration requires one exact value in both places. Without
2333
+ * configuration, RFC 8707 downscoping is allowed and omission inherits the grant.
2334
+ */
2335
+ resolveTokenResource(requestedResource, grantedResource) {
2336
+ const requestedAudience = parseResourceParameter(requestedResource);
2337
+ if (requestedResource && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2338
+ const grantedAudience = parseResourceParameter(grantedResource);
2339
+ if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2340
+ const configuredResource = this.options.resourceMetadata?.resource;
2341
+ if (configuredResource && (!isExactResource(grantedResource, configuredResource) || !isExactResource(requestedResource, configuredResource))) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2342
+ if (!configuredResource && requestedResource && !grantedResource) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
2343
+ const originOnly = configuredResource ? false : !!this.options.resourceMatchOriginOnly;
2344
+ if (requestedResource && grantedResource) {
2345
+ const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2346
+ const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2347
+ 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" });
2348
+ }
2349
+ return requestedAudience ?? grantedAudience;
2350
+ }
2351
+ /**
2187
2352
  * Creates and stores an access token
2188
2353
  * @param params - Options for creating the access token
2189
2354
  * @returns The access token string
@@ -2255,7 +2420,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2255
2420
  this.CIMD_FETCH_TIMEOUT_MS = 1e4;
2256
2421
  }
2257
2422
  static {
2258
- this.CIMD_ALLOWED_AUTH_METHODS = ["none", "private_key_jwt"];
2423
+ this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
2259
2424
  }
2260
2425
  /**
2261
2426
  * Validates that a field is a string or undefined
@@ -2372,15 +2537,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2372
2537
  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
2538
  const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
2374
2539
  const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
2540
+ const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
2375
2541
  const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
2376
- const tokenEndpointAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2542
+ const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2543
+ const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
2544
+ const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
2377
2545
  if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
2546
+ if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
2378
2547
  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(", ")}`);
2548
+ 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(", ")}`);
2549
+ const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2550
+ const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
2551
+ const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
2552
+ validateClientCapabilities(this.serverCapabilities, {
2553
+ tokenEndpointAuthMethod: effectiveAuthMethod,
2554
+ grantTypes,
2555
+ responseTypes
2556
+ });
2380
2557
  return {
2381
2558
  clientId,
2382
2559
  redirectUris,
2383
- clientName: OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name"),
2560
+ clientName,
2384
2561
  clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
2385
2562
  logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
2386
2563
  policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
@@ -2388,9 +2565,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2388
2565
  jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
2389
2566
  i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
2390
2567
  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"
2568
+ grantTypes,
2569
+ responseTypes,
2570
+ tokenEndpointAuthMethod: effectiveAuthMethod
2394
2571
  };
2395
2572
  } finally {
2396
2573
  clearTimeout(timeoutId);
@@ -2434,8 +2611,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2434
2611
  /**
2435
2612
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
2436
2613
  */
2437
- buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
2438
- let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
2614
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
2615
+ let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
2616
+ if (error) header += `, error="${error}"`;
2617
+ const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
2618
+ if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
2439
2619
  if (errorDescription) header += `, error_description="${errorDescription}"`;
2440
2620
  return header;
2441
2621
  }
@@ -2447,7 +2627,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2447
2627
  * so the public response stays RFC-compliant and free of information leak
2448
2628
  * while the deployer can still observe which check failed.
2449
2629
  */
2450
- createErrorResponse(code, options, internal) {
2630
+ createErrorResponse(code, options, internal, request) {
2451
2631
  const { description } = options;
2452
2632
  const responseStatus = options.statusCode ?? 400;
2453
2633
  const responseHeaders = {
@@ -2459,7 +2639,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2459
2639
  description,
2460
2640
  status: responseStatus,
2461
2641
  headers: responseHeaders,
2462
- ...internal ? { internal } : {}
2642
+ ...internal ? { internal } : {},
2643
+ ...request ? { request } : {}
2463
2644
  });
2464
2645
  if (customErrorResponse) return customErrorResponse;
2465
2646
  const body = JSON.stringify({
@@ -2476,11 +2657,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2476
2657
  }
2477
2658
  };
2478
2659
  /**
2479
- * Structured OAuth 2.0 error.
2660
+ * Structured OAuth 2.0 token-endpoint error.
2480
2661
  *
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
2662
+ * Throw from a `tokenExchangeCallback` or any code it calls to surface a
2663
+ * standard OAuth token response (`{ error, error_description }`) instead of a
2484
2664
  * generic `500 Internal Server Error`.
2485
2665
  *
2486
2666
  * Anything thrown that is **not** an `OAuthError` continues to surface as
@@ -2530,6 +2710,61 @@ var OAuthError = class extends Error {
2530
2710
  }
2531
2711
  };
2532
2712
  /**
2713
+ * Intentional public error from an external bearer-token validator.
2714
+ *
2715
+ * Throw only from `resolveExternalToken` when an expected validation outcome
2716
+ * should become a structured protected-resource response. Ordinary errors and
2717
+ * {@link OAuthError} retain their pre-existing behavior and propagate as
2718
+ * unexpected failures.
2719
+ */
2720
+ var ExternalTokenError = class extends Error {
2721
+ /**
2722
+ * Creates an intentional external-token validation error.
2723
+ * @param code - Standard OAuth error code to return
2724
+ * @param options - Public response details
2725
+ */
2726
+ constructor(code, options) {
2727
+ super(options.description);
2728
+ this.name = "ExternalTokenError";
2729
+ this.code = code;
2730
+ this.description = options.description;
2731
+ this.statusCode = options.statusCode;
2732
+ this.headers = options.headers;
2733
+ this.requiredScopes = options.requiredScopes;
2734
+ }
2735
+ };
2736
+ /**
2737
+ * Thrown when fetching a Client ID Metadata Document (CIMD) fails — the
2738
+ * server-to-server fetch errored, timed out, or returned an invalid document.
2739
+ * Distinct from a client that simply does not exist, which is reported as a
2740
+ * null client lookup result.
2741
+ *
2742
+ * At the token endpoint the provider handles this itself: the wire response
2743
+ * stays a generic `invalid_client` / "Client not found", and the failure is
2744
+ * reported through the `onError` hook's `internal` field (category
2745
+ * `client-id-metadata-document`) together with the originating `request`.
2746
+ *
2747
+ * `OAuthHelpers` methods that look up clients (`lookupClient`, and methods
2748
+ * built on it such as `exchangeToken`) let this error propagate to the
2749
+ * caller. Callers that previously relied on a `null` result for these
2750
+ * failures should catch it to preserve their error contract.
2751
+ */
2752
+ var CimdFetchError = class extends Error {
2753
+ /**
2754
+ * Creates an error for a failed CIMD fetch or validation.
2755
+ * @param metadataUrl - The CIMD URL that could not be resolved
2756
+ * @param cause - The underlying fetch or validation failure
2757
+ */
2758
+ constructor(metadataUrl, cause) {
2759
+ const detail = cause instanceof Error ? cause.message : String(cause);
2760
+ super(`CIMD fetch failed for ${metadataUrl}: ${detail}`);
2761
+ this.reason = "metadata_resolution_failed";
2762
+ this.name = "CimdFetchError";
2763
+ this.metadataUrl = metadataUrl;
2764
+ this.detail = detail;
2765
+ }
2766
+ };
2767
+ /**
2533
2768
  * Default expiration time for access tokens (1 hour in seconds)
2534
2769
  */
2535
2770
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
@@ -2581,10 +2816,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
2581
2816
  */
2582
2817
  const TOKEN_LENGTH = 32;
2583
2818
  /**
2584
- * RFC 6749 Section 3.3 scope-token grammar.
2585
- */
2586
- const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
2587
- /**
2588
2819
  * Validates a resource URI per RFC 8707 Section 2
2589
2820
  * @param uri - The URI string to validate
2590
2821
  * @returns true if valid, false otherwise
@@ -2614,6 +2845,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
2614
2845
  const resource = new URL(resourceServerUrl);
2615
2846
  const audience = new URL(audienceValue);
2616
2847
  if (resource.origin !== audience.origin) return false;
2848
+ if (audience.search && resource.search !== audience.search) return false;
2617
2849
  if (audience.pathname === "/" || audience.pathname === "") return true;
2618
2850
  return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
2619
2851
  } catch {
@@ -2632,6 +2864,10 @@ function parseResourceParameter(value) {
2632
2864
  for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
2633
2865
  return value;
2634
2866
  }
2867
+ /** Whether a request or audience names one exact configured resource. */
2868
+ function isExactResource(value, configuredResource) {
2869
+ return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
2870
+ }
2635
2871
  /**
2636
2872
  * Checks if a requested resource matches a granted resource.
2637
2873
  * When originOnly is true, compares only the origin (scheme + host + port),
@@ -2654,6 +2890,29 @@ async function hashSecret(secret) {
2654
2890
  return generateTokenId(secret);
2655
2891
  }
2656
2892
  /**
2893
+ * Parses RFC 6749 HTTP Basic client credentials.
2894
+ */
2895
+ function parseBasicAuthorizationHeader(header) {
2896
+ if (!header) return { kind: "not-basic" };
2897
+ const schemeEnd = header.search(/[ \t]/);
2898
+ if ((schemeEnd === -1 ? header : header.slice(0, schemeEnd)).toLowerCase() !== "basic") return { kind: "not-basic" };
2899
+ if (schemeEnd === -1) return { kind: "malformed" };
2900
+ const encodedCredentials = header.slice(schemeEnd).trim();
2901
+ if (!encodedCredentials || /[ \t]/.test(encodedCredentials)) return { kind: "malformed" };
2902
+ try {
2903
+ const credentials = atob(encodedCredentials);
2904
+ const separatorIndex = credentials.indexOf(":");
2905
+ if (separatorIndex === -1) return { kind: "malformed" };
2906
+ return {
2907
+ kind: "credentials",
2908
+ clientId: decodeFormUrlEncodedComponent(credentials.slice(0, separatorIndex)),
2909
+ clientSecret: decodeFormUrlEncodedComponent(credentials.slice(separatorIndex + 1))
2910
+ };
2911
+ } catch {
2912
+ return { kind: "malformed" };
2913
+ }
2914
+ }
2915
+ /**
2657
2916
  * Decodes an application/x-www-form-urlencoded component.
2658
2917
  * @param value - The encoded component value
2659
2918
  * @returns The decoded component value
@@ -2777,9 +3036,6 @@ function parseJwtJsonPart(encoded) {
2777
3036
  throw new Error("Malformed JWT part");
2778
3037
  }
2779
3038
  }
2780
- function isValidOAuthScopeToken(scopeToken) {
2781
- return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
2782
- }
2783
3039
  /**
2784
3040
  * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
2785
3041
  */
@@ -2951,6 +3207,8 @@ var OAuthHelpersImpl = class {
2951
3207
  * Parses an OAuth authorization request from the HTTP request
2952
3208
  * @param request - The HTTP request containing OAuth parameters
2953
3209
  * @returns The parsed authorization request parameters
3210
+ * @throws Error when the response type is missing, unsupported, or not registered for the client
3211
+ * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
2954
3212
  */
2955
3213
  async parseAuthRequest(request) {
2956
3214
  const url = new URL(request.url);
@@ -2960,20 +3218,26 @@ var OAuthHelpersImpl = class {
2960
3218
  const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
2961
3219
  const state = url.searchParams.get("state") || "";
2962
3220
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
2963
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
3221
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || void 0;
3222
+ const issuer = this.provider.getAuthorizationServerIssuer(url);
2964
3223
  const resourceParams = url.searchParams.getAll("resource");
2965
3224
  const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
2966
3225
  validateRedirectUriScheme(redirectUri);
2967
- const resource = parseResourceParameter(resourceParam);
3226
+ let resource = parseResourceParameter(resourceParam);
2968
3227
  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.");
3228
+ const configuredResource = this.provider.options.resourceMetadata?.resource;
3229
+ if (configuredResource && !isExactResource(resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3230
+ resource ??= url.origin;
2971
3231
  if (clientId) {
2972
3232
  const clientInfo = await this.lookupClient(clientId);
2973
3233
  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
- }
3234
+ 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.`);
3235
+ validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
3236
+ validateAuthorizationPkce(this.provider.serverCapabilities, {
3237
+ responseType,
3238
+ codeChallenge,
3239
+ codeChallengeMethod
3240
+ }, clientInfo);
2977
3241
  }
2978
3242
  return {
2979
3243
  responseType,
@@ -2983,13 +3247,20 @@ var OAuthHelpersImpl = class {
2983
3247
  state,
2984
3248
  codeChallenge,
2985
3249
  codeChallengeMethod,
2986
- resource
3250
+ resource,
3251
+ issuer
2987
3252
  };
2988
3253
  }
2989
3254
  /**
2990
3255
  * Looks up a client by its client ID
2991
3256
  * @param clientId - The client ID to look up
2992
- * @returns A Promise resolving to the client info, or null if not found
3257
+ * @returns A Promise resolving to the client info, or null if the client does not
3258
+ * exist. Null means definitive absence; failures to determine the answer throw
3259
+ * instead (KV errors propagate, and a CIMD metadata fetch failure throws
3260
+ * `CimdFetchError`), so an upstream outage cannot masquerade as an unregistered
3261
+ * client.
3262
+ * @throws CimdFetchError when the client ID is a CIMD URL and fetching or
3263
+ * validating the metadata document fails.
2993
3264
  */
2994
3265
  async lookupClient(clientId) {
2995
3266
  return await this.provider.getClient(this.env, clientId);
@@ -3000,12 +3271,19 @@ var OAuthHelpersImpl = class {
3000
3271
  * - For implicit flow: generating an access token directly
3001
3272
  * @param options - Options specifying the grant details
3002
3273
  * @returns A Promise resolving to an object containing the redirect URL
3274
+ * @throws Error when the request's response type is not permitted
3275
+ * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
3003
3276
  */
3004
3277
  async completeAuthorization(options) {
3005
3278
  const { clientId, redirectUri } = options.request;
3006
3279
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
3007
3280
  const clientInfo = await this.lookupClient(clientId);
3008
3281
  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.");
3282
+ validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3283
+ const configuredResource = this.provider.options.resourceMetadata?.resource;
3284
+ if (configuredResource && !isExactResource(options.request.resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3285
+ const effectiveResource = options.request.resource ?? options.request.issuer;
3286
+ validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3009
3287
  let grantsToRevoke = [];
3010
3288
  if (options.revokeExistingGrants !== false) {
3011
3289
  const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
@@ -3029,8 +3307,8 @@ var OAuthHelpersImpl = class {
3029
3307
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
3030
3308
  const accessTokenExpiresAt = now + accessTokenTTL;
3031
3309
  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");
3310
+ const audience = parseResourceParameter(effectiveResource);
3311
+ if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
3034
3312
  const grant = {
3035
3313
  id: grantId,
3036
3314
  clientId: options.request.clientId,
@@ -3039,7 +3317,7 @@ var OAuthHelpersImpl = class {
3039
3317
  metadata: options.metadata,
3040
3318
  encryptedProps: encryptedData,
3041
3319
  createdAt: now,
3042
- resource: options.request.resource
3320
+ resource: effectiveResource
3043
3321
  };
3044
3322
  const grantKey = `grant:${options.userId}:${grantId}`;
3045
3323
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -3066,6 +3344,7 @@ var OAuthHelpersImpl = class {
3066
3344
  fragment.set("expires_in", accessTokenTTL.toString());
3067
3345
  fragment.set("scope", options.scope.join(" "));
3068
3346
  if (options.request.state) fragment.set("state", options.request.state);
3347
+ if (options.request.issuer) fragment.set("iss", options.request.issuer);
3069
3348
  redirectUrl.hash = fragment.toString();
3070
3349
  try {
3071
3350
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
@@ -3088,13 +3367,14 @@ var OAuthHelpersImpl = class {
3088
3367
  authCodeWrappedKey,
3089
3368
  codeChallenge: options.request.codeChallenge,
3090
3369
  codeChallengeMethod: options.request.codeChallengeMethod,
3091
- resource: options.request.resource
3370
+ resource: effectiveResource
3092
3371
  };
3093
3372
  const grantKey = `grant:${options.userId}:${grantId}`;
3094
3373
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
3095
3374
  const redirectUrl = new URL(options.request.redirectUri);
3096
3375
  redirectUrl.searchParams.set("code", authCode);
3097
3376
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
3377
+ if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
3098
3378
  try {
3099
3379
  await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
3100
3380
  } catch {}
@@ -3287,6 +3567,7 @@ var OAuthHelpersImpl = class {
3287
3567
  * Implements OAuth 2.0 Token Exchange (RFC 8693)
3288
3568
  * @param options - Options for token exchange including subject token and optional modifications
3289
3569
  * @returns Promise resolving to token response with new access token
3570
+ * @throws CimdFetchError when the grant's client ID is a CIMD URL whose document cannot be resolved
3290
3571
  */
3291
3572
  async exchangeToken(options) {
3292
3573
  const tokenSummary = await this.unwrapToken(options.subjectToken);
@@ -3389,4 +3670,4 @@ var OAuthHelpersImpl = class {
3389
3670
  var oauth_provider_default = OAuthProvider;
3390
3671
 
3391
3672
  //#endregion
3392
- export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
3673
+ export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };