@cloudflare/workers-oauth-provider 0.10.0 → 0.10.2

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
@@ -294,18 +294,21 @@ new OAuthProvider({
294
294
  }
295
295
  ```
296
296
 
297
- The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises `client_id_metadata_document_supported: true` only when both settings are present.
297
+ The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises `client_id_metadata_document_supported: true` only when both settings are present. CIMD fetches also use the `cache` option of `fetch`, which requires a compatibility date of `2024-11-11` or later (or the `cache_option_enabled` compatibility flag).
298
298
 
299
- CIMD validation includes:
299
+ CIMD validation follows [draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00) — the revision pinned by the MCP 2026-07-28 authorization spec — and includes:
300
300
 
301
- - HTTPS URL with a non-root path.
301
+ - An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
302
302
  - A document `client_id` exactly matching its URL.
303
- - Non-empty `client_name` and `redirect_uris` fields.
303
+ - Non-empty `client_name` and `redirect_uris` fields, as MCP requires, with unsafe redirect schemes rejected at ingestion.
304
304
  - Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
305
- - A 5 KB response size limit and 10 second fetch timeout.
306
- - Safe URI schemes for client metadata fields.
305
+ - A 5 KB response size limit and a 10 second timeout covering both headers and body.
306
+ - Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
307
+ - No embedded client secrets or private JWK material.
307
308
 
308
- CIMD currently supports only `token_endpoint_auth_method: "none"`.
309
+ Validated documents are cached according to their `Cache-Control` headers, capped at 7 days. Error responses and invalid documents are never cached, and a cached document that stops validating is evicted and re-resolved from origin within the same request.
310
+
311
+ CIMD token endpoint authentication is negotiated from `token_endpoint_auth_method` and the OpenID RP Metadata Choices field `token_endpoint_auth_methods_supported`. The provider currently implements only `none`: a client may prefer `private_key_jwt` while also offering `none`, in which case the provider selects `none` and applies public-client PKCE requirements. A client that offers only `private_key_jwt` is rejected until assertion validation is implemented.
309
312
 
310
313
  When a CIMD document cannot be fetched or validated, the token endpoint returns a generic `invalid_client` response and reports diagnostics through `onError.internal`. `OAuthHelpers` methods that resolve a CIMD client throw the exported `CimdFetchError`, allowing applications to distinguish an upstream metadata failure from a client that does not exist. See [Advanced configuration](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/advanced-configuration.md#cimd-fetch-errors) for an example.
311
314
 
@@ -319,7 +322,11 @@ clientRegistrationEndpoint: '/oauth/register';
319
322
 
320
323
  MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endpoint remains useful for compatibility with clients that do not support CIMD.
321
324
 
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"]`.
325
+ Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued `token_endpoint_auth_methods_supported` input is negotiated to one effective `token_endpoint_auth_method`; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
326
+
327
+ The effective `token_endpoint_auth_method` returned by registration is enforced exactly. When both authentication metadata fields are 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.
328
+
329
+ Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
323
330
 
324
331
  Related options:
325
332
 
@@ -345,16 +352,11 @@ The provider owns `tokenEndpoint`. It exchanges authorization codes for tokens,
345
352
 
346
353
  ## Resources and token audiences
347
354
 
348
- 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.
349
-
350
- Resource policy follows `resourceMetadata.resource`:
351
-
352
- - When configured, authorization requests, token requests, and externally resolved tokens must use that one exact resource. `resourceMatchOriginOnly` cannot weaken this policy.
353
- - 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.
355
+ 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`.
354
356
 
355
- 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.
357
+ 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`.
356
358
 
357
- `resourceMatchOriginOnly` remains a migration option for grants created before path-aware resources were introduced. Do not enable it for a new deployment.
359
+ `resourceMatchOriginOnly` is deprecated; its existing behavior is unchanged. Prefer `resourceMetadata.resource` for new deployments.
358
360
 
359
361
  ## Scopes and step-up authorization
360
362
 
@@ -431,7 +433,7 @@ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants
431
433
  | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
432
434
  | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
433
435
  | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
434
- | `resourceMatchOriginOnly` | Migration mode for old origin-only resource grants | `false` |
436
+ | `resourceMatchOriginOnly` | Deprecated origin-only resource comparison | `false` |
435
437
  | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
436
438
  | `onError` | Observe or replace OAuth error responses | Logs a warning |
437
439
 
@@ -466,6 +468,7 @@ The package implements or supports the relevant portions of:
466
468
  - [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
467
469
  - [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
468
470
  - [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
471
+ - [OpenID Connect RP Metadata Choices 1.0](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html)
469
472
  - [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
470
473
 
471
474
  ## Development
@@ -299,7 +299,7 @@ interface EmaOptions<Env = Cloudflare.Env> {
299
299
  }
300
300
  //#endregion
301
301
  //#region src/oauth-capabilities.d.ts
302
- type AuthorizationErrorCode = 'invalid_request' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
302
+ type AuthorizationErrorCode = 'invalid_request' | 'invalid_target' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
303
303
  interface AuthorizationErrorOptions {
304
304
  /** Wire-safe OAuth authorization error description. */
305
305
  description: string;
@@ -685,14 +685,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
685
685
  */
686
686
  clientIdMetadataDocumentEnabled?: boolean;
687
687
  /**
688
- * When true, resource validation during token exchange compares origins only
689
- * (scheme + host + port) instead of exact URI matching. This allows grants issued
690
- * with an origin-only resource (e.g. `https://server.com`) to be used with
691
- * path-aware resource requests (e.g. `https://server.com/mcp`), enabling seamless
692
- * migration from pre-0.4.0 versions that stored origin-only resource URIs.
693
- * 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.
694
693
  *
695
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`.
696
698
  */
697
699
  resourceMatchOriginOnly?: boolean;
698
700
  /**
@@ -706,10 +708,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
706
708
  /**
707
709
  * The protected resource identifier URL (RFC 9728 `resource` field).
708
710
  *
709
- * Configuring this value pins authorization requests, token requests, and
710
- * access-token audiences to this exact resource. If omitted, the provider
711
- * accepts valid RFC 8707 resource indicators and uses the authorization
712
- * 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.
713
716
  */
714
717
  resource?: string;
715
718
  /**
@@ -53,6 +53,68 @@ function validateClientCapabilities(server, client) {
53
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");
54
54
  if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
55
55
  }
56
+ const SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = new Set([
57
+ "client_secret_basic",
58
+ "client_secret_post",
59
+ "client_secret_jwt"
60
+ ]);
61
+ function negotiateTokenEndpointAuthMethod(options) {
62
+ const { acceptedMethods, defaultMethod, preferredMethod, supportedMethods, context } = options;
63
+ if (preferredMethod !== void 0 && supportedMethods !== void 0 && !supportedMethods.includes(preferredMethod)) throw new Error("token_endpoint_auth_method must be included in token_endpoint_auth_methods_supported");
64
+ const advertisedMethods = supportedMethods ?? [preferredMethod ?? defaultMethod];
65
+ const effectiveMethod = preferredMethod !== void 0 && acceptedMethods.includes(preferredMethod) ? preferredMethod : acceptedMethods.find((method) => advertisedMethods.includes(method));
66
+ if (effectiveMethod !== void 0) return effectiveMethod;
67
+ const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
68
+ throw new Error(`${context} does not support an accepted token endpoint authentication method. Supported methods: ${acceptedMethods.join(", ")}. Client advertised: ${advertised.length > 0 ? advertised.join(", ") : "(none)"}`);
69
+ }
70
+ function negotiateCimdTokenEndpointAuthMethod(server, preferredMethod, supportedMethods) {
71
+ if (preferredMethod !== void 0 && SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(preferredMethod)) throw new Error(`CIMD clients cannot use symmetric token endpoint authentication method: ${preferredMethod}`);
72
+ return negotiateTokenEndpointAuthMethod({
73
+ acceptedMethods: server.tokenEndpointAuthMethods.filter((method) => !SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(method)),
74
+ defaultMethod: "none",
75
+ preferredMethod,
76
+ supportedMethods,
77
+ context: "CIMD client"
78
+ });
79
+ }
80
+ /**
81
+ * Negotiates choice-valued authentication metadata while keeping DCR grant and
82
+ * response registration strict.
83
+ */
84
+ function negotiateDynamicClientRegistrationCapabilities(server, client) {
85
+ const effective = {
86
+ grantTypes: [...client.grantTypes],
87
+ responseTypes: [...client.responseTypes],
88
+ tokenEndpointAuthMethod: negotiateTokenEndpointAuthMethod({
89
+ acceptedMethods: server.tokenEndpointAuthMethods,
90
+ defaultMethod: "client_secret_basic",
91
+ preferredMethod: client.tokenEndpointAuthMethod,
92
+ supportedMethods: client.tokenEndpointAuthMethodsSupported,
93
+ context: "Client"
94
+ })
95
+ };
96
+ validateClientCapabilities(server, effective);
97
+ return effective;
98
+ }
99
+ /**
100
+ * Selects the capabilities from a Client ID Metadata Document that this
101
+ * authorization server supports. CIMD documents may advertise extension
102
+ * capabilities alongside the flow used with this server, so unsupported grant
103
+ * and response types are omitted from the effective client metadata instead of
104
+ * invalidating an otherwise usable client.
105
+ *
106
+ * The effective subset is still checked for grant/response consistency, and
107
+ * token endpoint authentication must have a mutually supported method.
108
+ */
109
+ function negotiateCimdClientCapabilities(server, client) {
110
+ const effective = {
111
+ grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
112
+ responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
113
+ tokenEndpointAuthMethod: negotiateCimdTokenEndpointAuthMethod(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported)
114
+ };
115
+ validateClientCapabilities(server, effective);
116
+ return effective;
117
+ }
56
118
  function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
57
119
  if (!responseType) throw new AuthorizationError("invalid_request", { description: "response_type is required" });
58
120
  if (!server.responseTypes.includes(responseType)) throw new AuthorizationError("unsupported_response_type", { description: `The authorization server does not support response_type ${responseType}` });
@@ -88,6 +150,403 @@ function isValidOAuthScopeToken(scopeToken) {
88
150
  return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
89
151
  }
90
152
 
153
+ //#endregion
154
+ //#region src/oauth-client-metadata.ts
155
+ const CIMD_MAX_SIZE_BYTES = 5 * 1024;
156
+ const CIMD_FETCH_TIMEOUT_MS = 1e4;
157
+ const CIMD_CACHE_NAME = "workers-oauth-provider:cimd:v1";
158
+ const CIMD_CACHE_MAX_TTL_SECONDS = 10080 * 60;
159
+ function requireJsonObject(value) {
160
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Client metadata must be a JSON object");
161
+ return value;
162
+ }
163
+ function optionalString(value, fieldName) {
164
+ if (value === void 0) return void 0;
165
+ if (typeof value !== "string") throw new Error(`Invalid ${fieldName}: expected string, got ${typeof value}`);
166
+ return value;
167
+ }
168
+ function optionalStringArray(value, fieldName) {
169
+ if (value === void 0) return void 0;
170
+ if (!Array.isArray(value)) throw new Error(`Invalid ${fieldName}: expected array, got ${typeof value}`);
171
+ if (!value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName}: array must contain only strings`);
172
+ return [...value];
173
+ }
174
+ function optionalHttpUri(value, fieldName) {
175
+ const uri = optionalString(value, fieldName);
176
+ if (uri === void 0) return void 0;
177
+ let parsed;
178
+ try {
179
+ parsed = new URL(uri);
180
+ } catch {
181
+ throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
182
+ }
183
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
184
+ return uri;
185
+ }
186
+ const I18N_FIELDS = {
187
+ client_name: "string",
188
+ client_uri: "uri",
189
+ logo_uri: "uri",
190
+ tos_uri: "uri",
191
+ policy_uri: "uri"
192
+ };
193
+ function extractI18nFields(raw) {
194
+ const result = {};
195
+ for (const key of Object.keys(raw)) {
196
+ const hashIndex = key.indexOf("#");
197
+ if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
198
+ const kind = I18N_FIELDS[key.slice(0, hashIndex)];
199
+ if (!kind) continue;
200
+ const value = kind === "uri" ? optionalHttpUri(raw[key], key) : optionalString(raw[key], key);
201
+ if (value !== void 0) result[key] = value;
202
+ }
203
+ return Object.keys(result).length > 0 ? result : void 0;
204
+ }
205
+ function validateChoiceConsistency(preferredName, preferredValue, choicesName, choices) {
206
+ if (preferredValue !== void 0 && choices !== void 0 && !choices.includes(preferredValue)) throw new Error(`${preferredName} must be included in ${choicesName}`);
207
+ }
208
+ function parseOAuthClientMetadata(raw) {
209
+ const tokenEndpointAuthMethod = optionalString(raw.token_endpoint_auth_method, "token_endpoint_auth_method");
210
+ const tokenEndpointAuthMethodsSupported = optionalStringArray(raw.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
211
+ const tokenEndpointAuthSigningAlg = optionalString(raw.token_endpoint_auth_signing_alg, "token_endpoint_auth_signing_alg");
212
+ const tokenEndpointAuthSigningAlgValuesSupported = optionalStringArray(raw.token_endpoint_auth_signing_alg_values_supported, "token_endpoint_auth_signing_alg_values_supported");
213
+ validateChoiceConsistency("token_endpoint_auth_method", tokenEndpointAuthMethod, "token_endpoint_auth_methods_supported", tokenEndpointAuthMethodsSupported);
214
+ validateChoiceConsistency("token_endpoint_auth_signing_alg", tokenEndpointAuthSigningAlg, "token_endpoint_auth_signing_alg_values_supported", tokenEndpointAuthSigningAlgValuesSupported);
215
+ return {
216
+ clientId: optionalString(raw.client_id, "client_id"),
217
+ redirectUris: optionalStringArray(raw.redirect_uris, "redirect_uris"),
218
+ clientName: optionalString(raw.client_name, "client_name"),
219
+ clientUri: optionalHttpUri(raw.client_uri, "client_uri"),
220
+ logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
221
+ policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
222
+ tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
223
+ jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
224
+ i18n: extractI18nFields(raw),
225
+ contacts: optionalStringArray(raw.contacts, "contacts"),
226
+ grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
227
+ responseTypes: optionalStringArray(raw.response_types, "response_types"),
228
+ tokenEndpointAuthMethod,
229
+ tokenEndpointAuthMethodsSupported,
230
+ tokenEndpointAuthSigningAlg,
231
+ tokenEndpointAuthSigningAlgValuesSupported
232
+ };
233
+ }
234
+ function pickDisplayMetadata(metadata) {
235
+ const { clientName, clientUri, logoUri, policyUri, tosUri, jwksUri, i18n, contacts } = metadata;
236
+ return {
237
+ clientName,
238
+ clientUri,
239
+ logoUri,
240
+ policyUri,
241
+ tosUri,
242
+ jwksUri,
243
+ i18n,
244
+ contacts
245
+ };
246
+ }
247
+ /**
248
+ * Validates that a redirect URI has a scheme and does not use a dangerous
249
+ * pseudo-scheme or contain control characters.
250
+ */
251
+ function validateRedirectUriScheme(redirectUri) {
252
+ const dangerousSchemes = [
253
+ "javascript:",
254
+ "data:",
255
+ "vbscript:",
256
+ "file:",
257
+ "mailto:",
258
+ "blob:"
259
+ ];
260
+ const normalized = redirectUri.trim();
261
+ for (let i = 0; i < normalized.length; i++) {
262
+ const code = normalized.charCodeAt(i);
263
+ if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
264
+ }
265
+ const colonIndex = normalized.indexOf(":");
266
+ if (colonIndex === -1) throw new Error("Invalid redirect URI");
267
+ const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
268
+ if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
269
+ }
270
+ function requireValidRedirectUris(redirectUris) {
271
+ if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
272
+ for (const redirectUri of redirectUris) validateRedirectUriScheme(redirectUri);
273
+ return redirectUris;
274
+ }
275
+ /**
276
+ * Resolves a Dynamic Client Registration request into the complete metadata
277
+ * shape that can be stored, applying RFC 7591 defaults and server capability
278
+ * validation exactly once.
279
+ */
280
+ function resolveDynamicClientRegistrationMetadata(raw, server) {
281
+ const metadata = parseOAuthClientMetadata(raw);
282
+ const capabilities = negotiateDynamicClientRegistrationCapabilities(server, {
283
+ tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
284
+ tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
285
+ grantTypes: metadata.grantTypes ?? ["authorization_code"],
286
+ responseTypes: metadata.responseTypes ?? ["code"]
287
+ });
288
+ return {
289
+ ...pickDisplayMetadata(metadata),
290
+ redirectUris: requireValidRedirectUris(metadata.redirectUris),
291
+ ...capabilities,
292
+ authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
293
+ };
294
+ }
295
+ /**
296
+ * Path component of the raw client ID string. Deliberately not
297
+ * `new URL().pathname`: WHATWG parsing collapses the `.` and `..` segments
298
+ * that CIMD §3 requires rejecting, and normalizes `\` and scheme-relative
299
+ * forms this validation must see verbatim.
300
+ */
301
+ function rawPath(clientId) {
302
+ const schemeEnd = clientId.indexOf("://");
303
+ if (schemeEnd === -1) return "";
304
+ const authorityStart = schemeEnd + 3;
305
+ const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
306
+ if (authorityEndOffset === -1) return "";
307
+ const pathStart = authorityStart + authorityEndOffset;
308
+ if (clientId[pathStart] !== "/") return "";
309
+ const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
310
+ return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
311
+ }
312
+ function validateClientIdentifierUrl(clientId) {
313
+ if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
314
+ if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
315
+ let parsed;
316
+ try {
317
+ parsed = new URL(clientId);
318
+ } catch {
319
+ throw new Error("Client Identifier URL must be a valid HTTPS URL");
320
+ }
321
+ if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
322
+ if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
323
+ if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
324
+ const path = rawPath(clientId);
325
+ if (!path) throw new Error("Client Identifier URL must contain a path component");
326
+ for (const segment of path.split("/")) {
327
+ let decodedSegment;
328
+ try {
329
+ decodedSegment = decodeURIComponent(segment);
330
+ } catch {
331
+ throw new Error("Client Identifier URL contains invalid percent encoding");
332
+ }
333
+ if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
334
+ }
335
+ }
336
+ /** Returns whether a client ID has the URL shape this package resolves through CIMD. */
337
+ function isClientIdMetadataDocumentUrl(clientId) {
338
+ try {
339
+ return new URL(clientId).protocol === "https:" && rawPath(clientId) !== "";
340
+ } catch {
341
+ return false;
342
+ }
343
+ }
344
+ function containsPrivateJwkMaterial(value) {
345
+ if (value === void 0) return false;
346
+ const jwks = requireJsonObject(value);
347
+ if (!Array.isArray(jwks.keys)) throw new Error("Invalid jwks: keys must be an array");
348
+ const privateMembers = new Set([
349
+ "d",
350
+ "p",
351
+ "q",
352
+ "dp",
353
+ "dq",
354
+ "qi",
355
+ "oth",
356
+ "k"
357
+ ]);
358
+ for (const value$1 of jwks.keys) {
359
+ const key = requireJsonObject(value$1);
360
+ if (Object.keys(key).some((member) => privateMembers.has(member))) return true;
361
+ }
362
+ return false;
363
+ }
364
+ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
365
+ const raw = requireJsonObject(value);
366
+ const metadata = parseOAuthClientMetadata(raw);
367
+ if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
368
+ if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
369
+ const redirectUris = requireValidRedirectUris(metadata.redirectUris);
370
+ if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
371
+ if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
372
+ const capabilities = negotiateCimdClientCapabilities(server, {
373
+ tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
374
+ tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
375
+ grantTypes: metadata.grantTypes ?? ["authorization_code"],
376
+ responseTypes: metadata.responseTypes ?? ["code"]
377
+ });
378
+ return {
379
+ ...pickDisplayMetadata(metadata),
380
+ clientId: metadata.clientId,
381
+ clientName: metadata.clientName,
382
+ redirectUris,
383
+ ...capabilities
384
+ };
385
+ }
386
+ function readStreamChunk(reader, signal) {
387
+ if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
388
+ return new Promise((resolve, reject) => {
389
+ const abort = () => {
390
+ reader.cancel().catch(() => void 0);
391
+ reject(new DOMException("Aborted", "AbortError"));
392
+ };
393
+ signal.addEventListener("abort", abort, { once: true });
394
+ reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
395
+ });
396
+ }
397
+ async function readJsonWithSizeLimit$1(response, maxBytes, signal) {
398
+ const contentLength = response.headers.get("Content-Length");
399
+ if (contentLength !== null) {
400
+ const declaredSize = Number(contentLength);
401
+ if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
402
+ await response.body?.cancel().catch(() => void 0);
403
+ throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
404
+ }
405
+ }
406
+ const reader = response.body?.getReader();
407
+ if (!reader) throw new Error("Client metadata response body is empty");
408
+ const chunks = [];
409
+ let totalSize = 0;
410
+ while (true) {
411
+ const { done, value } = await readStreamChunk(reader, signal);
412
+ if (done) break;
413
+ if (!value) continue;
414
+ totalSize += value.length;
415
+ if (totalSize > maxBytes) {
416
+ await reader.cancel().catch(() => void 0);
417
+ throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
418
+ }
419
+ chunks.push(value);
420
+ }
421
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
422
+ const bytes = new Uint8Array(totalSize);
423
+ let offset = 0;
424
+ for (const chunk of chunks) {
425
+ bytes.set(chunk, offset);
426
+ offset += chunk.length;
427
+ }
428
+ let text;
429
+ try {
430
+ text = new TextDecoder("utf-8", {
431
+ fatal: true,
432
+ ignoreBOM: false
433
+ }).decode(bytes);
434
+ } catch {
435
+ throw new Error("Client metadata response is not valid UTF-8");
436
+ }
437
+ try {
438
+ return {
439
+ value: JSON.parse(text),
440
+ bytes
441
+ };
442
+ } catch {
443
+ throw new Error("Client metadata response is not valid JSON");
444
+ }
445
+ }
446
+ function fetchCimdOrigin(metadataUrl, signal) {
447
+ return fetch(metadataUrl, {
448
+ headers: {
449
+ Accept: "application/json",
450
+ "Cache-Control": "no-store"
451
+ },
452
+ signal,
453
+ cache: "no-store"
454
+ });
455
+ }
456
+ async function openCimdCache() {
457
+ if (typeof caches === "undefined") return void 0;
458
+ try {
459
+ return await caches.open(CIMD_CACHE_NAME);
460
+ } catch {
461
+ return;
462
+ }
463
+ }
464
+ /**
465
+ * Shared-cache lifetime derived from the origin's Cache-Control directives,
466
+ * bounded by the server-side cap the CIMD draft permits (§5.2 "MAY define its
467
+ * own upper and/or lower bounds on an acceptable cache lifetime"). Returns
468
+ * undefined when the response must not be stored.
469
+ */
470
+ function cacheTtlSeconds(response) {
471
+ const cacheControl = response.headers.get("Cache-Control");
472
+ if (cacheControl === null || /(?:^|,)\s*(?:no-cache|no-store|private)\b/i.test(cacheControl)) return void 0;
473
+ const directive = /(?:^|,)\s*s-maxage\s*=\s*"?(\d+)/i.exec(cacheControl) ?? /(?:^|,)\s*max-age\s*=\s*"?(\d+)/i.exec(cacheControl);
474
+ if (!directive) return void 0;
475
+ const ttl = Math.min(Number(directive[1]), CIMD_CACHE_MAX_TTL_SECONDS);
476
+ return ttl > 0 ? ttl : void 0;
477
+ }
478
+ async function cacheValidatedDocument(cache, metadataUrl, response, bytes) {
479
+ if (!cache) return;
480
+ const ttl = cacheTtlSeconds(response);
481
+ if (ttl === void 0) return;
482
+ const headers = new Headers({ "Cache-Control": `public, max-age=${ttl}` });
483
+ for (const name of [
484
+ "Content-Type",
485
+ "ETag",
486
+ "Last-Modified"
487
+ ]) {
488
+ const value = response.headers.get(name);
489
+ if (value !== null) headers.set(name, value);
490
+ }
491
+ try {
492
+ await cache.put(metadataUrl, new Response(bytes, {
493
+ status: 200,
494
+ headers
495
+ }));
496
+ } catch {}
497
+ }
498
+ /**
499
+ * Resolves a previously validated cache entry. A stored document that stops
500
+ * validating (changed server capabilities, stricter rules after an upgrade)
501
+ * is evicted so the caller re-resolves from origin in the same request;
502
+ * timeout aborts propagate instead.
503
+ */
504
+ async function tryResolveFromCache(cache, metadataUrl, server, signal) {
505
+ let cached;
506
+ try {
507
+ cached = await cache?.match(metadataUrl);
508
+ } catch {
509
+ return;
510
+ }
511
+ if (!cached) return void 0;
512
+ try {
513
+ const { value } = await readJsonWithSizeLimit$1(cached, CIMD_MAX_SIZE_BYTES, signal);
514
+ return resolveClientIdMetadataDocument(metadataUrl, value, server);
515
+ } catch (error) {
516
+ if (signal.aborted) throw error;
517
+ try {
518
+ await cache?.delete(metadataUrl);
519
+ } catch {}
520
+ return;
521
+ }
522
+ }
523
+ /**
524
+ * Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
525
+ * Document into the single effective client configuration used by OAuth flows.
526
+ */
527
+ async function fetchClientIdMetadataDocument(metadataUrl, server) {
528
+ validateClientIdentifierUrl(metadataUrl);
529
+ const abortController = new AbortController();
530
+ const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
531
+ try {
532
+ const cache = await openCimdCache();
533
+ const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
534
+ if (cachedDocument) return cachedDocument;
535
+ const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
536
+ if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
537
+ const { value, bytes } = await readJsonWithSizeLimit$1(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
538
+ const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
539
+ clearTimeout(timeoutId);
540
+ await cacheValidatedDocument(cache, metadataUrl, response, bytes);
541
+ return resolved;
542
+ } catch (error) {
543
+ if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
544
+ throw error;
545
+ } finally {
546
+ clearTimeout(timeoutId);
547
+ }
548
+ }
549
+
91
550
  //#endregion
92
551
  //#region src/ema/constants.ts
93
552
  /**
@@ -794,6 +1253,15 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
794
1253
  GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
795
1254
  return GrantType$1;
796
1255
  }({});
1256
+ function toPublicClientInfo(client) {
1257
+ const { authMethodExplicit: _explicit, ...publicClient } = client;
1258
+ return publicClient;
1259
+ }
1260
+ function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
1261
+ if (presentedMethod === client.tokenEndpointAuthMethod) return true;
1262
+ const isSecretMethod = (method) => method === "client_secret_basic" || method === "client_secret_post";
1263
+ return !isClientMetadataDocument && client.authMethodExplicit === void 0 && isSecretMethod(client.tokenEndpointAuthMethod) && isSecretMethod(presentedMethod);
1264
+ }
797
1265
  /**
798
1266
  * OAuth 2.0 Provider implementation for Cloudflare Workers
799
1267
  * Implements authorization code flow with support for refresh tokens
@@ -848,7 +1316,7 @@ function getOAuthApi(options, env) {
848
1316
  * annotation, and does not actually prevent the method from being called from outside the class,
849
1317
  * including over RPC.
850
1318
  */
851
- var OAuthProviderImpl = class OAuthProviderImpl {
1319
+ var OAuthProviderImpl = class {
852
1320
  /**
853
1321
  * Creates a new OAuth provider instance
854
1322
  * @param options - Configuration options for the provider
@@ -935,7 +1403,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
935
1403
  /** Validate configured RFC 9728 protected resource metadata. */
936
1404
  validateResourceMetadataOptions(options) {
937
1405
  if (!options) return;
938
- if (options.resource && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
1406
+ if (options.resource !== void 0 && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
939
1407
  if (options.authorization_servers !== void 0) {
940
1408
  if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
941
1409
  for (const issuer of options.authorization_servers) {
@@ -1179,7 +1647,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1179
1647
  }
1180
1648
  if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
1181
1649
  const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
1182
- if (presentedAuthMethod !== clientInfo.tokenEndpointAuthMethod) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted);
1650
+ const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
1651
+ if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
1652
+ category: "client-authentication",
1653
+ reason: "token_endpoint_auth_method_mismatch",
1654
+ detail: {
1655
+ clientId: clientInfo.clientId,
1656
+ registeredMethod: registeredAuthMethod,
1657
+ presentedMethod: presentedAuthMethod
1658
+ }
1659
+ });
1183
1660
  if (presentedAuthMethod !== "none") {
1184
1661
  if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
1185
1662
  if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
@@ -1671,7 +2148,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1671
2148
  let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
1672
2149
  const configuredResource = this.options.resourceMetadata?.resource;
1673
2150
  if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
1674
- const newAudience = this.resolveTokenResource(requestedResource, grantData.resource);
2151
+ const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
1675
2152
  const now = Math.floor(Date.now() / 1e3);
1676
2153
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
1677
2154
  if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
@@ -2064,35 +2541,29 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2064
2541
  statusCode: 413
2065
2542
  });
2066
2543
  const callbackRequest = request.clone();
2067
- let clientMetadata;
2544
+ let parsedJson;
2068
2545
  try {
2069
2546
  const text = await request.text();
2070
2547
  if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
2071
2548
  description: "Request payload too large, must be under 1 MiB",
2072
2549
  statusCode: 413
2073
2550
  });
2074
- clientMetadata = JSON.parse(text);
2075
- } catch (error) {
2551
+ parsedJson = JSON.parse(text);
2552
+ } catch {
2076
2553
  return this.createErrorResponse("invalid_request", {
2077
2554
  description: "Invalid JSON payload",
2078
2555
  statusCode: 400
2079
2556
  });
2080
2557
  }
2081
- let authMethod;
2082
- let grantTypes;
2083
- let responseTypes;
2558
+ let clientMetadata;
2559
+ let metadata;
2084
2560
  try {
2085
- authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
2086
- grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2087
- responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
2088
- validateClientCapabilities(this.serverCapabilities, {
2089
- tokenEndpointAuthMethod: authMethod,
2090
- grantTypes,
2091
- responseTypes
2092
- });
2561
+ clientMetadata = requireJsonObject(parsedJson);
2562
+ metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
2093
2563
  } catch (error) {
2094
2564
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
2095
2565
  }
2566
+ const authMethod = metadata.tokenEndpointAuthMethod;
2096
2567
  const isPublicClient = authMethod === "none";
2097
2568
  if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
2098
2569
  const clientId = generateRandomString(16);
@@ -2102,31 +2573,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2102
2573
  clientSecret = generateRandomString(32);
2103
2574
  hashedSecret = await hashSecret(clientSecret);
2104
2575
  }
2105
- let clientInfo;
2106
- try {
2107
- const redirectUris = OAuthProviderImpl.validateStringArray(clientMetadata.redirect_uris);
2108
- if (!redirectUris || redirectUris.length === 0) throw new Error("At least one redirect URI is required");
2109
- for (const uri of redirectUris) validateRedirectUriScheme(uri);
2110
- clientInfo = {
2111
- clientId,
2112
- redirectUris,
2113
- clientName: OAuthProviderImpl.validateStringField(clientMetadata.client_name, "client_name"),
2114
- logoUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.logo_uri, "logo_uri"),
2115
- clientUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.client_uri, "client_uri"),
2116
- policyUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.policy_uri, "policy_uri"),
2117
- tosUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.tos_uri, "tos_uri"),
2118
- jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
2119
- i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
2120
- contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
2121
- grantTypes,
2122
- responseTypes,
2123
- registrationDate: Math.floor(Date.now() / 1e3),
2124
- tokenEndpointAuthMethod: authMethod
2125
- };
2126
- if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
2127
- } catch (error) {
2128
- return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
2129
- }
2576
+ const clientInfo = {
2577
+ clientId,
2578
+ redirectUris: metadata.redirectUris,
2579
+ clientName: metadata.clientName,
2580
+ logoUri: metadata.logoUri,
2581
+ clientUri: metadata.clientUri,
2582
+ policyUri: metadata.policyUri,
2583
+ tosUri: metadata.tosUri,
2584
+ jwksUri: metadata.jwksUri,
2585
+ i18n: metadata.i18n,
2586
+ contacts: metadata.contacts,
2587
+ grantTypes: metadata.grantTypes,
2588
+ responseTypes: metadata.responseTypes,
2589
+ registrationDate: Math.floor(Date.now() / 1e3),
2590
+ tokenEndpointAuthMethod: authMethod,
2591
+ ...metadata.authMethodExplicit ? { authMethodExplicit: true } : {},
2592
+ ...!isPublicClient && hashedSecret ? { clientSecret: hashedSecret } : {}
2593
+ };
2130
2594
  if (this.options.clientRegistrationCallback) {
2131
2595
  let callbackResult;
2132
2596
  try {
@@ -2147,7 +2611,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2147
2611
  }
2148
2612
  const clientKvOptions = {};
2149
2613
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
2150
- await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
2614
+ await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
2151
2615
  const response = {
2152
2616
  client_id: clientInfo.clientId,
2153
2617
  redirect_uris: clientInfo.redirectUris,
@@ -2342,7 +2806,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2342
2806
  }
2343
2807
  if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
2344
2808
  try {
2345
- return await this.fetchClientMetadataDocument(clientId);
2809
+ return await fetchClientIdMetadataDocument(clientId, this.serverCapabilities);
2346
2810
  } catch (error) {
2347
2811
  console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
2348
2812
  throw new CimdFetchError(clientId, error);
@@ -2353,19 +2817,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2353
2817
  }
2354
2818
  /**
2355
2819
  * Resolves an access-token audience from a token request and its authorization grant.
2356
- * Explicit resource configuration requires one exact value in both places. Without
2357
- * configuration, RFC 8707 downscoping is allowed and omission inherits the grant.
2820
+ * A configured canonical resource is inherited when omitted but cannot be overridden.
2821
+ * Without configuration, RFC 8707 downscoping is allowed, omission inherits a
2822
+ * bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
2358
2823
  */
2359
2824
  resolveTokenResource(requestedResource, grantedResource) {
2825
+ const resourceWasProvided = requestedResource !== void 0;
2360
2826
  const requestedAudience = parseResourceParameter(requestedResource);
2361
- if (requestedResource && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2827
+ if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
2828
+ const grantResourceWasStored = grantedResource !== void 0;
2362
2829
  const grantedAudience = parseResourceParameter(grantedResource);
2363
- if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2830
+ if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
2364
2831
  const configuredResource = this.options.resourceMetadata?.resource;
2365
- if (configuredResource && (!isExactResource(grantedResource, configuredResource) || !isExactResource(requestedResource, configuredResource))) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2366
- if (!configuredResource && requestedResource && !grantedResource) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
2367
- const originOnly = configuredResource ? false : !!this.options.resourceMatchOriginOnly;
2368
- if (requestedResource && grantedResource) {
2832
+ if (configuredResource) {
2833
+ if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
2834
+ if (isExactResource(grantedResource, configuredResource)) return configuredResource;
2835
+ if (!grantResourceWasStored) return configuredResource;
2836
+ throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
2837
+ }
2838
+ const originOnly = !!this.options.resourceMatchOriginOnly;
2839
+ if (resourceWasProvided && grantResourceWasStored) {
2369
2840
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
2370
2841
  const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
2371
2842
  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" });
@@ -2430,207 +2901,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2430
2901
  * Not private because OAuthHelpersImpl needs access for purgeExpiredData.
2431
2902
  */
2432
2903
  isClientMetadataUrl(clientId) {
2433
- try {
2434
- const url = new URL(clientId);
2435
- return url.protocol === "https:" && url.pathname !== "/";
2436
- } catch {
2437
- return false;
2438
- }
2439
- }
2440
- static {
2441
- this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
2442
- }
2443
- static {
2444
- this.CIMD_FETCH_TIMEOUT_MS = 1e4;
2445
- }
2446
- static {
2447
- this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
2448
- }
2449
- /**
2450
- * Validates that a field is a string or undefined
2451
- * @param field - The field value to validate
2452
- * @param fieldName - Name of the field for error messages
2453
- * @returns The validated string or undefined
2454
- * @throws Error if field is not a string or undefined
2455
- */
2456
- static validateStringField(field, fieldName) {
2457
- if (field === void 0) return void 0;
2458
- if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
2459
- return field;
2460
- }
2461
- /**
2462
- * Validates that a field is an optional URI string using a safe scheme.
2463
- *
2464
- * Client metadata URI fields (e.g. logo_uri, client_uri, policy_uri, tos_uri,
2465
- * jwks_uri) are frequently rendered into HTML attributes such as `<a href>` or
2466
- * `<img src>` on consent screens. Permitting non-http(s) schemes such as
2467
- * `javascript:` or `data:` would allow script execution in that context, so we
2468
- * require an absolute http: or https: URL here, matching how redirect URIs are
2469
- * already restricted.
2470
- *
2471
- * @param field - The field to validate
2472
- * @param fieldName - Name of the field for error messages
2473
- * @returns The validated URI string or undefined
2474
- * @throws Error if the field is not a string or is not an absolute http(s) URL
2475
- */
2476
- static validateOptionalUriField(field, fieldName) {
2477
- const value = OAuthProviderImpl.validateStringField(field, fieldName);
2478
- if (value === void 0) return void 0;
2479
- let parsed;
2480
- try {
2481
- parsed = new URL(value);
2482
- } catch {
2483
- throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
2484
- }
2485
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
2486
- return value;
2487
- }
2488
- static {
2489
- this.I18N_FIELDS = {
2490
- client_name: "string",
2491
- client_uri: "uri",
2492
- logo_uri: "uri",
2493
- tos_uri: "uri",
2494
- policy_uri: "uri"
2495
- };
2496
- }
2497
- /**
2498
- * Extracts RFC 7591 §2.2 internationalized metadata variants from a raw
2499
- * registration payload.
2500
- *
2501
- * Localized variants are expressed by appending a `#<BCP 47 language tag>`
2502
- * suffix to a metadata member name (e.g. `client_name#ja`, `tos_uri#fr`).
2503
- * Only the human-readable fields the RFC names are considered; each value is
2504
- * validated with the same rules as its canonical field (URI fields must be
2505
- * absolute http(s) URLs). The raw `field#tag` keys are preserved verbatim so
2506
- * that consumers can do their own locale matching.
2507
- *
2508
- * @param raw - The parsed client metadata object
2509
- * @returns A map of `field#tag` to validated value, or undefined if none present
2510
- * @throws Error if a localized value fails its field's validation
2511
- */
2512
- static extractI18nFields(raw) {
2513
- const result = {};
2514
- for (const key of Object.keys(raw)) {
2515
- const hashIndex = key.indexOf("#");
2516
- if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
2517
- const baseField = key.slice(0, hashIndex);
2518
- const kind = OAuthProviderImpl.I18N_FIELDS[baseField];
2519
- if (!kind) continue;
2520
- const validated = kind === "uri" ? OAuthProviderImpl.validateOptionalUriField(raw[key], key) : OAuthProviderImpl.validateStringField(raw[key], key);
2521
- if (validated !== void 0) result[key] = validated;
2522
- }
2523
- return Object.keys(result).length > 0 ? result : void 0;
2524
- }
2525
- /**
2526
- * Validates that a field is a string array or undefined
2527
- * @param arr - The array to validate
2528
- * @param fieldName - Name of the field for error messages
2529
- * @returns The validated string array or undefined
2530
- * @throws Error if field is not a string array or undefined
2531
- */
2532
- static validateStringArray(arr, fieldName) {
2533
- if (arr === void 0) return void 0;
2534
- if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
2535
- if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
2536
- return arr;
2537
- }
2538
- /**
2539
- * Fetches and validates a Client ID Metadata Document from the given URL
2540
- * Per the MCP spec, the client_id in the document must match the URL exactly
2541
- *
2542
- * Uses Cloudflare HTTP cache for caching (via cacheEverything option).
2543
- * Response size is limited to 5KB per IETF spec.
2544
- *
2545
- * @param metadataUrl - The HTTPS URL to fetch metadata from
2546
- * @returns The client information
2547
- * @throws Error if fetch fails or validation fails
2548
- */
2549
- async fetchClientMetadataDocument(metadataUrl) {
2550
- const abortController = new AbortController();
2551
- const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
2552
- try {
2553
- const response = await fetch(metadataUrl, {
2554
- headers: { Accept: "application/json" },
2555
- signal: abortController.signal,
2556
- cf: { cacheEverything: true }
2557
- });
2558
- clearTimeout(timeoutId);
2559
- if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
2560
- const contentLength = response.headers.get("content-length");
2561
- 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})`);
2562
- const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
2563
- const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
2564
- const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
2565
- const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
2566
- const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2567
- const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
2568
- const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
2569
- if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
2570
- if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
2571
- if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
2572
- 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(", ")}`);
2573
- const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2574
- const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
2575
- const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
2576
- validateClientCapabilities(this.serverCapabilities, {
2577
- tokenEndpointAuthMethod: effectiveAuthMethod,
2578
- grantTypes,
2579
- responseTypes
2580
- });
2581
- return {
2582
- clientId,
2583
- redirectUris,
2584
- clientName,
2585
- clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
2586
- logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
2587
- policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
2588
- tosUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.tos_uri, "tos_uri"),
2589
- jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
2590
- i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
2591
- contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
2592
- grantTypes,
2593
- responseTypes,
2594
- tokenEndpointAuthMethod: effectiveAuthMethod
2595
- };
2596
- } finally {
2597
- clearTimeout(timeoutId);
2598
- }
2599
- }
2600
- /**
2601
- * Reads JSON from a response with a size limit to prevent DoS attacks.
2602
- * Streams the response body and aborts if it exceeds the limit.
2603
- *
2604
- * @param response - The fetch response
2605
- * @param maxBytes - Maximum allowed size in bytes
2606
- * @returns Parsed JSON object
2607
- * @throws Error if response body is null, size exceeded, or JSON parse failed
2608
- */
2609
- async readJsonWithSizeLimit(response, maxBytes) {
2610
- const reader = response.body?.getReader();
2611
- if (!reader) throw new Error("Response body is null");
2612
- const chunks = [];
2613
- let totalSize = 0;
2614
- while (true) {
2615
- const { done, value } = await reader.read();
2616
- if (done) break;
2617
- if (value) {
2618
- totalSize += value.length;
2619
- if (totalSize > maxBytes) {
2620
- await reader.cancel();
2621
- throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
2622
- }
2623
- chunks.push(value);
2624
- }
2625
- }
2626
- const allChunks = new Uint8Array(totalSize);
2627
- let position = 0;
2628
- for (const chunk of chunks) {
2629
- allChunks.set(chunk, position);
2630
- position += chunk.length;
2631
- }
2632
- const text = new TextDecoder().decode(allChunks);
2633
- return JSON.parse(text);
2904
+ return isClientIdMetadataDocumentUrl(clientId);
2634
2905
  }
2635
2906
  /**
2636
2907
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
@@ -2885,6 +3156,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
2885
3156
  function parseResourceParameter(value) {
2886
3157
  if (!value) return;
2887
3158
  const uris = Array.isArray(value) ? value : [value];
3159
+ if (uris.length === 0) return;
2888
3160
  for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
2889
3161
  return value;
2890
3162
  }
@@ -2968,34 +3240,6 @@ async function generateTokenId(token) {
2968
3240
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
2969
3241
  }
2970
3242
  /**
2971
- * Validates that a redirect URI does not use a dangerous pseudo-scheme.
2972
- * Normalizes the URI by trimming whitespace and checking the scheme in a
2973
- * case-insensitive manner to prevent bypass attacks.
2974
- * Per RFC 3986, control characters are explicitly disallowed in URIs and
2975
- * will cause rejection rather than silent removal.
2976
- * @param redirectUri - The redirect URI to validate
2977
- * @throws Error if the URI uses a blacklisted scheme or contains control characters
2978
- */
2979
- function validateRedirectUriScheme(redirectUri) {
2980
- const dangerousSchemes = [
2981
- "javascript:",
2982
- "data:",
2983
- "vbscript:",
2984
- "file:",
2985
- "mailto:",
2986
- "blob:"
2987
- ];
2988
- const normalized = redirectUri.trim();
2989
- for (let i = 0; i < normalized.length; i++) {
2990
- const code = normalized.charCodeAt(i);
2991
- if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
2992
- }
2993
- const colonIndex = normalized.indexOf(":");
2994
- if (colonIndex === -1) throw new Error("Invalid redirect URI");
2995
- const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
2996
- for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
2997
- }
2998
- /**
2999
3243
  * Checks if a URI is a loopback redirect URI (127.0.0.0/8, ::1, or localhost).
3000
3244
  * Per RFC 8252 Section 7.3, loopback IPs get special port handling. This library
3001
3245
  * applies the same port flexibility to localhost for native apps (e.g., Claude Code).
@@ -3258,11 +3502,14 @@ var OAuthHelpersImpl = class {
3258
3502
  const withRedirect = (error) => {
3259
3503
  throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3260
3504
  };
3505
+ const resourceWasProvided = resourceParam !== void 0;
3261
3506
  let resource = parseResourceParameter(resourceParam);
3262
- if (resourceParam && !resource) withRedirect(new AuthorizationError("invalid_request", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3507
+ if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3263
3508
  const configuredResource = this.provider.options.resourceMetadata?.resource;
3264
- if (configuredResource && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_request", { description: `The resource parameter must exactly match ${configuredResource}` }));
3265
- resource ??= url.origin;
3509
+ if (configuredResource) {
3510
+ if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
3511
+ resource = configuredResource;
3512
+ }
3266
3513
  try {
3267
3514
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
3268
3515
  validateAuthorizationPkce(this.provider.serverCapabilities, {
@@ -3298,7 +3545,8 @@ var OAuthHelpersImpl = class {
3298
3545
  * validating the metadata document fails.
3299
3546
  */
3300
3547
  async lookupClient(clientId) {
3301
- return await this.provider.getClient(this.env, clientId);
3548
+ const client = await this.provider.getClient(this.env, clientId);
3549
+ return client ? toPublicClientInfo(client) : null;
3302
3550
  }
3303
3551
  /**
3304
3552
  * Completes an authorization request by creating a grant and either:
@@ -3316,8 +3564,11 @@ var OAuthHelpersImpl = class {
3316
3564
  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.");
3317
3565
  validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
3318
3566
  const configuredResource = this.provider.options.resourceMetadata?.resource;
3319
- if (configuredResource && !isExactResource(options.request.resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3320
- const effectiveResource = options.request.resource ?? options.request.issuer;
3567
+ const resourceWasProvided = options.request.resource !== void 0;
3568
+ const parsedResource = parseResourceParameter(options.request.resource);
3569
+ if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
3570
+ if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
3571
+ const effectiveResource = configuredResource ?? parsedResource;
3321
3572
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3322
3573
  let grantsToRevoke = [];
3323
3574
  if (options.revokeExistingGrants !== false) {
@@ -3423,6 +3674,7 @@ var OAuthHelpersImpl = class {
3423
3674
  */
3424
3675
  async createClient(clientInfo) {
3425
3676
  const clientId = generateRandomString(16);
3677
+ const authMethodWasExplicit = clientInfo.tokenEndpointAuthMethod !== void 0;
3426
3678
  const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
3427
3679
  const isPublicClient = tokenEndpointAuthMethod === "none";
3428
3680
  const newClient = {
@@ -3443,7 +3695,8 @@ var OAuthHelpersImpl = class {
3443
3695
  ],
3444
3696
  responseTypes: clientInfo.responseTypes || ["code"],
3445
3697
  registrationDate: Math.floor(Date.now() / 1e3),
3446
- tokenEndpointAuthMethod
3698
+ tokenEndpointAuthMethod,
3699
+ ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
3447
3700
  };
3448
3701
  for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
3449
3702
  let clientSecret;
@@ -3452,7 +3705,7 @@ var OAuthHelpersImpl = class {
3452
3705
  newClient.clientSecret = await hashSecret(clientSecret);
3453
3706
  }
3454
3707
  await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
3455
- const clientResponse = { ...newClient };
3708
+ const clientResponse = toPublicClientInfo(newClient);
3456
3709
  if (!isPublicClient && clientSecret) clientResponse.clientSecret = clientSecret;
3457
3710
  return clientResponse;
3458
3711
  }
@@ -3470,7 +3723,7 @@ var OAuthHelpersImpl = class {
3470
3723
  const promises = response.keys.map(async (key) => {
3471
3724
  const clientId = key.name.substring(7);
3472
3725
  const client = await this.provider.getClient(this.env, clientId);
3473
- if (client) clients.push(client);
3726
+ if (client) clients.push(toPublicClientInfo(client));
3474
3727
  });
3475
3728
  await Promise.all(promises);
3476
3729
  return {
@@ -3487,7 +3740,8 @@ var OAuthHelpersImpl = class {
3487
3740
  async updateClient(clientId, updates) {
3488
3741
  const client = await this.provider.getClient(this.env, clientId);
3489
3742
  if (!client) return null;
3490
- let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
3743
+ const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
3744
+ const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
3491
3745
  const isPublicClient = authMethod === "none";
3492
3746
  let secretToStore = client.clientSecret;
3493
3747
  let originalSecret = void 0;
@@ -3500,14 +3754,15 @@ var OAuthHelpersImpl = class {
3500
3754
  ...client,
3501
3755
  ...updates,
3502
3756
  clientId: client.clientId,
3503
- tokenEndpointAuthMethod: authMethod
3757
+ tokenEndpointAuthMethod: authMethod,
3758
+ authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
3504
3759
  };
3505
3760
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
3506
3761
  else delete updatedClient.clientSecret;
3507
3762
  const clientKvOptions = {};
3508
3763
  if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
3509
3764
  await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
3510
- const response = { ...updatedClient };
3765
+ const response = toPublicClientInfo(updatedClient);
3511
3766
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
3512
3767
  return response;
3513
3768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",