@cloudflare/workers-oauth-provider 0.10.1 → 0.10.3

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
@@ -248,6 +248,8 @@ A typical flow has three steps:
248
248
 
249
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.
250
250
 
251
+ For Client ID Metadata Document clients, whose client_id is the metadata URL shared by every installation, default revocation is additionally scoped to grants created from the same redirect URI, so one installation's re-authorization does not revoke another's. Grants created before the redirect URI was recorded are never auto-revoked by CIMD clients.
252
+
251
253
  For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used during that scan. It defaults to `50` and is capped at KV's maximum page size of `1000`.
252
254
 
253
255
  ### Authorization response issuer
@@ -294,18 +296,21 @@ new OAuthProvider({
294
296
  }
295
297
  ```
296
298
 
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.
299
+ 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
300
 
299
- CIMD validation includes:
301
+ 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
302
 
301
- - HTTPS URL with a non-root path.
303
+ - An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
302
304
  - A document `client_id` exactly matching its URL.
303
- - Non-empty `client_name` and `redirect_uris` fields.
305
+ - Non-empty `client_name` and `redirect_uris` fields, as MCP requires, with unsafe redirect schemes rejected at ingestion.
304
306
  - 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.
307
+ - A 5 KB response size limit and a 10 second timeout covering both headers and body.
308
+ - Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
309
+ - No embedded client secrets or private JWK material.
310
+
311
+ 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.
307
312
 
308
- CIMD currently supports only `token_endpoint_auth_method: "none"`.
313
+ 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
314
 
310
315
  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
316
 
@@ -319,9 +324,9 @@ clientRegistrationEndpoint: '/oauth/register';
319
324
 
320
325
  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
326
 
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"]`.
327
+ 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"]`.
323
328
 
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.
329
+ 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.
325
330
 
326
331
  Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
327
332
 
@@ -465,6 +470,7 @@ The package implements or supports the relevant portions of:
465
470
  - [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
466
471
  - [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
467
472
  - [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
473
+ - [OpenID Connect RP Metadata Choices 1.0](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html)
468
474
  - [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
469
475
 
470
476
  ## Development
@@ -1103,6 +1103,13 @@ interface Grant {
1103
1103
  * Indicates the protected resource(s) for which access is requested
1104
1104
  */
1105
1105
  resource?: string | string[];
1106
+ /**
1107
+ * The exact redirect URI used in the authorization request that created this grant
1108
+ * Recorded so that default grant revocation can be scoped to a single installation
1109
+ * of a CIMD client (whose client_id is shared across all installations). Absent on
1110
+ * grants created before this field was introduced.
1111
+ */
1112
+ redirectUri?: string;
1106
1113
  }
1107
1114
  /**
1108
1115
  * OAuth 2.0 Token Response
@@ -1314,6 +1321,13 @@ interface GrantSummary {
1314
1321
  * Unix timestamp when the grant expires (if TTL is configured)
1315
1322
  */
1316
1323
  expiresAt?: number;
1324
+ /**
1325
+ * The exact redirect URI used in the authorization request that created this grant
1326
+ * Recorded so that default grant revocation can be scoped to a single installation
1327
+ * of a CIMD client (whose client_id is shared across all installations). Absent on
1328
+ * grants created before this field was introduced.
1329
+ */
1330
+ redirectUri?: string;
1317
1331
  }
1318
1332
  /**
1319
1333
  * OAuth 2.0 Provider implementation for Cloudflare Workers
@@ -53,6 +53,49 @@ 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
+ }
56
99
  /**
57
100
  * Selects the capabilities from a Client ID Metadata Document that this
58
101
  * authorization server supports. CIMD documents may advertise extension
@@ -67,7 +110,7 @@ function negotiateCimdClientCapabilities(server, client) {
67
110
  const effective = {
68
111
  grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
69
112
  responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
70
- tokenEndpointAuthMethod: client.tokenEndpointAuthMethod
113
+ tokenEndpointAuthMethod: negotiateCimdTokenEndpointAuthMethod(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported)
71
114
  };
72
115
  validateClientCapabilities(server, effective);
73
116
  return effective;
@@ -107,6 +150,403 @@ function isValidOAuthScopeToken(scopeToken) {
107
150
  return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
108
151
  }
109
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
+
110
550
  //#endregion
111
551
  //#region src/ema/constants.ts
112
552
  /**
@@ -876,7 +1316,7 @@ function getOAuthApi(options, env) {
876
1316
  * annotation, and does not actually prevent the method from being called from outside the class,
877
1317
  * including over RPC.
878
1318
  */
879
- var OAuthProviderImpl = class OAuthProviderImpl {
1319
+ var OAuthProviderImpl = class {
880
1320
  /**
881
1321
  * Creates a new OAuth provider instance
882
1322
  * @param options - Configuration options for the provider
@@ -2101,37 +2541,29 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2101
2541
  statusCode: 413
2102
2542
  });
2103
2543
  const callbackRequest = request.clone();
2104
- let clientMetadata;
2544
+ let parsedJson;
2105
2545
  try {
2106
2546
  const text = await request.text();
2107
2547
  if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
2108
2548
  description: "Request payload too large, must be under 1 MiB",
2109
2549
  statusCode: 413
2110
2550
  });
2111
- clientMetadata = JSON.parse(text);
2112
- } catch (error) {
2551
+ parsedJson = JSON.parse(text);
2552
+ } catch {
2113
2553
  return this.createErrorResponse("invalid_request", {
2114
2554
  description: "Invalid JSON payload",
2115
2555
  statusCode: 400
2116
2556
  });
2117
2557
  }
2118
- let authMethodWasExplicit;
2119
- let authMethod;
2120
- let grantTypes;
2121
- let responseTypes;
2558
+ let clientMetadata;
2559
+ let metadata;
2122
2560
  try {
2123
- authMethodWasExplicit = clientMetadata.token_endpoint_auth_method !== void 0;
2124
- authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
2125
- grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2126
- responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
2127
- validateClientCapabilities(this.serverCapabilities, {
2128
- tokenEndpointAuthMethod: authMethod,
2129
- grantTypes,
2130
- responseTypes
2131
- });
2561
+ clientMetadata = requireJsonObject(parsedJson);
2562
+ metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
2132
2563
  } catch (error) {
2133
2564
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
2134
2565
  }
2566
+ const authMethod = metadata.tokenEndpointAuthMethod;
2135
2567
  const isPublicClient = authMethod === "none";
2136
2568
  if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
2137
2569
  const clientId = generateRandomString(16);
@@ -2141,32 +2573,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2141
2573
  clientSecret = generateRandomString(32);
2142
2574
  hashedSecret = await hashSecret(clientSecret);
2143
2575
  }
2144
- let clientInfo;
2145
- try {
2146
- const redirectUris = OAuthProviderImpl.validateStringArray(clientMetadata.redirect_uris);
2147
- if (!redirectUris || redirectUris.length === 0) throw new Error("At least one redirect URI is required");
2148
- for (const uri of redirectUris) validateRedirectUriScheme(uri);
2149
- clientInfo = {
2150
- clientId,
2151
- redirectUris,
2152
- clientName: OAuthProviderImpl.validateStringField(clientMetadata.client_name, "client_name"),
2153
- logoUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.logo_uri, "logo_uri"),
2154
- clientUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.client_uri, "client_uri"),
2155
- policyUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.policy_uri, "policy_uri"),
2156
- tosUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.tos_uri, "tos_uri"),
2157
- jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
2158
- i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
2159
- contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
2160
- grantTypes,
2161
- responseTypes,
2162
- registrationDate: Math.floor(Date.now() / 1e3),
2163
- tokenEndpointAuthMethod: authMethod,
2164
- ...authMethodWasExplicit ? { authMethodExplicit: true } : {}
2165
- };
2166
- if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
2167
- } catch (error) {
2168
- return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
2169
- }
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
+ };
2170
2594
  if (this.options.clientRegistrationCallback) {
2171
2595
  let callbackResult;
2172
2596
  try {
@@ -2187,7 +2611,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2187
2611
  }
2188
2612
  const clientKvOptions = {};
2189
2613
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
2190
- await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
2614
+ await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
2191
2615
  const response = {
2192
2616
  client_id: clientInfo.clientId,
2193
2617
  redirect_uris: clientInfo.redirectUris,
@@ -2382,7 +2806,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2382
2806
  }
2383
2807
  if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
2384
2808
  try {
2385
- return await this.fetchClientMetadataDocument(clientId);
2809
+ return await fetchClientIdMetadataDocument(clientId, this.serverCapabilities);
2386
2810
  } catch (error) {
2387
2811
  console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
2388
2812
  throw new CimdFetchError(clientId, error);
@@ -2477,207 +2901,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2477
2901
  * Not private because OAuthHelpersImpl needs access for purgeExpiredData.
2478
2902
  */
2479
2903
  isClientMetadataUrl(clientId) {
2480
- try {
2481
- const url = new URL(clientId);
2482
- return url.protocol === "https:" && url.pathname !== "/";
2483
- } catch {
2484
- return false;
2485
- }
2486
- }
2487
- static {
2488
- this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
2489
- }
2490
- static {
2491
- this.CIMD_FETCH_TIMEOUT_MS = 1e4;
2492
- }
2493
- static {
2494
- this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
2495
- }
2496
- /**
2497
- * Validates that a field is a string or undefined
2498
- * @param field - The field value to validate
2499
- * @param fieldName - Name of the field for error messages
2500
- * @returns The validated string or undefined
2501
- * @throws Error if field is not a string or undefined
2502
- */
2503
- static validateStringField(field, fieldName) {
2504
- if (field === void 0) return void 0;
2505
- if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
2506
- return field;
2507
- }
2508
- /**
2509
- * Validates that a field is an optional URI string using a safe scheme.
2510
- *
2511
- * Client metadata URI fields (e.g. logo_uri, client_uri, policy_uri, tos_uri,
2512
- * jwks_uri) are frequently rendered into HTML attributes such as `<a href>` or
2513
- * `<img src>` on consent screens. Permitting non-http(s) schemes such as
2514
- * `javascript:` or `data:` would allow script execution in that context, so we
2515
- * require an absolute http: or https: URL here, matching how redirect URIs are
2516
- * already restricted.
2517
- *
2518
- * @param field - The field to validate
2519
- * @param fieldName - Name of the field for error messages
2520
- * @returns The validated URI string or undefined
2521
- * @throws Error if the field is not a string or is not an absolute http(s) URL
2522
- */
2523
- static validateOptionalUriField(field, fieldName) {
2524
- const value = OAuthProviderImpl.validateStringField(field, fieldName);
2525
- if (value === void 0) return void 0;
2526
- let parsed;
2527
- try {
2528
- parsed = new URL(value);
2529
- } catch {
2530
- throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
2531
- }
2532
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
2533
- return value;
2534
- }
2535
- static {
2536
- this.I18N_FIELDS = {
2537
- client_name: "string",
2538
- client_uri: "uri",
2539
- logo_uri: "uri",
2540
- tos_uri: "uri",
2541
- policy_uri: "uri"
2542
- };
2543
- }
2544
- /**
2545
- * Extracts RFC 7591 §2.2 internationalized metadata variants from a raw
2546
- * registration payload.
2547
- *
2548
- * Localized variants are expressed by appending a `#<BCP 47 language tag>`
2549
- * suffix to a metadata member name (e.g. `client_name#ja`, `tos_uri#fr`).
2550
- * Only the human-readable fields the RFC names are considered; each value is
2551
- * validated with the same rules as its canonical field (URI fields must be
2552
- * absolute http(s) URLs). The raw `field#tag` keys are preserved verbatim so
2553
- * that consumers can do their own locale matching.
2554
- *
2555
- * @param raw - The parsed client metadata object
2556
- * @returns A map of `field#tag` to validated value, or undefined if none present
2557
- * @throws Error if a localized value fails its field's validation
2558
- */
2559
- static extractI18nFields(raw) {
2560
- const result = {};
2561
- for (const key of Object.keys(raw)) {
2562
- const hashIndex = key.indexOf("#");
2563
- if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
2564
- const baseField = key.slice(0, hashIndex);
2565
- const kind = OAuthProviderImpl.I18N_FIELDS[baseField];
2566
- if (!kind) continue;
2567
- const validated = kind === "uri" ? OAuthProviderImpl.validateOptionalUriField(raw[key], key) : OAuthProviderImpl.validateStringField(raw[key], key);
2568
- if (validated !== void 0) result[key] = validated;
2569
- }
2570
- return Object.keys(result).length > 0 ? result : void 0;
2571
- }
2572
- /**
2573
- * Validates that a field is a string array or undefined
2574
- * @param arr - The array to validate
2575
- * @param fieldName - Name of the field for error messages
2576
- * @returns The validated string array or undefined
2577
- * @throws Error if field is not a string array or undefined
2578
- */
2579
- static validateStringArray(arr, fieldName) {
2580
- if (arr === void 0) return void 0;
2581
- if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
2582
- if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
2583
- return arr;
2584
- }
2585
- /**
2586
- * Fetches and validates a Client ID Metadata Document from the given URL
2587
- * Per the MCP spec, the client_id in the document must match the URL exactly
2588
- *
2589
- * Uses Cloudflare HTTP cache for caching (via cacheEverything option).
2590
- * Response size is limited to 5KB per IETF spec.
2591
- *
2592
- * @param metadataUrl - The HTTPS URL to fetch metadata from
2593
- * @returns The client information
2594
- * @throws Error if fetch fails or validation fails
2595
- */
2596
- async fetchClientMetadataDocument(metadataUrl) {
2597
- const abortController = new AbortController();
2598
- const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
2599
- try {
2600
- const response = await fetch(metadataUrl, {
2601
- headers: { Accept: "application/json" },
2602
- signal: abortController.signal,
2603
- cf: { cacheEverything: true }
2604
- });
2605
- clearTimeout(timeoutId);
2606
- if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
2607
- const contentLength = response.headers.get("content-length");
2608
- 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})`);
2609
- const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
2610
- const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
2611
- const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
2612
- const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
2613
- const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
2614
- const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
2615
- const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
2616
- if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
2617
- if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
2618
- if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
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(", ")}`);
2620
- const advertisedGrantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
2621
- const advertisedResponseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
2622
- const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
2623
- const { grantTypes, responseTypes } = negotiateCimdClientCapabilities(this.serverCapabilities, {
2624
- tokenEndpointAuthMethod: effectiveAuthMethod,
2625
- grantTypes: advertisedGrantTypes,
2626
- responseTypes: advertisedResponseTypes
2627
- });
2628
- return {
2629
- clientId,
2630
- redirectUris,
2631
- clientName,
2632
- clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
2633
- logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
2634
- policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
2635
- tosUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.tos_uri, "tos_uri"),
2636
- jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
2637
- i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
2638
- contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
2639
- grantTypes,
2640
- responseTypes,
2641
- tokenEndpointAuthMethod: effectiveAuthMethod
2642
- };
2643
- } finally {
2644
- clearTimeout(timeoutId);
2645
- }
2646
- }
2647
- /**
2648
- * Reads JSON from a response with a size limit to prevent DoS attacks.
2649
- * Streams the response body and aborts if it exceeds the limit.
2650
- *
2651
- * @param response - The fetch response
2652
- * @param maxBytes - Maximum allowed size in bytes
2653
- * @returns Parsed JSON object
2654
- * @throws Error if response body is null, size exceeded, or JSON parse failed
2655
- */
2656
- async readJsonWithSizeLimit(response, maxBytes) {
2657
- const reader = response.body?.getReader();
2658
- if (!reader) throw new Error("Response body is null");
2659
- const chunks = [];
2660
- let totalSize = 0;
2661
- while (true) {
2662
- const { done, value } = await reader.read();
2663
- if (done) break;
2664
- if (value) {
2665
- totalSize += value.length;
2666
- if (totalSize > maxBytes) {
2667
- await reader.cancel();
2668
- throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
2669
- }
2670
- chunks.push(value);
2671
- }
2672
- }
2673
- const allChunks = new Uint8Array(totalSize);
2674
- let position = 0;
2675
- for (const chunk of chunks) {
2676
- allChunks.set(chunk, position);
2677
- position += chunk.length;
2678
- }
2679
- const text = new TextDecoder().decode(allChunks);
2680
- return JSON.parse(text);
2904
+ return isClientIdMetadataDocumentUrl(clientId);
2681
2905
  }
2682
2906
  /**
2683
2907
  * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
@@ -3016,34 +3240,6 @@ async function generateTokenId(token) {
3016
3240
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
3017
3241
  }
3018
3242
  /**
3019
- * Validates that a redirect URI does not use a dangerous pseudo-scheme.
3020
- * Normalizes the URI by trimming whitespace and checking the scheme in a
3021
- * case-insensitive manner to prevent bypass attacks.
3022
- * Per RFC 3986, control characters are explicitly disallowed in URIs and
3023
- * will cause rejection rather than silent removal.
3024
- * @param redirectUri - The redirect URI to validate
3025
- * @throws Error if the URI uses a blacklisted scheme or contains control characters
3026
- */
3027
- function validateRedirectUriScheme(redirectUri) {
3028
- const dangerousSchemes = [
3029
- "javascript:",
3030
- "data:",
3031
- "vbscript:",
3032
- "file:",
3033
- "mailto:",
3034
- "blob:"
3035
- ];
3036
- const normalized = redirectUri.trim();
3037
- for (let i = 0; i < normalized.length; i++) {
3038
- const code = normalized.charCodeAt(i);
3039
- if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
3040
- }
3041
- const colonIndex = normalized.indexOf(":");
3042
- if (colonIndex === -1) throw new Error("Invalid redirect URI");
3043
- const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
3044
- for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
3045
- }
3046
- /**
3047
3243
  * Checks if a URI is a loopback redirect URI (127.0.0.0/8, ::1, or localhost).
3048
3244
  * Per RFC 8252 Section 7.3, loopback IPs get special port handling. This library
3049
3245
  * applies the same port flexibility to localhost for native apps (e.g., Claude Code).
@@ -3376,6 +3572,7 @@ var OAuthHelpersImpl = class {
3376
3572
  validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
3377
3573
  let grantsToRevoke = [];
3378
3574
  if (options.revokeExistingGrants !== false) {
3575
+ const isCimdClient = this.provider.isClientMetadataUrl(clientId);
3379
3576
  const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
3380
3577
  let cursor;
3381
3578
  do {
@@ -3383,7 +3580,7 @@ var OAuthHelpersImpl = class {
3383
3580
  cursor,
3384
3581
  limit: batchSize
3385
3582
  });
3386
- for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
3583
+ for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
3387
3584
  cursor = page.cursor;
3388
3585
  } while (cursor);
3389
3586
  }
@@ -3407,7 +3604,8 @@ var OAuthHelpersImpl = class {
3407
3604
  metadata: options.metadata,
3408
3605
  encryptedProps: encryptedData,
3409
3606
  createdAt: now,
3410
- resource: effectiveResource
3607
+ resource: effectiveResource,
3608
+ redirectUri: options.request.redirectUri
3411
3609
  };
3412
3610
  const grantKey = `grant:${options.userId}:${grantId}`;
3413
3611
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -3457,7 +3655,8 @@ var OAuthHelpersImpl = class {
3457
3655
  authCodeWrappedKey,
3458
3656
  codeChallenge: options.request.codeChallenge,
3459
3657
  codeChallengeMethod: options.request.codeChallengeMethod,
3460
- resource: effectiveResource
3658
+ resource: effectiveResource,
3659
+ redirectUri: options.request.redirectUri
3461
3660
  };
3462
3661
  const grantKey = `grant:${options.userId}:${grantId}`;
3463
3662
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
@@ -3614,7 +3813,8 @@ var OAuthHelpersImpl = class {
3614
3813
  scope: grantData.scope,
3615
3814
  metadata: grantData.metadata,
3616
3815
  createdAt: grantData.createdAt,
3617
- expiresAt: grantData.expiresAt
3816
+ expiresAt: grantData.expiresAt,
3817
+ redirectUri: grantData.redirectUri
3618
3818
  };
3619
3819
  grantSummaries.push(summary);
3620
3820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",