@cloudflare/workers-oauth-provider 0.9.1 → 0.10.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.
package/README.md CHANGED
@@ -36,7 +36,12 @@ See [Client registration](#client-registration) for the matching provider option
36
36
  The provider accepts either plain `ExportedHandler` objects or classes extending `WorkerEntrypoint`. This example uses both.
37
37
 
38
38
  ```ts
39
- import { OAuthProvider, type AuthRequest, type OAuthHelpers } from '@cloudflare/workers-oauth-provider';
39
+ import {
40
+ AuthorizationError,
41
+ OAuthProvider,
42
+ type AuthRequest,
43
+ type OAuthHelpers,
44
+ } from '@cloudflare/workers-oauth-provider';
40
45
  import { WorkerEntrypoint } from 'cloudflare:workers';
41
46
 
42
47
  interface AuthProps {
@@ -72,9 +77,18 @@ const defaultHandler: ExportedHandler<Env> = {
72
77
  let oauthRequest: AuthRequest;
73
78
  try {
74
79
  oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
75
- } catch {
76
- // Do not redirect until the client and redirect URI have been validated.
77
- return new Response('Invalid authorization request', { status: 400 });
80
+ } catch (error) {
81
+ if (!(error instanceof AuthorizationError)) throw error;
82
+ if (!error.redirectUri) {
83
+ // Unknown clients and invalid redirects must be rendered locally.
84
+ return new Response(error.description, { status: 400 });
85
+ }
86
+ const redirect = new URL(error.redirectUri);
87
+ redirect.searchParams.set('error', error.code);
88
+ redirect.searchParams.set('error_description', error.description);
89
+ if (error.state) redirect.searchParams.set('state', error.state);
90
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
91
+ return Response.redirect(redirect, 302);
78
92
  }
79
93
 
80
94
  const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
@@ -228,7 +242,9 @@ A typical flow has three steps:
228
242
  2. Authenticate the user, show consent, and decide which scopes to grant.
229
243
  3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
230
244
 
231
- `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. The application remains responsible for rendering local authorization errors and for constructing any terminal OAuth error redirect only after client and redirect URI validation.
245
+ `parseAuthRequest()` throws an exported `AuthorizationError` for expected request validation failures. Its optional `redirectUri` is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's `code`, `description`, original `state`, and RFC 9207 `issuer`, as shown in the quick start.
246
+
247
+ `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as `AuthorizationError`, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by `parseAuthRequest()`.
232
248
 
233
249
  `completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user and client after the new grant is safely stored. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants for the same user and client.
234
250
 
@@ -305,6 +321,10 @@ MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endp
305
321
 
306
322
  Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
307
323
 
324
+ An explicitly supplied `token_endpoint_auth_method` is enforced exactly. When it is omitted, no explicit-method marker is stored and the client may use either `client_secret_basic` or `client_secret_post`, provided the same stored secret validates. Client records written by earlier releases have no marker and receive the same compatibility. This never crosses between `none` and a secret method and does not apply to CIMD clients.
325
+
326
+ Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
327
+
308
328
  Related options:
309
329
 
310
330
  - `clientRegistrationTTL` controls the lifetime of dynamically registered clients. The default is 90 days.
@@ -329,16 +349,11 @@ The provider owns `tokenEndpoint`. It exchanges authorization codes for tokens,
329
349
 
330
350
  ## Resources and token audiences
331
351
 
332
- MCP clients must send the canonical MCP server URI as `resource` in authorization and token requests. The provider parses RFC 8707 resource indicators, stores the authorized resource on the grant, uses it as the access-token audience, and rejects resource expansion or audience mismatch.
333
-
334
- Resource policy follows `resourceMetadata.resource`:
335
-
336
- - When configured, authorization requests, token requests, and externally resolved tokens must use that one exact resource. `resourceMatchOriginOnly` cannot weaken this policy.
337
- - When omitted, valid resources are accepted. Token requests may inherit the authorization resource. If the authorization request also omits it, the provider uses the request origin as the default and issues an origin-bound token.
352
+ MCP clients are required to send the canonical MCP server URI as `resource` in authorization and token requests. The provider tolerates omission for compatibility: when `resourceMetadata.resource` is configured, it is used as the canonical default and inherited by later token requests; otherwise a token request inherits any resource already stored on the grant. An explicit resource that does not match a bound grant is rejected with `invalid_target`.
338
353
 
339
- Path-aware audiences use path-boundary prefix matching. A token for `https://example.com/mcp` can be used at `/mcp/tools`, but not at `/mcp-other`. Split deployments and deployments requiring path isolation should configure the canonical resource explicitly.
354
+ Legacy grants may have no stored resource. With no configured canonical resource, omitting `resource` preserves that unbound state. If a client supplies a resource during code exchange or refresh, it applies to that issued token but is not persisted as a new grant binding. Path-aware audiences use path-boundary prefix matching, so a token for `https://example.com/mcp` can be used at `/mcp/tools`, but not at `/mcp-other`.
340
355
 
341
- `resourceMatchOriginOnly` remains a migration option for grants created before path-aware resources were introduced. Do not enable it for a new deployment.
356
+ `resourceMatchOriginOnly` is deprecated; its existing behavior is unchanged. Prefer `resourceMetadata.resource` for new deployments.
342
357
 
343
358
  ## Scopes and step-up authorization
344
359
 
@@ -415,7 +430,7 @@ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants
415
430
  | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
416
431
  | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
417
432
  | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
418
- | `resourceMatchOriginOnly` | Migration mode for old origin-only resource grants | `false` |
433
+ | `resourceMatchOriginOnly` | Deprecated origin-only resource comparison | `false` |
419
434
  | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
420
435
  | `onError` | Observe or replace OAuth error responses | Logs a warning |
421
436
 
@@ -299,6 +299,29 @@ interface EmaOptions<Env = Cloudflare.Env> {
299
299
  }
300
300
  //#endregion
301
301
  //#region src/oauth-capabilities.d.ts
302
+ type AuthorizationErrorCode = 'invalid_request' | 'invalid_target' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
303
+ interface AuthorizationErrorOptions {
304
+ /** Wire-safe OAuth authorization error description. */
305
+ description: string;
306
+ /** Exact registered redirect URI. Present only after client and redirect validation. */
307
+ redirectUri?: string;
308
+ /** Original client state, when supplied. */
309
+ state?: string;
310
+ /** Authorization server issuer for RFC 9207 error responses. */
311
+ issuer?: string;
312
+ }
313
+ /**
314
+ * Expected authorization-request validation failure. Absence of `redirectUri`
315
+ * means a caller MUST render locally and MUST NOT redirect.
316
+ */
317
+ declare class AuthorizationError extends Error {
318
+ readonly code: AuthorizationErrorCode;
319
+ readonly description: string;
320
+ readonly redirectUri?: string;
321
+ readonly state?: string;
322
+ readonly issuer?: string;
323
+ constructor(code: AuthorizationErrorCode, options: AuthorizationErrorOptions);
324
+ }
302
325
  declare function isValidOAuthScopeToken(scopeToken: string): boolean;
303
326
  //#endregion
304
327
  //#region src/oauth-provider.d.ts
@@ -662,14 +685,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
662
685
  */
663
686
  clientIdMetadataDocumentEnabled?: boolean;
664
687
  /**
665
- * When true, resource validation during token exchange compares origins only
666
- * (scheme + host + port) instead of exact URI matching. This allows grants issued
667
- * with an origin-only resource (e.g. `https://server.com`) to be used with
668
- * path-aware resource requests (e.g. `https://server.com/mcp`), enabling seamless
669
- * migration from pre-0.4.0 versions that stored origin-only resource URIs.
670
- * Explicit `resourceMetadata.resource` configuration always uses exact matching.
688
+ * When true, requested-vs-granted resource validation compares origins only
689
+ * (scheme + host + port) instead of exact URIs. This allows an origin-only
690
+ * grant such as `https://server.com` to accept `https://server.com/mcp`, but
691
+ * also ignores path and query differences. Configured canonical resources
692
+ * always use exact matching.
671
693
  *
672
694
  * Defaults to false (strict exact matching per RFC 8707).
695
+ *
696
+ * @deprecated This comparison is unsafe for shared-origin multi-path or
697
+ * multi-tenant deployments. Prefer configuring `resourceMetadata.resource`.
673
698
  */
674
699
  resourceMatchOriginOnly?: boolean;
675
700
  /**
@@ -683,10 +708,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
683
708
  /**
684
709
  * The protected resource identifier URL (RFC 9728 `resource` field).
685
710
  *
686
- * Configuring this value pins authorization requests, token requests, and
687
- * access-token audiences to this exact resource. If omitted, the provider
688
- * accepts valid RFC 8707 resource indicators and uses the authorization
689
- * request origin as the default when the client does not send one.
711
+ * Configuring this value pins grants and access-token audiences to this
712
+ * exact resource. An omitted authorization resource defaults to this value,
713
+ * and an omitted token-request resource inherits it from the grant. Without
714
+ * configuration, explicit RFC 8707 resource indicators are accepted and
715
+ * omission remains unbound for backwards compatibility.
690
716
  */
691
717
  resource?: string;
692
718
  /**
@@ -1511,4 +1537,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
1511
1537
  verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1512
1538
  };
1513
1539
  //#endregion
1514
- export { AuthRequest, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
1540
+ export { AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
@@ -2,6 +2,30 @@ import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
3
  //#region src/oauth-capabilities.ts
4
4
  const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
5
+ /**
6
+ * Expected authorization-request validation failure. Absence of `redirectUri`
7
+ * means a caller MUST render locally and MUST NOT redirect.
8
+ */
9
+ var AuthorizationError = class extends Error {
10
+ constructor(code, options) {
11
+ super(options.description);
12
+ this.name = "AuthorizationError";
13
+ this.code = code;
14
+ this.description = options.description;
15
+ this.redirectUri = options.redirectUri;
16
+ this.state = options.state;
17
+ this.issuer = options.issuer;
18
+ }
19
+ };
20
+ /** @internal Attach context after exact client redirect validation succeeds. */
21
+ function withAuthorizationRedirect(error, redirectUri, state, issuer) {
22
+ return new AuthorizationError(error.code, {
23
+ description: error.description,
24
+ redirectUri,
25
+ state,
26
+ issuer
27
+ });
28
+ }
5
29
  function buildOAuthServerCapabilities(options) {
6
30
  return {
7
31
  grantTypes: [
@@ -29,21 +53,40 @@ function validateClientCapabilities(server, client) {
29
53
  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
54
  if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
31
55
  }
56
+ /**
57
+ * Selects the capabilities from a Client ID Metadata Document that this
58
+ * authorization server supports. CIMD documents may advertise extension
59
+ * capabilities alongside the flow used with this server, so unsupported grant
60
+ * and response types are omitted from the effective client metadata instead of
61
+ * invalidating an otherwise usable client.
62
+ *
63
+ * The effective subset is still checked for grant/response consistency, and
64
+ * token endpoint authentication must have a mutually supported method.
65
+ */
66
+ function negotiateCimdClientCapabilities(server, client) {
67
+ const effective = {
68
+ grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
69
+ responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
70
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod
71
+ };
72
+ validateClientCapabilities(server, effective);
73
+ return effective;
74
+ }
32
75
  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}`);
76
+ if (!responseType) throw new AuthorizationError("invalid_request", { description: "response_type is required" });
77
+ if (!server.responseTypes.includes(responseType)) throw new AuthorizationError("unsupported_response_type", { description: `The authorization server does not support response_type ${responseType}` });
78
+ if (!(clientResponseTypes ?? ["code"]).includes(responseType)) throw new AuthorizationError("unauthorized_client", { description: `The client is not registered for response_type ${responseType}` });
36
79
  }
37
80
  /** Parse a PKCE method, applying RFC 7636's default of `plain`. */
38
81
  function normalizePkceCodeChallengeMethod(method) {
39
82
  const effectiveMethod = method ?? "plain";
40
- if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new Error(`Unsupported PKCE code_challenge_method: ${effectiveMethod}`);
83
+ if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new AuthorizationError("invalid_request", { description: `Unsupported PKCE code_challenge_method: ${effectiveMethod}` });
41
84
  return effectiveMethod;
42
85
  }
43
86
  /** Require a syntactically valid PKCE method that the server advertises. */
44
87
  function validatePkceCodeChallengeMethod(server, method) {
45
88
  const effectiveMethod = normalizePkceCodeChallengeMethod(method);
46
- if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
89
+ if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new AuthorizationError("invalid_request", { description: "The plain PKCE method is not allowed. Use S256 instead." });
47
90
  return effectiveMethod;
48
91
  }
49
92
  /** Validate authorization-request PKCE against server and client capabilities. */
@@ -52,8 +95,8 @@ function validateAuthorizationPkce(server, request, client) {
52
95
  validatePkceCodeChallengeMethod(server, request.codeChallengeMethod);
53
96
  return;
54
97
  }
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.");
98
+ if (request.codeChallengeMethod) throw new AuthorizationError("invalid_request", { description: "PKCE code_challenge is required when code_challenge_method is provided." });
99
+ if (request.responseType === "code" && client.tokenEndpointAuthMethod === "none") throw new AuthorizationError("invalid_request", { description: "Public clients must use PKCE with the authorization code flow." });
57
100
  }
58
101
  function validateAuthorizationServerScopes(scopes) {
59
102
  if (!scopes) return;
@@ -770,6 +813,15 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
770
813
  GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
771
814
  return GrantType$1;
772
815
  }({});
816
+ function toPublicClientInfo(client) {
817
+ const { authMethodExplicit: _explicit, ...publicClient } = client;
818
+ return publicClient;
819
+ }
820
+ function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
821
+ if (presentedMethod === client.tokenEndpointAuthMethod) return true;
822
+ const isSecretMethod = (method) => method === "client_secret_basic" || method === "client_secret_post";
823
+ return !isClientMetadataDocument && client.authMethodExplicit === void 0 && isSecretMethod(client.tokenEndpointAuthMethod) && isSecretMethod(presentedMethod);
824
+ }
773
825
  /**
774
826
  * OAuth 2.0 Provider implementation for Cloudflare Workers
775
827
  * Implements authorization code flow with support for refresh tokens
@@ -911,7 +963,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
911
963
  /** Validate configured RFC 9728 protected resource metadata. */
912
964
  validateResourceMetadataOptions(options) {
913
965
  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");
966
+ if (options.resource !== void 0 && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
915
967
  if (options.authorization_servers !== void 0) {
916
968
  if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
917
969
  for (const issuer of options.authorization_servers) {
@@ -1155,7 +1207,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1155
1207
  }
1156
1208
  if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
1157
1209
  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);
1210
+ const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
1211
+ if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
1212
+ category: "client-authentication",
1213
+ reason: "token_endpoint_auth_method_mismatch",
1214
+ detail: {
1215
+ clientId: clientInfo.clientId,
1216
+ registeredMethod: registeredAuthMethod,
1217
+ presentedMethod: presentedAuthMethod
1218
+ }
1219
+ });
1159
1220
  if (presentedAuthMethod !== "none") {
1160
1221
  if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1161
1222
  if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
@@ -1647,7 +1708,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1647
1708
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
1648
1709
  const configuredResource = this.options.resourceMetadata?.resource;
1649
1710
  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);
1711
+ const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
1651
1712
  const now = Math.floor(Date.now() / 1e3);
1652
1713
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
1653
1714
  if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
@@ -2054,10 +2115,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2054
2115
  statusCode: 400
2055
2116
  });
2056
2117
  }
2118
+ let authMethodWasExplicit;
2057
2119
  let authMethod;
2058
2120
  let grantTypes;
2059
2121
  let responseTypes;
2060
2122
  try {
2123
+ authMethodWasExplicit = clientMetadata.token_endpoint_auth_method !== void 0;
2061
2124
  authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
2062
2125
  grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2063
2126
  responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
@@ -2097,7 +2160,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2097
2160
  grantTypes,
2098
2161
  responseTypes,
2099
2162
  registrationDate: Math.floor(Date.now() / 1e3),
2100
- tokenEndpointAuthMethod: authMethod
2163
+ tokenEndpointAuthMethod: authMethod,
2164
+ ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
2101
2165
  };
2102
2166
  if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
2103
2167
  } catch (error) {
@@ -2329,19 +2393,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2329
2393
  }
2330
2394
  /**
2331
2395
  * 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.
2396
+ * A configured canonical resource is inherited when omitted but cannot be overridden.
2397
+ * Without configuration, RFC 8707 downscoping is allowed, omission inherits a
2398
+ * bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
2334
2399
  */
2335
2400
  resolveTokenResource(requestedResource, grantedResource) {
2401
+ const resourceWasProvided = requestedResource !== void 0;
2336
2402
  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" });
2403
+ if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2404
+ const grantResourceWasStored = grantedResource !== void 0;
2338
2405
  const grantedAudience = parseResourceParameter(grantedResource);
2339
- if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2406
+ if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2340
2407
  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) {
2408
+ if (configuredResource) {
2409
+ if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2410
+ if (isExactResource(grantedResource, configuredResource)) return configuredResource;
2411
+ if (!grantResourceWasStored) return configuredResource;
2412
+ throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
2413
+ }
2414
+ const originOnly = !!this.options.resourceMatchOriginOnly;
2415
+ if (resourceWasProvided && grantResourceWasStored) {
2345
2416
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2346
2417
  const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2347
2418
  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" });
@@ -2546,13 +2617,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2546
2617
  if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
2547
2618
  if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
2548
2619
  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"];
2620
+ const advertisedGrantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2621
+ const advertisedResponseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
2551
2622
  const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
2552
- validateClientCapabilities(this.serverCapabilities, {
2623
+ const { grantTypes, responseTypes } = negotiateCimdClientCapabilities(this.serverCapabilities, {
2553
2624
  tokenEndpointAuthMethod: effectiveAuthMethod,
2554
- grantTypes,
2555
- responseTypes
2625
+ grantTypes: advertisedGrantTypes,
2626
+ responseTypes: advertisedResponseTypes
2556
2627
  });
2557
2628
  return {
2558
2629
  clientId,
@@ -2861,6 +2932,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
2861
2932
  function parseResourceParameter(value) {
2862
2933
  if (!value) return;
2863
2934
  const uris = Array.isArray(value) ? value : [value];
2935
+ if (uris.length === 0) return;
2864
2936
  for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
2865
2937
  return value;
2866
2938
  }
@@ -3207,7 +3279,7 @@ var OAuthHelpersImpl = class {
3207
3279
  * Parses an OAuth authorization request from the HTTP request
3208
3280
  * @param request - The HTTP request containing OAuth parameters
3209
3281
  * @returns The parsed authorization request parameters
3210
- * @throws Error when the response type is missing, unsupported, or not registered for the client
3282
+ * @throws AuthorizationError for expected authorization-request validation failures
3211
3283
  * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
3212
3284
  */
3213
3285
  async parseAuthRequest(request) {
@@ -3222,22 +3294,36 @@ var OAuthHelpersImpl = class {
3222
3294
  const issuer = this.provider.getAuthorizationServerIssuer(url);
3223
3295
  const resourceParams = url.searchParams.getAll("resource");
3224
3296
  const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
3225
- validateRedirectUriScheme(redirectUri);
3297
+ if (!clientId) throw new AuthorizationError("invalid_request", { description: "client_id is required" });
3298
+ const clientInfo = await this.lookupClient(clientId);
3299
+ if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
3300
+ try {
3301
+ validateRedirectUriScheme(redirectUri);
3302
+ } catch {
3303
+ throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
3304
+ }
3305
+ if (!redirectUri || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
3306
+ const withRedirect = (error) => {
3307
+ throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3308
+ };
3309
+ const resourceWasProvided = resourceParam !== void 0;
3226
3310
  let resource = parseResourceParameter(resourceParam);
3227
- if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
3311
+ if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3228
3312
  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;
3231
- if (clientId) {
3232
- const clientInfo = await this.lookupClient(clientId);
3233
- if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
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.`);
3313
+ if (configuredResource) {
3314
+ if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
3315
+ resource = configuredResource;
3316
+ }
3317
+ try {
3235
3318
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
3236
3319
  validateAuthorizationPkce(this.provider.serverCapabilities, {
3237
3320
  responseType,
3238
3321
  codeChallenge,
3239
3322
  codeChallengeMethod
3240
3323
  }, clientInfo);
3324
+ } catch (error) {
3325
+ if (error instanceof AuthorizationError) withRedirect(error);
3326
+ throw error;
3241
3327
  }
3242
3328
  return {
3243
3329
  responseType,
@@ -3263,7 +3349,8 @@ var OAuthHelpersImpl = class {
3263
3349
  * validating the metadata document fails.
3264
3350
  */
3265
3351
  async lookupClient(clientId) {
3266
- return await this.provider.getClient(this.env, clientId);
3352
+ const client = await this.provider.getClient(this.env, clientId);
3353
+ return client ? toPublicClientInfo(client) : null;
3267
3354
  }
3268
3355
  /**
3269
3356
  * Completes an authorization request by creating a grant and either:
@@ -3281,8 +3368,11 @@ var OAuthHelpersImpl = class {
3281
3368
  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
3369
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3283
3370
  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;
3371
+ const resourceWasProvided = options.request.resource !== void 0;
3372
+ const parsedResource = parseResourceParameter(options.request.resource);
3373
+ if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
3374
+ if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
3375
+ const effectiveResource = configuredResource ?? parsedResource;
3286
3376
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3287
3377
  let grantsToRevoke = [];
3288
3378
  if (options.revokeExistingGrants !== false) {
@@ -3388,6 +3478,7 @@ var OAuthHelpersImpl = class {
3388
3478
  */
3389
3479
  async createClient(clientInfo) {
3390
3480
  const clientId = generateRandomString(16);
3481
+ const authMethodWasExplicit = clientInfo.tokenEndpointAuthMethod !== void 0;
3391
3482
  const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
3392
3483
  const isPublicClient = tokenEndpointAuthMethod === "none";
3393
3484
  const newClient = {
@@ -3408,7 +3499,8 @@ var OAuthHelpersImpl = class {
3408
3499
  ],
3409
3500
  responseTypes: clientInfo.responseTypes || ["code"],
3410
3501
  registrationDate: Math.floor(Date.now() / 1e3),
3411
- tokenEndpointAuthMethod
3502
+ tokenEndpointAuthMethod,
3503
+ ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
3412
3504
  };
3413
3505
  for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
3414
3506
  let clientSecret;
@@ -3417,7 +3509,7 @@ var OAuthHelpersImpl = class {
3417
3509
  newClient.clientSecret = await hashSecret(clientSecret);
3418
3510
  }
3419
3511
  await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
3420
- const clientResponse = { ...newClient };
3512
+ const clientResponse = toPublicClientInfo(newClient);
3421
3513
  if (!isPublicClient && clientSecret) clientResponse.clientSecret = clientSecret;
3422
3514
  return clientResponse;
3423
3515
  }
@@ -3435,7 +3527,7 @@ var OAuthHelpersImpl = class {
3435
3527
  const promises = response.keys.map(async (key) => {
3436
3528
  const clientId = key.name.substring(7);
3437
3529
  const client = await this.provider.getClient(this.env, clientId);
3438
- if (client) clients.push(client);
3530
+ if (client) clients.push(toPublicClientInfo(client));
3439
3531
  });
3440
3532
  await Promise.all(promises);
3441
3533
  return {
@@ -3452,7 +3544,8 @@ var OAuthHelpersImpl = class {
3452
3544
  async updateClient(clientId, updates) {
3453
3545
  const client = await this.provider.getClient(this.env, clientId);
3454
3546
  if (!client) return null;
3455
- let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
3547
+ const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
3548
+ const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
3456
3549
  const isPublicClient = authMethod === "none";
3457
3550
  let secretToStore = client.clientSecret;
3458
3551
  let originalSecret = void 0;
@@ -3465,14 +3558,15 @@ var OAuthHelpersImpl = class {
3465
3558
  ...client,
3466
3559
  ...updates,
3467
3560
  clientId: client.clientId,
3468
- tokenEndpointAuthMethod: authMethod
3561
+ tokenEndpointAuthMethod: authMethod,
3562
+ authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
3469
3563
  };
3470
3564
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
3471
3565
  else delete updatedClient.clientSecret;
3472
3566
  const clientKvOptions = {};
3473
3567
  if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
3474
3568
  await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
3475
- const response = { ...updatedClient };
3569
+ const response = toPublicClientInfo(updatedClient);
3476
3570
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
3477
3571
  return response;
3478
3572
  }
@@ -3670,4 +3764,4 @@ var OAuthHelpersImpl = class {
3670
3764
  var oauth_provider_default = OAuthProvider;
3671
3765
 
3672
3766
  //#endregion
3673
- export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
3767
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",