@cloudflare/workers-oauth-provider 1.2.1 → 1.2.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.
@@ -107,15 +107,27 @@ function negotiateTokenEndpointAuthMethod(options) {
107
107
  const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
108
108
  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)"}`);
109
109
  }
110
- function negotiateCimdTokenEndpointAuthMethod(server, preferredMethod, supportedMethods) {
110
+ /** Asymmetric method a CIMD client may use when its document supplies usable keys. */
111
+ const PRIVATE_KEY_JWT = "private_key_jwt";
112
+ /**
113
+ * Every token endpoint authentication method a CIMD client may use with this server, in the
114
+ * server's order of preference.
115
+ *
116
+ * A metadata document has no registration response, so the client learns nothing about which
117
+ * method this server picked. It chooses one per request from this server's metadata. So every
118
+ * method both sides support stays usable: a document that offers `none` and `private_key_jwt`
119
+ * (ChatGPT's does) works with either, and a document that offers only `private_key_jwt` must
120
+ * use it.
121
+ */
122
+ function negotiateCimdTokenEndpointAuthMethods(server, preferredMethod, supportedMethods, privateKeyJwt) {
111
123
  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}`);
112
- return negotiateTokenEndpointAuthMethod({
113
- acceptedMethods: server.tokenEndpointAuthMethods.filter((method) => !SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(method)),
114
- defaultMethod: "none",
115
- preferredMethod,
116
- supportedMethods,
117
- context: "CIMD client"
118
- });
124
+ 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");
125
+ const acceptedMethods = [...server.tokenEndpointAuthMethods.filter((method) => !SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(method)), ...privateKeyJwt ? [PRIVATE_KEY_JWT] : []];
126
+ const advertisedMethods = supportedMethods ?? [preferredMethod ?? "none"];
127
+ const methods = acceptedMethods.filter((method) => advertisedMethods.includes(method));
128
+ if (methods.length > 0) return methods;
129
+ const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
130
+ throw new Error(`CIMD client does not support an accepted token endpoint authentication method. Supported methods: ${acceptedMethods.join(", ")}. Client advertised: ${advertised.length > 0 ? advertised.join(", ") : "(none)"}`);
119
131
  }
120
132
  /**
121
133
  * Negotiates choice-valued authentication metadata while keeping DCR grant and
@@ -146,13 +158,18 @@ function negotiateDynamicClientRegistrationCapabilities(server, client) {
146
158
  * The effective subset is still checked for grant/response consistency, and
147
159
  * token endpoint authentication must have a mutually supported method.
148
160
  */
149
- function negotiateCimdClientCapabilities(server, client) {
161
+ function negotiateCimdClientCapabilities(server, client, options = { privateKeyJwt: false }) {
162
+ const tokenEndpointAuthMethods = negotiateCimdTokenEndpointAuthMethods(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported, options.privateKeyJwt);
150
163
  const effective = {
151
164
  grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
152
165
  responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
153
- tokenEndpointAuthMethod: negotiateCimdTokenEndpointAuthMethod(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported)
166
+ tokenEndpointAuthMethod: tokenEndpointAuthMethods[0],
167
+ tokenEndpointAuthMethods
154
168
  };
155
- validateClientCapabilities(server, effective);
169
+ validateClientCapabilities({
170
+ ...server,
171
+ tokenEndpointAuthMethods: [...server.tokenEndpointAuthMethods, PRIVATE_KEY_JWT]
172
+ }, effective);
156
173
  return effective;
157
174
  }
158
175
  function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
@@ -190,6 +207,10 @@ function isValidOAuthScopeToken(scopeToken) {
190
207
  return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
191
208
  }
192
209
 
210
+ //#endregion
211
+ //#region package.json
212
+ var version = "1.2.2";
213
+
193
214
  //#endregion
194
215
  //#region src/oauth-http.ts
195
216
  /**
@@ -197,6 +218,13 @@ function isValidOAuthScopeToken(scopeToken) {
197
218
  * and the resource-server host (`OAuthResourceServer`), so the two can't drift apart.
198
219
  */
199
220
  /**
221
+ * `User-Agent` sent on the library's own outbound fetches (EMA JWKS, Client ID Metadata Documents).
222
+ * Workers' `fetch` sends none by default, and common WAF rule sets (AWS WAF's `NoUserAgent_HEADER`,
223
+ * for one) block requests without it, so an IdP or client behind one would answer `403`. The RFC 9110
224
+ * product token carries the release, and the comment points an operator at who is calling.
225
+ */
226
+ const OUTBOUND_USER_AGENT = `workers-oauth-provider/${version} (+https://github.com/cloudflare/workers-oauth-provider)`;
227
+ /**
200
228
  * Adds CORS headers for a browser client. The request's `Origin` is reflected: bearer tokens are
201
229
  * not ambient credentials, so any origin may present one it holds. Browser OAuth and MCP clients
202
230
  * need `WWW-Authenticate` (discovery, step-up) and `Retry-After` (backoff) exposed. Headers the
@@ -307,956 +335,1200 @@ var OAuthError = class extends Error {
307
335
  };
308
336
 
309
337
  //#endregion
310
- //#region src/oauth-resource.ts
338
+ //#region src/ema/constants.ts
311
339
  /**
312
- * Loopback hosts where a plain `http` scheme is accepted for local development.
313
- * Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
340
+ * Constants for MCP Enterprise-Managed Authorization (EMA).
341
+ *
342
+ * Co-located so that anyone touching the EMA code path sees every magic
343
+ * number in one place. Public-facing defaults can be overridden via
344
+ * `EmaOptions`.
314
345
  */
315
- function isLoopbackHostname(hostname) {
316
- const host = hostname.toLowerCase();
317
- if (host === "localhost" || host === "::1" || host === "[::1]") return true;
318
- const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
319
- return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
320
- }
346
+ /** JWT `typ` header value required for ID-JAG assertions (RFC 8725 §3.11). */
347
+ const EMA_ID_JAG_JWT_TYPE = "oauth-id-jag+jwt";
321
348
  /**
322
- * Whether a canonical resource, issuer, or endpoint URL uses an accepted
323
- * scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
324
- * http://localhost. Workers are always served over `https`, so there is no
325
- * deployment in which cleartext OAuth traffic to a remote host is correct,
326
- * and no option exists to allow it.
349
+ * Grant-profile URN advertised in `authorization_grant_profiles_supported`
350
+ * when EMA is configured (MCP Enterprise-Managed Authorization spec).
327
351
  */
328
- function hasAcceptedCanonicalScheme(url) {
329
- return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
330
- }
331
- /** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
332
- function validateResourceUri(uri) {
333
- if (!uri || typeof uri !== "string") return false;
334
- if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
335
- try {
336
- const parsed = new URL(uri);
337
- return parsed.protocol === "http:" || parsed.protocol === "https:";
338
- } catch {
339
- return false;
340
- }
341
- }
352
+ const EMA_ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";
353
+ /** Maximum compact JWT assertion size accepted at the token endpoint. */
354
+ const EMA_MAX_JWT_BYTES = 16 * 1024;
355
+ /** Maximum JWKS response size accepted from a trusted enterprise IdP. */
356
+ const EMA_JWKS_MAX_SIZE_BYTES = 64 * 1024;
357
+ /** Request timeout for JWKS fetches. */
358
+ const EMA_JWKS_FETCH_TIMEOUT_MS = 1e4;
359
+ /** Default JWKS cache TTL. */
360
+ const EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS = 300;
361
+ /** Default allowed clock skew for ID-JAG time claim validation. */
362
+ const EMA_DEFAULT_CLOCK_SKEW_SECONDS = 60;
363
+ /** Default maximum accepted ID-JAG lifetime. */
364
+ const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
342
365
  /**
343
- * Whether a requested resource identifies a granted or configured resource.
344
- * Only ASCII case in the URI scheme and host is ignored, and an empty path is
345
- * equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
346
- * `https://example.com` through a URL parser as `https://example.com/` still
347
- * names it. Port, path, query, a trailing slash after a path segment, user
348
- * information, and every other byte remain significant.
366
+ * Minimum cool-down between JWKS force-refreshes per issuer.
367
+ * Defends against attackers that send many assertions with random `kid`
368
+ * values to amplify load on the IdP's JWKS endpoint.
349
369
  */
350
- function resourceMatches(requested, granted) {
351
- const foldedRequested = foldResourceSchemeAndHost(requested);
352
- const foldedGranted = foldResourceSchemeAndHost(granted);
353
- if (foldedRequested === void 0 || foldedGranted === void 0) return false;
354
- return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
355
- }
370
+ const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
371
+ /** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
372
+ const EMA_JTI_MIN_TTL_SECONDS = 60;
373
+ /** Default JWT signing algorithm assumed for a trusted issuer. */
374
+ const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
375
+ /** JWT signing algorithms supported by the built-in WebCrypto verifier. */
376
+ const EMA_SUPPORTED_JWT_ALGORITHMS = new Set(["RS256", "ES256"]);
377
+
378
+ //#endregion
379
+ //#region src/ema/result.ts
380
+ const ok = (value) => ({
381
+ ok: true,
382
+ value
383
+ });
384
+ const err = (error) => ({
385
+ ok: false,
386
+ error
387
+ });
356
388
  /**
357
- * Fold only the URI components whose comparison is ASCII case-insensitive: the
358
- * scheme and the host. A configured identifier is canonical when folding it is
359
- * the identity.
389
+ * Map an internal `EmaValidationError` to its public OAuth error code and
390
+ * description. Most validation failures collapse to a single generic message
391
+ * to avoid leaking which check failed to attackers probing the IdP. The
392
+ * exceptions are RFC-prescribed distinct codes (`invalid_target` for
393
+ * RFC 8707 resource issues, `invalid_request` for malformed input).
360
394
  */
361
- function foldResourceSchemeAndHost(resource) {
362
- const schemeSeparator = resource.indexOf("://");
363
- if (schemeSeparator <= 0) return void 0;
364
- const authorityStart = schemeSeparator + 3;
365
- const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
366
- const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
367
- const authority = resource.slice(authorityStart, authorityEnd);
368
- const hostStart = authority.lastIndexOf("@") + 1;
369
- let hostEnd;
370
- if (authority[hostStart] === "[") {
371
- const closingBracket = authority.indexOf("]", hostStart + 1);
372
- if (closingBracket === -1) return void 0;
373
- hostEnd = closingBracket + 1;
374
- } else {
375
- const portSeparator = authority.indexOf(":", hostStart);
376
- hostEnd = portSeparator === -1 ? authority.length : portSeparator;
395
+ function emaErrorToWire(e) {
396
+ switch (e.reason) {
397
+ case "assertion_missing": return {
398
+ code: "invalid_request",
399
+ message: "assertion is required"
400
+ };
401
+ case "invalid_scope_param": return {
402
+ code: "invalid_request",
403
+ message: "Invalid scope parameter format"
404
+ };
405
+ case "resource_invalid":
406
+ case "resource_mismatch": return {
407
+ code: "invalid_target",
408
+ message: "Invalid resource"
409
+ };
410
+ case "mapper_denied":
411
+ case "mapper_threw": return {
412
+ code: "invalid_grant",
413
+ message: "Assertion was not authorized"
414
+ };
415
+ case "invalid_mapped_user": return {
416
+ code: "invalid_grant",
417
+ message: "Invalid mapped user"
418
+ };
419
+ case "invalid_mapped_scope": return {
420
+ code: "invalid_grant",
421
+ message: "Invalid mapped scope"
422
+ };
423
+ case "invalid_mapped_props": return {
424
+ code: "invalid_grant",
425
+ message: "Invalid mapped props"
426
+ };
427
+ case "invalid_mapped_ttl": return {
428
+ code: "invalid_grant",
429
+ message: "Invalid access token TTL"
430
+ };
431
+ case "assertion_expired_after_processing": return {
432
+ code: "invalid_grant",
433
+ message: "Assertion has expired"
434
+ };
435
+ case "assertion_too_large":
436
+ case "assertion_malformed":
437
+ case "invalid_typ":
438
+ case "invalid_alg":
439
+ case "issuer_not_trusted":
440
+ case "no_matching_key":
441
+ case "signature_failed":
442
+ case "jwks_fetch_failed":
443
+ case "invalid_claim":
444
+ case "aud_mismatch":
445
+ case "unsupported_claim":
446
+ case "expired":
447
+ case "iat_in_future":
448
+ case "nbf_in_future":
449
+ case "lifetime_too_long":
450
+ case "replayed":
451
+ case "client_id_mismatch": return {
452
+ code: "invalid_grant",
453
+ message: "Invalid assertion"
454
+ };
377
455
  }
378
- const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
379
- return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
380
- }
381
- /** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
382
- function normalizeEmptyPath(resource) {
383
- const authorityStart = resource.indexOf("://") + 3;
384
- const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
385
- if (pathOffset === -1) return `${resource}/`;
386
- const pathStart = authorityStart + pathOffset;
387
- return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
388
456
  }
457
+
458
+ //#endregion
459
+ //#region src/ema/util.ts
389
460
  /**
390
- * Whether a request carries every query parameter of a canonical resource. A
391
- * query-bearing resource identifier names a more specific resource: a request
392
- * may add parameters of its own but must preserve the resource's.
461
+ * Tiny utility helpers used by EMA adapters.
462
+ *
463
+ * Lives in `src/ema/util.ts` rather than `src/util.ts` to keep the EMA
464
+ * module self-contained — the main `oauth-provider.ts` reaches into here
465
+ * only through the adapters' public interfaces.
393
466
  */
394
- function requestCarriesResourceQuery(requestUrl, resourceUrl) {
395
- for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
396
- return true;
467
+ /** SHA-256 a string and return its hex digest. */
468
+ async function sha256Hex$1(input) {
469
+ const data = new TextEncoder().encode(input);
470
+ const buffer = await crypto.subtle.digest("SHA-256", data);
471
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
397
472
  }
398
473
 
399
474
  //#endregion
400
- //#region src/oauth-client-metadata.ts
401
- const CIMD_MAX_SIZE_BYTES = 5 * 1024;
402
- const CIMD_FETCH_TIMEOUT_MS = 1e4;
403
- const CIMD_CACHE_NAME = "workers-oauth-provider:cimd:v1";
404
- const CIMD_CACHE_MAX_TTL_SECONDS = 10080 * 60;
405
- function requireJsonObject(value) {
406
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Client metadata must be a JSON object");
407
- return value;
408
- }
409
- function optionalString(value, fieldName) {
410
- if (value === void 0) return void 0;
411
- if (typeof value !== "string") throw new Error(`Invalid ${fieldName}: expected string, got ${typeof value}`);
412
- return value;
413
- }
414
- function optionalStringArray(value, fieldName) {
415
- if (value === void 0) return void 0;
416
- if (!Array.isArray(value)) throw new Error(`Invalid ${fieldName}: expected array, got ${typeof value}`);
417
- if (!value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName}: array must contain only strings`);
418
- return [...value];
419
- }
420
- function optionalHttpUri(value, fieldName) {
421
- const uri = optionalString(value, fieldName);
422
- if (uri === void 0) return void 0;
423
- let parsed;
424
- try {
425
- parsed = new URL(uri);
426
- } catch {
427
- throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
428
- }
429
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
430
- return uri;
431
- }
432
- const I18N_FIELDS = {
433
- client_name: "string",
434
- client_uri: "uri",
435
- logo_uri: "uri",
436
- tos_uri: "uri",
437
- policy_uri: "uri"
438
- };
439
- function extractI18nFields(raw) {
440
- const result = {};
441
- for (const key of Object.keys(raw)) {
442
- const hashIndex = key.indexOf("#");
443
- if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
444
- const kind = I18N_FIELDS[key.slice(0, hashIndex)];
445
- if (!kind) continue;
446
- const value = kind === "uri" ? optionalHttpUri(raw[key], key) : optionalString(raw[key], key);
447
- if (value !== void 0) result[key] = value;
448
- }
449
- return Object.keys(result).length > 0 ? result : void 0;
450
- }
451
- function validateChoiceConsistency(preferredName, preferredValue, choicesName, choices) {
452
- if (preferredValue !== void 0 && choices !== void 0 && !choices.includes(preferredValue)) throw new Error(`${preferredName} must be included in ${choicesName}`);
453
- }
454
- function parseOAuthClientMetadata(raw) {
455
- const tokenEndpointAuthMethod = optionalString(raw.token_endpoint_auth_method, "token_endpoint_auth_method");
456
- const tokenEndpointAuthMethodsSupported = optionalStringArray(raw.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
457
- const tokenEndpointAuthSigningAlg = optionalString(raw.token_endpoint_auth_signing_alg, "token_endpoint_auth_signing_alg");
458
- const tokenEndpointAuthSigningAlgValuesSupported = optionalStringArray(raw.token_endpoint_auth_signing_alg_values_supported, "token_endpoint_auth_signing_alg_values_supported");
459
- validateChoiceConsistency("token_endpoint_auth_method", tokenEndpointAuthMethod, "token_endpoint_auth_methods_supported", tokenEndpointAuthMethodsSupported);
460
- validateChoiceConsistency("token_endpoint_auth_signing_alg", tokenEndpointAuthSigningAlg, "token_endpoint_auth_signing_alg_values_supported", tokenEndpointAuthSigningAlgValuesSupported);
461
- return {
462
- clientId: optionalString(raw.client_id, "client_id"),
463
- redirectUris: optionalStringArray(raw.redirect_uris, "redirect_uris"),
464
- clientName: optionalString(raw.client_name, "client_name"),
465
- clientUri: optionalHttpUri(raw.client_uri, "client_uri"),
466
- logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
467
- policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
468
- tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
469
- jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
470
- i18n: extractI18nFields(raw),
471
- contacts: optionalStringArray(raw.contacts, "contacts"),
472
- grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
473
- responseTypes: optionalStringArray(raw.response_types, "response_types"),
474
- tokenEndpointAuthMethod,
475
- tokenEndpointAuthMethodsSupported,
476
- tokenEndpointAuthSigningAlg,
477
- tokenEndpointAuthSigningAlgValuesSupported
478
- };
479
- }
480
- function pickDisplayMetadata(metadata) {
481
- const { clientName, clientUri, logoUri, policyUri, tosUri, jwksUri, i18n, contacts } = metadata;
482
- return {
483
- clientName,
484
- clientUri,
485
- logoUri,
486
- policyUri,
487
- tosUri,
488
- jwksUri,
489
- i18n,
490
- contacts
491
- };
475
+ //#region src/ema/jti.ts
476
+ /**
477
+ * Default `EmaJtiStore`: KV-backed `jti` replay marker.
478
+ *
479
+ * KV is eventually-consistent and does not provide compare-and-set, so two
480
+ * concurrent requests with the same `jti` can both observe "not seen" and
481
+ * succeed — the trade-off accepted here. Surrounding claim checks
482
+ * (signature, `exp`, `nbf`, `aud`, `resource`, client binding) constrain
483
+ * the practical attack window.
484
+ */
485
+ /** Storage key prefix for replay markers. Stable across versions. */
486
+ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
487
+ /**
488
+ * Create the default KV-backed JTI store. KV TTL handles cleanup.
489
+ *
490
+ * @param keyPrefix Storage key prefix, so each kind of assertion keeps its own replay namespace.
491
+ */
492
+ function createKvJtiStore(keyPrefix = EMA_JTI_KV_PREFIX) {
493
+ return { async markUsed({ issuer, jti, exp, now, env }) {
494
+ const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
495
+ const key = `${keyPrefix}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
496
+ if (await env.OAUTH_KV.get(key)) return err({
497
+ reason: "replayed",
498
+ jti
499
+ });
500
+ await env.OAUTH_KV.put(key, "1", { expirationTtl: ttl });
501
+ return ok(void 0);
502
+ } };
492
503
  }
504
+
505
+ //#endregion
506
+ //#region src/ema/jwks.ts
493
507
  /**
494
- * The checks every listed redirect URI passes, whatever the policy: no control characters, a
495
- * scheme, and not a dangerous one (`javascript:`, `data:` and the like). A Client ID Metadata
496
- * Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
497
- * @throws Error when the redirect URI is not safe to store
508
+ * Default `EmaJwksProvider`: fetches IdP signing keys with caching and a
509
+ * force-refresh cool-down. The cool-down defends against attackers that
510
+ * spam random `kid` values to amplify load on the IdP's JWKS endpoint.
511
+ *
512
+ * The cache lives inside the returned provider closure, so each
513
+ * `OAuthProvider` instance has its own cache — no cross-instance bleed.
498
514
  */
499
- function validateRedirectUriSafety(redirectUri) {
500
- const dangerousSchemes = [
501
- "javascript:",
502
- "data:",
503
- "vbscript:",
504
- "file:",
505
- "mailto:",
506
- "blob:"
507
- ];
508
- const normalized = redirectUri.trim();
509
- for (let i = 0; i < normalized.length; i++) {
510
- const code = normalized.charCodeAt(i);
511
- if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
512
- }
513
- const colonIndex = normalized.indexOf(":");
514
- if (colonIndex === -1) throw new Error("Invalid redirect URI");
515
- const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
516
- if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
515
+ /** Create the default JWKS provider — a closure with its own private cache. */
516
+ function createDefaultJwksProvider(opts = {}) {
517
+ const cache = /* @__PURE__ */ new Map();
518
+ const cacheTtl = opts.cacheTtlSeconds ?? EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS;
519
+ return { async fetch(issuer, { forceRefresh, now }) {
520
+ const cached = cache.get(issuer.issuer);
521
+ if (!forceRefresh && cached && cached.expiresAt > now) return ok(cached.jwks);
522
+ if (forceRefresh && cached && cached.nextForceRefreshAllowedAt > now) return ok(cached.jwks);
523
+ const abortController = new AbortController();
524
+ const timeoutId = setTimeout(() => abortController.abort(), EMA_JWKS_FETCH_TIMEOUT_MS);
525
+ try {
526
+ const response = await fetch(issuer.jwksUri, {
527
+ headers: {
528
+ Accept: "application/json",
529
+ "User-Agent": OUTBOUND_USER_AGENT
530
+ },
531
+ signal: abortController.signal,
532
+ cf: { cacheEverything: true }
533
+ });
534
+ if (!response.ok) return err({
535
+ reason: "jwks_fetch_failed",
536
+ status: response.status
537
+ });
538
+ const contentLength = response.headers.get("content-length");
539
+ if (contentLength && parseInt(contentLength, 10) > EMA_JWKS_MAX_SIZE_BYTES) return err({
540
+ reason: "jwks_fetch_failed",
541
+ status: response.status
542
+ });
543
+ const rawJwks = await readJsonWithSizeLimit$1(response, EMA_JWKS_MAX_SIZE_BYTES);
544
+ if (!rawJwks.ok) return err({ reason: "jwks_fetch_failed" });
545
+ if (!Array.isArray(rawJwks.value.keys)) return err({ reason: "jwks_fetch_failed" });
546
+ const jwks = { keys: rawJwks.value.keys };
547
+ cache.delete(issuer.issuer);
548
+ if (opts.maxEntries !== void 0 && cache.size >= opts.maxEntries) {
549
+ const oldest = cache.keys().next();
550
+ if (!oldest.done) cache.delete(oldest.value);
551
+ }
552
+ cache.set(issuer.issuer, {
553
+ jwks,
554
+ expiresAt: now + cacheTtl,
555
+ nextForceRefreshAllowedAt: now + EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
556
+ });
557
+ return ok(jwks);
558
+ } catch {
559
+ return err({ reason: "jwks_fetch_failed" });
560
+ } finally {
561
+ clearTimeout(timeoutId);
562
+ }
563
+ } };
517
564
  }
518
565
  /**
519
- * The checks every redirect URI a client registers passes, whether or not this server would send a
520
- * code to it: the {@link validateRedirectUriSafety} checks, a parseable URI, and no fragment (not
521
- * even an empty `#`) or userinfo.
522
- * @throws Error when the redirect URI can't be registered at all
566
+ * Streaming JSON reader that rejects responses exceeding `maxBytes`.
567
+ * Bounds memory consumption before we attempt to JSON-parse the body.
523
568
  */
524
- function validateListedRedirectUri(redirectUri) {
525
- validateRedirectUriSafety(redirectUri);
526
- const normalized = redirectUri.trim();
527
- let url;
569
+ async function readJsonWithSizeLimit$1(response, maxBytes) {
570
+ if (!response.body) return { ok: false };
571
+ const reader = response.body.getReader();
572
+ const chunks = [];
573
+ let total = 0;
528
574
  try {
529
- url = new URL(normalized);
575
+ while (true) {
576
+ const { done, value } = await reader.read();
577
+ if (done) break;
578
+ total += value.byteLength;
579
+ if (total > maxBytes) {
580
+ reader.cancel();
581
+ return { ok: false };
582
+ }
583
+ chunks.push(value);
584
+ }
585
+ } finally {
586
+ reader.releaseLock();
587
+ }
588
+ const merged = new Uint8Array(total);
589
+ let offset = 0;
590
+ for (const chunk of chunks) {
591
+ merged.set(chunk, offset);
592
+ offset += chunk.byteLength;
593
+ }
594
+ try {
595
+ const parsed = JSON.parse(new TextDecoder().decode(merged));
596
+ if (typeof parsed !== "object" || parsed === null) return { ok: false };
597
+ return {
598
+ ok: true,
599
+ value: parsed
600
+ };
530
601
  } catch {
531
- throw new Error("Invalid redirect URI");
602
+ return { ok: false };
532
603
  }
533
- if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
534
- return url;
535
604
  }
605
+
606
+ //#endregion
607
+ //#region src/ema/parser.ts
536
608
  /**
537
- * Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
538
- * loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
539
- * empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
540
- * `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
541
- * checks always apply.
542
- * @throws Error describing why the redirect URI is not acceptable
609
+ * Pure JWT parsing for ID-JAG assertions.
610
+ *
611
+ * Splits a compact JWS (`<base64url-header>.<base64url-payload>.<base64url-signature>`)
612
+ * into its three parts, decodes header and payload as JSON, and exposes the
613
+ * raw signing input + signature bytes for downstream signature verification.
614
+ *
615
+ * No I/O. No `this`. Returns `Result<ParsedIdJag, EmaValidationError>`.
543
616
  */
544
- function validateRedirectUri(redirectUri, server) {
545
- const url = validateListedRedirectUri(redirectUri);
546
- if (url.protocol === "https:") return;
547
- if (url.protocol === "http:") {
548
- if (isLoopbackHostname(url.hostname)) return;
549
- throw new Error("Redirect URI must use https, or http on a loopback host");
617
+ /**
618
+ * Parse a compact JWS assertion.
619
+ *
620
+ * @param assertion The raw assertion string from the token request body.
621
+ * @param maxBytes Reject assertions whose length exceeds this many bytes.
622
+ * Guards against memory exhaustion before any JSON parsing happens.
623
+ */
624
+ function parseIdJag(assertion, maxBytes) {
625
+ if (typeof assertion !== "string" || assertion.length === 0) return err({ reason: "assertion_missing" });
626
+ if (assertion.length > maxBytes) return err({
627
+ reason: "assertion_too_large",
628
+ size: assertion.length,
629
+ max: maxBytes
630
+ });
631
+ const parts = assertion.split(".");
632
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) return err({ reason: "assertion_malformed" });
633
+ const [encodedHeader, encodedClaims, encodedSignature] = parts;
634
+ let header;
635
+ let rawClaims;
636
+ let signature;
637
+ try {
638
+ header = parseJwtJsonPart(encodedHeader);
639
+ rawClaims = parseJwtJsonPart(encodedClaims);
640
+ signature = base64UrlToBytes(encodedSignature);
641
+ } catch {
642
+ return err({ reason: "assertion_malformed" });
550
643
  }
551
- if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
644
+ const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`);
645
+ return ok({
646
+ header,
647
+ rawClaims,
648
+ signingInput,
649
+ signature
650
+ });
552
651
  }
553
652
  /**
554
- * Validates the redirect URIs a client registers (dynamic registration, `createClient()`,
555
- * `updateClient()`). A client may list callbacks for surfaces this server won't send codes to:
556
- * Cursor lists `cursor://anysphere.cursor-mcp/oauth/callback` next to its https and loopback
557
- * callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
558
- * {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
559
- * policy by `parseAuthRequest()` and `completeAuthorization()`.
560
- * @throws Error when a URI can't be registered, or none of them could ever receive a code
653
+ * Decodes a base64url-encoded string to bytes.
561
654
  */
562
- function validateRegisteredRedirectUris(redirectUris, server) {
563
- if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
564
- for (const redirectUri of redirectUris) validateListedRedirectUri(redirectUri);
565
- let firstRefusal;
566
- for (const redirectUri of redirectUris) try {
567
- validateRedirectUri(redirectUri, server);
568
- return redirectUris;
569
- } catch (error) {
570
- firstRefusal ??= error;
571
- }
572
- throw firstRefusal instanceof Error ? firstRefusal : /* @__PURE__ */ new Error("Invalid redirect URI");
573
- }
574
- function requireValidRedirectUris(redirectUris, validate) {
575
- if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
576
- for (const redirectUri of redirectUris) validate(redirectUri);
577
- return redirectUris;
578
- }
579
- /**
580
- * Resolves a Dynamic Client Registration request into the complete metadata
581
- * shape that can be stored, applying RFC 7591 defaults and server capability
582
- * validation exactly once.
583
- */
584
- function resolveDynamicClientRegistrationMetadata(raw, server) {
585
- const metadata = parseOAuthClientMetadata(raw);
586
- const capabilities = negotiateDynamicClientRegistrationCapabilities(server, {
587
- tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
588
- tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
589
- grantTypes: metadata.grantTypes ?? ["authorization_code"],
590
- responseTypes: metadata.responseTypes ?? ["code"]
591
- });
592
- return {
593
- ...pickDisplayMetadata(metadata),
594
- redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
595
- ...capabilities,
596
- authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
597
- };
655
+ function base64UrlToBytes(base64Url) {
656
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
657
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
658
+ const binaryString = atob(padded);
659
+ const bytes = new Uint8Array(binaryString.length);
660
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
661
+ return bytes;
598
662
  }
599
663
  /**
600
- * Path component of the raw client ID string. Deliberately not
601
- * `new URL().pathname`: WHATWG parsing collapses the `.` and `..` segments
602
- * that CIMD §3 requires rejecting, and normalizes `\` and scheme-relative
603
- * forms this validation must see verbatim.
664
+ * Parses a base64url-encoded JWT JSON part into an object.
604
665
  */
605
- function rawPath(clientId) {
606
- const schemeEnd = clientId.indexOf("://");
607
- if (schemeEnd === -1) return "";
608
- const authorityStart = schemeEnd + 3;
609
- const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
610
- if (authorityEndOffset === -1) return "";
611
- const pathStart = authorityStart + authorityEndOffset;
612
- if (clientId[pathStart] !== "/") return "";
613
- const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
614
- return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
615
- }
616
- function validateClientIdentifierUrl(clientId) {
617
- if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
618
- if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
619
- let parsed;
666
+ function parseJwtJsonPart(encoded) {
620
667
  try {
621
- parsed = new URL(clientId);
668
+ const json = new TextDecoder().decode(base64UrlToBytes(encoded));
669
+ const parsed = JSON.parse(json);
670
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
671
+ return parsed;
622
672
  } catch {
623
- throw new Error("Client Identifier URL must be a valid HTTPS URL");
673
+ throw new Error("Malformed JWT part");
624
674
  }
625
- if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
626
- if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
627
- if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
628
- const path = rawPath(clientId);
629
- if (!path) throw new Error("Client Identifier URL must contain a path component");
630
- for (const segment of path.split("/")) {
631
- let decodedSegment;
632
- try {
633
- decodedSegment = decodeURIComponent(segment);
634
- } catch {
635
- throw new Error("Client Identifier URL contains invalid percent encoding");
636
- }
637
- if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
675
+ }
676
+
677
+ //#endregion
678
+ //#region src/ema/signature.ts
679
+ /**
680
+ * Pick a signing key from a JWKS that matches the assertion header.
681
+ *
682
+ * Filters by:
683
+ * - `kid` (if the assertion header carries one)
684
+ * - JWK `use === 'sig'` (when present)
685
+ * - JWK `key_ops` containing `verify` (when present)
686
+ * - JWK `alg` matching (when present)
687
+ * - `kty` compatible with the requested `alg`
688
+ *
689
+ * If the assertion has a `kid`, the first matching key wins. Without a `kid`,
690
+ * we only return a key if exactly one candidate matches — otherwise the
691
+ * selection is ambiguous and we reject (caller may then force-refresh JWKS).
692
+ */
693
+ function selectJwk(jwks, alg, kid) {
694
+ const matching = (jwks.keys ?? []).filter((key) => {
695
+ if (kid && key.kid !== kid) return false;
696
+ if (key.alg && key.alg !== alg) return false;
697
+ if (key.use && key.use !== "sig") return false;
698
+ if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
699
+ if (alg.startsWith("RS") && key.kty !== "RSA") return false;
700
+ if (alg.startsWith("ES") && key.kty !== "EC") return false;
701
+ return true;
702
+ });
703
+ if (kid) {
704
+ const picked = matching[0];
705
+ if (!picked) return err({
706
+ reason: "no_matching_key",
707
+ kid
708
+ });
709
+ return ok(picked);
638
710
  }
711
+ if (matching.length !== 1) return err({ reason: "no_matching_key" });
712
+ return ok(matching[0]);
639
713
  }
640
- /** Returns whether a client ID has the URL shape this package resolves through CIMD. */
641
- function isClientIdMetadataDocumentUrl(clientId) {
714
+ /**
715
+ * Verify an ID-JAG's compact-JWS signature using WebCrypto.
716
+ *
717
+ * Returns `false` on any WebCrypto-level failure (import or verify).
718
+ * The caller is responsible for mapping `false` to the appropriate
719
+ * `EmaValidationError`.
720
+ */
721
+ async function verifyIdJagSignature(input) {
642
722
  try {
643
- return new URL(clientId).protocol === "https:" && rawPath(clientId) !== "";
723
+ const { importAlgorithm, verifyAlgorithm } = getJwtCryptoAlgorithms(input.alg);
724
+ const key = await crypto.subtle.importKey("jwk", input.jwk, importAlgorithm, false, ["verify"]);
725
+ return await crypto.subtle.verify(verifyAlgorithm, key, input.signature, input.signingInput);
644
726
  } catch {
645
727
  return false;
646
728
  }
647
729
  }
648
- function containsPrivateJwkMaterial(value) {
649
- if (value === void 0) return false;
650
- const jwks = requireJsonObject(value);
651
- if (!Array.isArray(jwks.keys)) throw new Error("Invalid jwks: keys must be an array");
652
- const privateMembers = new Set([
653
- "d",
654
- "p",
655
- "q",
656
- "dp",
657
- "dq",
658
- "qi",
659
- "oth",
660
- "k"
661
- ]);
662
- for (const value$1 of jwks.keys) {
663
- const key = requireJsonObject(value$1);
664
- if (Object.keys(key).some((member) => privateMembers.has(member))) return true;
665
- }
666
- return false;
667
- }
668
- function resolveClientIdMetadataDocument(metadataUrl, value, server) {
669
- const raw = requireJsonObject(value);
670
- const metadata = parseOAuthClientMetadata(raw);
671
- if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
672
- if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
673
- const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
674
- if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
675
- if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
676
- const capabilities = negotiateCimdClientCapabilities(server, {
677
- tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
678
- tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
679
- grantTypes: metadata.grantTypes ?? ["authorization_code"],
680
- responseTypes: metadata.responseTypes ?? ["code"]
681
- });
682
- return {
683
- ...pickDisplayMetadata(metadata),
684
- clientId: metadata.clientId,
685
- clientName: metadata.clientName,
686
- redirectUris,
687
- ...capabilities
688
- };
689
- }
690
- function readStreamChunk(reader, signal) {
691
- if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
692
- return new Promise((resolve, reject) => {
693
- const abort = () => {
694
- reader.cancel().catch(() => void 0);
695
- reject(new DOMException("Aborted", "AbortError"));
730
+ /**
731
+ * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
732
+ */
733
+ function getJwtCryptoAlgorithms(alg) {
734
+ if (alg === "RS256") {
735
+ const algorithm = {
736
+ name: "RSASSA-PKCS1-v1_5",
737
+ hash: "SHA-256"
696
738
  };
697
- signal.addEventListener("abort", abort, { once: true });
698
- reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
699
- });
700
- }
701
- async function readJsonWithSizeLimit$1(response, maxBytes, signal) {
702
- const contentLength = response.headers.get("Content-Length");
703
- if (contentLength !== null) {
704
- const declaredSize = Number(contentLength);
705
- if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
706
- await response.body?.cancel().catch(() => void 0);
707
- throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
708
- }
709
- }
710
- const reader = response.body?.getReader();
711
- if (!reader) throw new Error("Client metadata response body is empty");
712
- const chunks = [];
713
- let totalSize = 0;
714
- while (true) {
715
- const { done, value } = await readStreamChunk(reader, signal);
716
- if (done) break;
717
- if (!value) continue;
718
- totalSize += value.length;
719
- if (totalSize > maxBytes) {
720
- await reader.cancel().catch(() => void 0);
721
- throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
722
- }
723
- chunks.push(value);
724
- }
725
- if (signal.aborted) throw new DOMException("Aborted", "AbortError");
726
- const bytes = new Uint8Array(totalSize);
727
- let offset = 0;
728
- for (const chunk of chunks) {
729
- bytes.set(chunk, offset);
730
- offset += chunk.length;
731
- }
732
- let text;
733
- try {
734
- text = new TextDecoder("utf-8", {
735
- fatal: true,
736
- ignoreBOM: false
737
- }).decode(bytes);
738
- } catch {
739
- throw new Error("Client metadata response is not valid UTF-8");
740
- }
741
- try {
742
739
  return {
743
- value: JSON.parse(text),
744
- bytes
740
+ importAlgorithm: algorithm,
741
+ verifyAlgorithm: algorithm
745
742
  };
746
- } catch {
747
- throw new Error("Client metadata response is not valid JSON");
748
743
  }
749
- }
750
- function fetchCimdOrigin(metadataUrl, signal) {
751
- return fetch(metadataUrl, {
752
- headers: {
753
- Accept: "application/json",
754
- "Cache-Control": "no-store"
744
+ if (alg === "ES256") return {
745
+ importAlgorithm: {
746
+ name: "ECDSA",
747
+ namedCurve: "P-256"
755
748
  },
756
- signal,
757
- cache: "no-store"
758
- });
759
- }
760
- async function openCimdCache() {
761
- if (typeof caches === "undefined") return void 0;
762
- try {
763
- return await caches.open(CIMD_CACHE_NAME);
764
- } catch {
765
- return;
766
- }
749
+ verifyAlgorithm: {
750
+ name: "ECDSA",
751
+ hash: "SHA-256"
752
+ }
753
+ };
754
+ throw new Error(`Unsupported JWT alg: ${alg}`);
767
755
  }
756
+
757
+ //#endregion
758
+ //#region src/client-assertion.ts
759
+ /** RFC 7523 §2.2 `client_assertion_type` for a JWT client assertion. */
760
+ const JWT_BEARER_CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
761
+ /** JWS algorithms accepted on a client assertion. */
762
+ const CLIENT_ASSERTION_ALGORITHMS = ["RS256", "ES256"];
763
+ /** Largest compact JWS accepted as a client assertion. */
764
+ const CLIENT_ASSERTION_MAX_BYTES = 16 * 1024;
765
+ /** Allowed clock skew on `exp`, `nbf`, and `iat`. */
766
+ const CLIENT_ASSERTION_CLOCK_SKEW_SECONDS = 60;
768
767
  /**
769
- * Shared-cache lifetime derived from the origin's Cache-Control directives,
770
- * bounded by the server-side cap the CIMD draft permits (§5.2 "MAY define its
771
- * own upper and/or lower bounds on an acceptable cache lifetime"). Returns
772
- * undefined when the response must not be stored.
768
+ * Longest remaining lifetime accepted on an assertion. A client assertion is minted per request,
769
+ * and the bound caps how long its replay marker stays in KV.
773
770
  */
774
- function cacheTtlSeconds(response) {
775
- const cacheControl = response.headers.get("Cache-Control");
776
- if (cacheControl === null || /(?:^|,)\s*(?:no-cache|no-store|private)\b/i.test(cacheControl)) return void 0;
777
- const directive = /(?:^|,)\s*s-maxage\s*=\s*"?(\d+)/i.exec(cacheControl) ?? /(?:^|,)\s*max-age\s*=\s*"?(\d+)/i.exec(cacheControl);
778
- if (!directive) return void 0;
779
- const ttl = Math.min(Number(directive[1]), CIMD_CACHE_MAX_TTL_SECONDS);
780
- return ttl > 0 ? ttl : void 0;
781
- }
782
- async function cacheValidatedDocument(cache, metadataUrl, response, bytes) {
783
- if (!cache) return;
784
- const ttl = cacheTtlSeconds(response);
785
- if (ttl === void 0) return;
786
- const headers = new Headers({ "Cache-Control": `public, max-age=${ttl}` });
787
- for (const name of [
788
- "Content-Type",
789
- "ETag",
790
- "Last-Modified"
791
- ]) {
792
- const value = response.headers.get(name);
793
- if (value !== null) headers.set(name, value);
794
- }
795
- try {
796
- await cache.put(metadataUrl, new Response(bytes, {
797
- status: 200,
798
- headers
799
- }));
800
- } catch {}
771
+ const CLIENT_ASSERTION_MAX_LIFETIME_SECONDS = 3600;
772
+ /** Most client JWKS kept in the per-isolate cache; their URLs come from client metadata. */
773
+ const CLIENT_JWKS_CACHE_MAX_ENTRIES = 256;
774
+ /** KV prefix for client assertion replay markers. Stable across versions. */
775
+ const CLIENT_ASSERTION_JTI_KV_PREFIX = "client-assertion-jti:";
776
+ /** draft-ietf-oauth-rfc7523bis explicit type. A typed assertion must use the issuer as its sole audience. */
777
+ const CLIENT_AUTHENTICATION_JWT_TYPES = new Set(["client-authentication+jwt", "application/client-authentication+jwt"]);
778
+ /** Whether a public JWK could verify an assertion signed with `alg`, by key type and curve. */
779
+ function isKeyUsableForAlgorithm(key, alg) {
780
+ if (key.use !== void 0 && key.use !== "sig") return false;
781
+ if (key.alg !== void 0 && key.alg !== alg) return false;
782
+ if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
783
+ return alg === "RS256" ? key.kty === "RSA" : key.kty === "EC" && key.crv === "P-256";
801
784
  }
802
785
  /**
803
- * Resolves a previously validated cache entry. A stored document that stops
804
- * validating (changed server capabilities, stricter rules after an upgrade)
805
- * is evicted so the caller re-resolves from origin in the same request;
806
- * timeout aborts propagate instead.
786
+ * Reads the unverified `iss` of a client assertion, so a request that omits `client_id` can still
787
+ * locate the client. The assertion is verified against that client's keys before it is trusted.
807
788
  */
808
- async function tryResolveFromCache(cache, metadataUrl, server, signal) {
809
- let cached;
810
- try {
811
- cached = await cache?.match(metadataUrl);
812
- } catch {
813
- return;
789
+ function readClientAssertionIssuer(assertion) {
790
+ if (typeof assertion !== "string") return void 0;
791
+ const parsed = parseIdJag(assertion, CLIENT_ASSERTION_MAX_BYTES);
792
+ if (!parsed.ok) return void 0;
793
+ const iss = parsed.value.rawClaims.iss;
794
+ return typeof iss === "string" && iss.length > 0 ? iss : void 0;
795
+ }
796
+ /** Creates a verifier with its own JWKS cache and replay store. */
797
+ function createClientAssertionVerifier() {
798
+ const jwksProvider = createDefaultJwksProvider({ maxEntries: CLIENT_JWKS_CACHE_MAX_ENTRIES });
799
+ const jtiStore = createKvJtiStore(CLIENT_ASSERTION_JTI_KV_PREFIX);
800
+ return { async verify(input) {
801
+ if (typeof input.assertion !== "string") return err({ reason: "assertion_missing" });
802
+ const parsed = parseIdJag(input.assertion, CLIENT_ASSERTION_MAX_BYTES);
803
+ if (!parsed.ok) return parsed;
804
+ const header = validateHeader(parsed.value.header, input.keys.algorithms);
805
+ if (!header.ok) return header;
806
+ const now = Math.floor(Date.now() / 1e3);
807
+ const claims = validateClaims(parsed.value.rawClaims, input, header.value.typed, now);
808
+ if (!claims.ok) return claims;
809
+ const verified = await verifySignature({
810
+ parsed: parsed.value,
811
+ alg: header.value.alg,
812
+ kid: header.value.kid,
813
+ keys: input.keys,
814
+ jwksProvider,
815
+ now
816
+ });
817
+ if (!verified.ok) return verified;
818
+ return jtiStore.markUsed({
819
+ issuer: input.clientId,
820
+ jti: claims.value.jti,
821
+ exp: claims.value.exp + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS,
822
+ now: Math.floor(Date.now() / 1e3),
823
+ env: input.env
824
+ });
825
+ } };
826
+ }
827
+ function validateHeader(header, algorithms) {
828
+ if (header.crit !== void 0 || header.b64 !== void 0) return err({ reason: "assertion_malformed" });
829
+ const alg = header.alg;
830
+ if (typeof alg !== "string" || !algorithms.includes(alg)) return err({
831
+ reason: "invalid_alg",
832
+ got: alg
833
+ });
834
+ const typ = header.typ;
835
+ let typed = false;
836
+ if (typ !== void 0) {
837
+ const normalized = typeof typ === "string" ? typ.toLowerCase() : "";
838
+ typed = CLIENT_AUTHENTICATION_JWT_TYPES.has(normalized);
839
+ if (!typed && normalized !== "jwt") return err({
840
+ reason: "invalid_typ",
841
+ got: typ
842
+ });
814
843
  }
815
- if (!cached) return void 0;
816
- try {
817
- const { value } = await readJsonWithSizeLimit$1(cached, CIMD_MAX_SIZE_BYTES, signal);
818
- return resolveClientIdMetadataDocument(metadataUrl, value, server);
819
- } catch (error) {
820
- if (signal.aborted) throw error;
821
- try {
822
- await cache?.delete(metadataUrl);
823
- } catch {}
824
- return;
844
+ const kid = header.kid;
845
+ if (kid !== void 0 && (typeof kid !== "string" || kid.length === 0)) return err({ reason: "assertion_malformed" });
846
+ return ok({
847
+ alg,
848
+ kid,
849
+ typed
850
+ });
851
+ }
852
+ function validateClaims(claims, input, typed, now) {
853
+ if (claims.iss !== input.clientId) return err({
854
+ reason: "client_id_mismatch",
855
+ expected: input.clientId,
856
+ got: String(claims.iss)
857
+ });
858
+ if (claims.sub !== input.clientId) return err({
859
+ reason: "client_id_mismatch",
860
+ expected: input.clientId,
861
+ got: String(claims.sub)
862
+ });
863
+ const aud = claims.aud;
864
+ if (!(typed ? aud === input.issuer : aud === input.issuer || aud === input.tokenEndpoint || Array.isArray(aud) && aud.every((value) => typeof value === "string") && (aud.includes(input.issuer) || aud.includes(input.tokenEndpoint)))) return err({
865
+ reason: "aud_mismatch",
866
+ expected: input.issuer,
867
+ got: Array.isArray(aud) ? aud.map(String) : String(aud)
868
+ });
869
+ const exp = claims.exp;
870
+ if (typeof exp !== "number" || !Number.isInteger(exp)) return err({
871
+ reason: "invalid_claim",
872
+ claim: "exp"
873
+ });
874
+ if (exp + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS <= now) return err({
875
+ reason: "expired",
876
+ exp,
877
+ now
878
+ });
879
+ if (exp - now > CLIENT_ASSERTION_MAX_LIFETIME_SECONDS) return err({
880
+ reason: "lifetime_too_long",
881
+ lifetime: exp - now,
882
+ max: CLIENT_ASSERTION_MAX_LIFETIME_SECONDS
883
+ });
884
+ const nbf = claims.nbf;
885
+ if (nbf !== void 0) {
886
+ if (typeof nbf !== "number" || !Number.isInteger(nbf)) return err({
887
+ reason: "invalid_claim",
888
+ claim: "nbf"
889
+ });
890
+ if (nbf > now + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS) return err({
891
+ reason: "nbf_in_future",
892
+ nbf,
893
+ now,
894
+ skew: CLIENT_ASSERTION_CLOCK_SKEW_SECONDS
895
+ });
896
+ }
897
+ const iat = claims.iat;
898
+ if (iat !== void 0) {
899
+ if (typeof iat !== "number" || !Number.isInteger(iat)) return err({
900
+ reason: "invalid_claim",
901
+ claim: "iat"
902
+ });
903
+ if (iat > now + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS) return err({
904
+ reason: "iat_in_future",
905
+ iat,
906
+ now,
907
+ skew: CLIENT_ASSERTION_CLOCK_SKEW_SECONDS
908
+ });
825
909
  }
910
+ const jti = claims.jti;
911
+ if (typeof jti !== "string" || jti.length === 0) return err({
912
+ reason: "invalid_claim",
913
+ claim: "jti"
914
+ });
915
+ return ok({
916
+ jti,
917
+ exp
918
+ });
919
+ }
920
+ async function verifySignature(args) {
921
+ const { keys, alg, kid, parsed } = args;
922
+ const verifyWith = async (jwks) => {
923
+ const jwk = selectJwk(jwks, alg, kid);
924
+ if (!jwk.ok) return jwk;
925
+ return await verifyIdJagSignature({
926
+ alg,
927
+ jwk: jwk.value,
928
+ signingInput: parsed.signingInput,
929
+ signature: parsed.signature
930
+ }) ? ok(void 0) : err({ reason: "signature_failed" });
931
+ };
932
+ if (keys.jwks) return verifyWith(keys.jwks);
933
+ if (!keys.jwksUri) return err({ reason: "no_matching_key" });
934
+ const source = {
935
+ issuer: keys.jwksUri,
936
+ jwksUri: keys.jwksUri
937
+ };
938
+ const initial = await args.jwksProvider.fetch(source, {
939
+ forceRefresh: false,
940
+ now: args.now
941
+ });
942
+ if (!initial.ok) return initial;
943
+ const result = await verifyWith(initial.value);
944
+ if (result.ok) return result;
945
+ const refreshed = await args.jwksProvider.fetch(source, {
946
+ forceRefresh: true,
947
+ now: args.now
948
+ });
949
+ if (!refreshed.ok) return refreshed;
950
+ return verifyWith(refreshed.value);
826
951
  }
952
+
953
+ //#endregion
954
+ //#region src/oauth-resource.ts
827
955
  /**
828
- * Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
829
- * Document into the single effective client configuration used by OAuth flows.
956
+ * Loopback hosts where a plain `http` scheme is accepted for local development.
957
+ * Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
830
958
  */
831
- async function fetchClientIdMetadataDocument(metadataUrl, server) {
832
- validateClientIdentifierUrl(metadataUrl);
833
- const abortController = new AbortController();
834
- const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
959
+ function isLoopbackHostname(hostname) {
960
+ const host = hostname.toLowerCase();
961
+ if (host === "localhost" || host === "::1" || host === "[::1]") return true;
962
+ const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
963
+ return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
964
+ }
965
+ /**
966
+ * Whether a canonical resource, issuer, or endpoint URL uses an accepted
967
+ * scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
968
+ * http://localhost. Workers are always served over `https`, so there is no
969
+ * deployment in which cleartext OAuth traffic to a remote host is correct,
970
+ * and no option exists to allow it.
971
+ */
972
+ function hasAcceptedCanonicalScheme(url) {
973
+ return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
974
+ }
975
+ /** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
976
+ function validateResourceUri(uri) {
977
+ if (!uri || typeof uri !== "string") return false;
978
+ if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
835
979
  try {
836
- const cache = await openCimdCache();
837
- const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
838
- if (cachedDocument) return cachedDocument;
839
- const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
840
- if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
841
- const { value, bytes } = await readJsonWithSizeLimit$1(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
842
- const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
843
- clearTimeout(timeoutId);
844
- await cacheValidatedDocument(cache, metadataUrl, response, bytes);
845
- return resolved;
846
- } catch (error) {
847
- if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
848
- throw error;
849
- } finally {
850
- clearTimeout(timeoutId);
980
+ const parsed = new URL(uri);
981
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
982
+ } catch {
983
+ return false;
851
984
  }
852
985
  }
853
-
854
- //#endregion
855
- //#region src/ema/constants.ts
856
986
  /**
857
- * Constants for MCP Enterprise-Managed Authorization (EMA).
858
- *
859
- * Co-located so that anyone touching the EMA code path sees every magic
860
- * number in one place. Public-facing defaults can be overridden via
861
- * `EmaOptions`.
987
+ * Whether a requested resource identifies a granted or configured resource.
988
+ * Only ASCII case in the URI scheme and host is ignored, and an empty path is
989
+ * equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
990
+ * `https://example.com` through a URL parser as `https://example.com/` still
991
+ * names it. Port, path, query, a trailing slash after a path segment, user
992
+ * information, and every other byte remain significant.
862
993
  */
863
- /** JWT `typ` header value required for ID-JAG assertions (RFC 8725 §3.11). */
864
- const EMA_ID_JAG_JWT_TYPE = "oauth-id-jag+jwt";
994
+ function resourceMatches(requested, granted) {
995
+ const foldedRequested = foldResourceSchemeAndHost(requested);
996
+ const foldedGranted = foldResourceSchemeAndHost(granted);
997
+ if (foldedRequested === void 0 || foldedGranted === void 0) return false;
998
+ return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
999
+ }
865
1000
  /**
866
- * Grant-profile URN advertised in `authorization_grant_profiles_supported`
867
- * when EMA is configured (MCP Enterprise-Managed Authorization spec).
1001
+ * Fold only the URI components whose comparison is ASCII case-insensitive: the
1002
+ * scheme and the host. A configured identifier is canonical when folding it is
1003
+ * the identity.
868
1004
  */
869
- const EMA_ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";
870
- /** Maximum compact JWT assertion size accepted at the token endpoint. */
871
- const EMA_MAX_JWT_BYTES = 16 * 1024;
872
- /** Maximum JWKS response size accepted from a trusted enterprise IdP. */
873
- const EMA_JWKS_MAX_SIZE_BYTES = 64 * 1024;
874
- /** Request timeout for JWKS fetches. */
875
- const EMA_JWKS_FETCH_TIMEOUT_MS = 1e4;
876
- /** Default JWKS cache TTL. */
877
- const EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS = 300;
878
- /** Default allowed clock skew for ID-JAG time claim validation. */
879
- const EMA_DEFAULT_CLOCK_SKEW_SECONDS = 60;
880
- /** Default maximum accepted ID-JAG lifetime. */
881
- const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
1005
+ function foldResourceSchemeAndHost(resource) {
1006
+ const schemeSeparator = resource.indexOf("://");
1007
+ if (schemeSeparator <= 0) return void 0;
1008
+ const authorityStart = schemeSeparator + 3;
1009
+ const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
1010
+ const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
1011
+ const authority = resource.slice(authorityStart, authorityEnd);
1012
+ const hostStart = authority.lastIndexOf("@") + 1;
1013
+ let hostEnd;
1014
+ if (authority[hostStart] === "[") {
1015
+ const closingBracket = authority.indexOf("]", hostStart + 1);
1016
+ if (closingBracket === -1) return void 0;
1017
+ hostEnd = closingBracket + 1;
1018
+ } else {
1019
+ const portSeparator = authority.indexOf(":", hostStart);
1020
+ hostEnd = portSeparator === -1 ? authority.length : portSeparator;
1021
+ }
1022
+ const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
1023
+ return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
1024
+ }
1025
+ /** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
1026
+ function normalizeEmptyPath(resource) {
1027
+ const authorityStart = resource.indexOf("://") + 3;
1028
+ const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
1029
+ if (pathOffset === -1) return `${resource}/`;
1030
+ const pathStart = authorityStart + pathOffset;
1031
+ return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
1032
+ }
882
1033
  /**
883
- * Minimum cool-down between JWKS force-refreshes per issuer.
884
- * Defends against attackers that send many assertions with random `kid`
885
- * values to amplify load on the IdP's JWKS endpoint.
1034
+ * Whether a request carries every query parameter of a canonical resource. A
1035
+ * query-bearing resource identifier names a more specific resource: a request
1036
+ * may add parameters of its own but must preserve the resource's.
886
1037
  */
887
- const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
888
- /** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
889
- const EMA_JTI_MIN_TTL_SECONDS = 60;
890
- /** Default JWT signing algorithm assumed for a trusted issuer. */
891
- const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
892
- /** JWT signing algorithms supported by the built-in WebCrypto verifier. */
893
- const EMA_SUPPORTED_JWT_ALGORITHMS = new Set(["RS256", "ES256"]);
1038
+ function requestCarriesResourceQuery(requestUrl, resourceUrl) {
1039
+ for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
1040
+ return true;
1041
+ }
894
1042
 
895
1043
  //#endregion
896
- //#region src/ema/result.ts
897
- const ok = (value) => ({
898
- ok: true,
899
- value
900
- });
901
- const err = (error) => ({
902
- ok: false,
903
- error
904
- });
1044
+ //#region src/oauth-client-metadata.ts
1045
+ const CIMD_MAX_SIZE_BYTES = 5 * 1024;
1046
+ const CIMD_FETCH_TIMEOUT_MS = 1e4;
1047
+ const CIMD_CACHE_NAME = "workers-oauth-provider:cimd:v1";
1048
+ const CIMD_CACHE_MAX_TTL_SECONDS = 10080 * 60;
1049
+ function requireJsonObject(value) {
1050
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Client metadata must be a JSON object");
1051
+ return value;
1052
+ }
1053
+ function optionalString(value, fieldName) {
1054
+ if (value === void 0) return void 0;
1055
+ if (typeof value !== "string") throw new Error(`Invalid ${fieldName}: expected string, got ${typeof value}`);
1056
+ return value;
1057
+ }
1058
+ function optionalStringArray(value, fieldName) {
1059
+ if (value === void 0) return void 0;
1060
+ if (!Array.isArray(value)) throw new Error(`Invalid ${fieldName}: expected array, got ${typeof value}`);
1061
+ if (!value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName}: array must contain only strings`);
1062
+ return [...value];
1063
+ }
1064
+ function optionalHttpUri(value, fieldName) {
1065
+ const uri = optionalString(value, fieldName);
1066
+ if (uri === void 0) return void 0;
1067
+ let parsed;
1068
+ try {
1069
+ parsed = new URL(uri);
1070
+ } catch {
1071
+ throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
1072
+ }
1073
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
1074
+ return uri;
1075
+ }
1076
+ const I18N_FIELDS = {
1077
+ client_name: "string",
1078
+ client_uri: "uri",
1079
+ logo_uri: "uri",
1080
+ tos_uri: "uri",
1081
+ policy_uri: "uri"
1082
+ };
1083
+ function extractI18nFields(raw) {
1084
+ const result = {};
1085
+ for (const key of Object.keys(raw)) {
1086
+ const hashIndex = key.indexOf("#");
1087
+ if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
1088
+ const kind = I18N_FIELDS[key.slice(0, hashIndex)];
1089
+ if (!kind) continue;
1090
+ const value = kind === "uri" ? optionalHttpUri(raw[key], key) : optionalString(raw[key], key);
1091
+ if (value !== void 0) result[key] = value;
1092
+ }
1093
+ return Object.keys(result).length > 0 ? result : void 0;
1094
+ }
1095
+ function validateChoiceConsistency(preferredName, preferredValue, choicesName, choices) {
1096
+ if (preferredValue !== void 0 && choices !== void 0 && !choices.includes(preferredValue)) throw new Error(`${preferredName} must be included in ${choicesName}`);
1097
+ }
1098
+ function parseOAuthClientMetadata(raw) {
1099
+ const tokenEndpointAuthMethod = optionalString(raw.token_endpoint_auth_method, "token_endpoint_auth_method");
1100
+ const tokenEndpointAuthMethodsSupported = optionalStringArray(raw.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
1101
+ const tokenEndpointAuthSigningAlg = optionalString(raw.token_endpoint_auth_signing_alg, "token_endpoint_auth_signing_alg");
1102
+ const tokenEndpointAuthSigningAlgValuesSupported = optionalStringArray(raw.token_endpoint_auth_signing_alg_values_supported, "token_endpoint_auth_signing_alg_values_supported");
1103
+ validateChoiceConsistency("token_endpoint_auth_method", tokenEndpointAuthMethod, "token_endpoint_auth_methods_supported", tokenEndpointAuthMethodsSupported);
1104
+ validateChoiceConsistency("token_endpoint_auth_signing_alg", tokenEndpointAuthSigningAlg, "token_endpoint_auth_signing_alg_values_supported", tokenEndpointAuthSigningAlgValuesSupported);
1105
+ return {
1106
+ clientId: optionalString(raw.client_id, "client_id"),
1107
+ redirectUris: optionalStringArray(raw.redirect_uris, "redirect_uris"),
1108
+ clientName: optionalString(raw.client_name, "client_name"),
1109
+ clientUri: optionalHttpUri(raw.client_uri, "client_uri"),
1110
+ logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
1111
+ policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
1112
+ tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
1113
+ jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
1114
+ i18n: extractI18nFields(raw),
1115
+ contacts: optionalStringArray(raw.contacts, "contacts"),
1116
+ grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
1117
+ responseTypes: optionalStringArray(raw.response_types, "response_types"),
1118
+ tokenEndpointAuthMethod,
1119
+ tokenEndpointAuthMethodsSupported,
1120
+ tokenEndpointAuthSigningAlg,
1121
+ tokenEndpointAuthSigningAlgValuesSupported
1122
+ };
1123
+ }
1124
+ function pickDisplayMetadata(metadata) {
1125
+ const { clientName, clientUri, logoUri, policyUri, tosUri, jwksUri, i18n, contacts } = metadata;
1126
+ return {
1127
+ clientName,
1128
+ clientUri,
1129
+ logoUri,
1130
+ policyUri,
1131
+ tosUri,
1132
+ jwksUri,
1133
+ i18n,
1134
+ contacts
1135
+ };
1136
+ }
905
1137
  /**
906
- * Map an internal `EmaValidationError` to its public OAuth error code and
907
- * description. Most validation failures collapse to a single generic message
908
- * to avoid leaking which check failed to attackers probing the IdP. The
909
- * exceptions are RFC-prescribed distinct codes (`invalid_target` for
910
- * RFC 8707 resource issues, `invalid_request` for malformed input).
1138
+ * The checks every listed redirect URI passes, whatever the policy: no control characters, a
1139
+ * scheme, and not a dangerous one (`javascript:`, `data:` and the like). A Client ID Metadata
1140
+ * Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
1141
+ * @throws Error when the redirect URI is not safe to store
911
1142
  */
912
- function emaErrorToWire(e) {
913
- switch (e.reason) {
914
- case "assertion_missing": return {
915
- code: "invalid_request",
916
- message: "assertion is required"
917
- };
918
- case "invalid_scope_param": return {
919
- code: "invalid_request",
920
- message: "Invalid scope parameter format"
921
- };
922
- case "resource_invalid":
923
- case "resource_mismatch": return {
924
- code: "invalid_target",
925
- message: "Invalid resource"
926
- };
927
- case "mapper_denied":
928
- case "mapper_threw": return {
929
- code: "invalid_grant",
930
- message: "Assertion was not authorized"
931
- };
932
- case "invalid_mapped_user": return {
933
- code: "invalid_grant",
934
- message: "Invalid mapped user"
935
- };
936
- case "invalid_mapped_scope": return {
937
- code: "invalid_grant",
938
- message: "Invalid mapped scope"
939
- };
940
- case "invalid_mapped_props": return {
941
- code: "invalid_grant",
942
- message: "Invalid mapped props"
943
- };
944
- case "invalid_mapped_ttl": return {
945
- code: "invalid_grant",
946
- message: "Invalid access token TTL"
947
- };
948
- case "assertion_expired_after_processing": return {
949
- code: "invalid_grant",
950
- message: "Assertion has expired"
951
- };
952
- case "assertion_too_large":
953
- case "assertion_malformed":
954
- case "invalid_typ":
955
- case "invalid_alg":
956
- case "issuer_not_trusted":
957
- case "no_matching_key":
958
- case "signature_failed":
959
- case "jwks_fetch_failed":
960
- case "invalid_claim":
961
- case "aud_mismatch":
962
- case "unsupported_claim":
963
- case "expired":
964
- case "iat_in_future":
965
- case "nbf_in_future":
966
- case "lifetime_too_long":
967
- case "replayed":
968
- case "client_id_mismatch": return {
969
- code: "invalid_grant",
970
- message: "Invalid assertion"
971
- };
1143
+ function validateRedirectUriSafety(redirectUri) {
1144
+ const dangerousSchemes = [
1145
+ "javascript:",
1146
+ "data:",
1147
+ "vbscript:",
1148
+ "file:",
1149
+ "mailto:",
1150
+ "blob:"
1151
+ ];
1152
+ const normalized = redirectUri.trim();
1153
+ for (let i = 0; i < normalized.length; i++) {
1154
+ const code = normalized.charCodeAt(i);
1155
+ if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
1156
+ }
1157
+ const colonIndex = normalized.indexOf(":");
1158
+ if (colonIndex === -1) throw new Error("Invalid redirect URI");
1159
+ const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
1160
+ if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
1161
+ }
1162
+ /**
1163
+ * The checks every redirect URI a client registers passes, whether or not this server would send a
1164
+ * code to it: the {@link validateRedirectUriSafety} checks, a parseable URI, and no fragment (not
1165
+ * even an empty `#`) or userinfo.
1166
+ * @throws Error when the redirect URI can't be registered at all
1167
+ */
1168
+ function validateListedRedirectUri(redirectUri) {
1169
+ validateRedirectUriSafety(redirectUri);
1170
+ const normalized = redirectUri.trim();
1171
+ let url;
1172
+ try {
1173
+ url = new URL(normalized);
1174
+ } catch {
1175
+ throw new Error("Invalid redirect URI");
1176
+ }
1177
+ if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
1178
+ return url;
1179
+ }
1180
+ /**
1181
+ * Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
1182
+ * loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
1183
+ * empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
1184
+ * `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
1185
+ * checks always apply.
1186
+ * @throws Error describing why the redirect URI is not acceptable
1187
+ */
1188
+ function validateRedirectUri(redirectUri, server) {
1189
+ const url = validateListedRedirectUri(redirectUri);
1190
+ if (url.protocol === "https:") return;
1191
+ if (url.protocol === "http:") {
1192
+ if (isLoopbackHostname(url.hostname)) return;
1193
+ throw new Error("Redirect URI must use https, or http on a loopback host");
972
1194
  }
1195
+ if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
973
1196
  }
974
-
975
- //#endregion
976
- //#region src/ema/util.ts
977
1197
  /**
978
- * Tiny utility helpers used by EMA adapters.
979
- *
980
- * Lives in `src/ema/util.ts` rather than `src/util.ts` to keep the EMA
981
- * module self-contained — the main `oauth-provider.ts` reaches into here
982
- * only through the adapters' public interfaces.
1198
+ * Validates the redirect URIs a client registers (dynamic registration, `createClient()`,
1199
+ * `updateClient()`). A client may list callbacks for surfaces this server won't send codes to:
1200
+ * Cursor lists `cursor://anysphere.cursor-mcp/oauth/callback` next to its https and loopback
1201
+ * callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
1202
+ * {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
1203
+ * policy by `parseAuthRequest()` and `completeAuthorization()`.
1204
+ * @throws Error when a URI can't be registered, or none of them could ever receive a code
983
1205
  */
984
- /** SHA-256 a string and return its hex digest. */
985
- async function sha256Hex$1(input) {
986
- const data = new TextEncoder().encode(input);
987
- const buffer = await crypto.subtle.digest("SHA-256", data);
988
- return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1206
+ function validateRegisteredRedirectUris(redirectUris, server) {
1207
+ if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
1208
+ for (const redirectUri of redirectUris) validateListedRedirectUri(redirectUri);
1209
+ let firstRefusal;
1210
+ for (const redirectUri of redirectUris) try {
1211
+ validateRedirectUri(redirectUri, server);
1212
+ return redirectUris;
1213
+ } catch (error) {
1214
+ firstRefusal ??= error;
1215
+ }
1216
+ throw firstRefusal instanceof Error ? firstRefusal : /* @__PURE__ */ new Error("Invalid redirect URI");
1217
+ }
1218
+ function requireValidRedirectUris(redirectUris, validate) {
1219
+ if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
1220
+ for (const redirectUri of redirectUris) validate(redirectUri);
1221
+ return redirectUris;
989
1222
  }
990
-
991
- //#endregion
992
- //#region src/ema/jti.ts
993
1223
  /**
994
- * Default `EmaJtiStore`: KV-backed `jti` replay marker.
995
- *
996
- * KV is eventually-consistent and does not provide compare-and-set, so two
997
- * concurrent requests with the same `jti` can both observe "not seen" and
998
- * succeed — the trade-off accepted here. Surrounding claim checks
999
- * (signature, `exp`, `nbf`, `aud`, `resource`, client binding) constrain
1000
- * the practical attack window.
1224
+ * Resolves a Dynamic Client Registration request into the complete metadata
1225
+ * shape that can be stored, applying RFC 7591 defaults and server capability
1226
+ * validation exactly once.
1001
1227
  */
1002
- /** Storage key prefix for replay markers. Stable across versions. */
1003
- const EMA_JTI_KV_PREFIX = "enterprise-jti:";
1004
- /** Create the default KV-backed JTI store. KV TTL handles cleanup. */
1005
- function createKvJtiStore() {
1006
- return { async markUsed({ issuer, jti, exp, now, env }) {
1007
- const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
1008
- const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
1009
- if (await env.OAUTH_KV.get(key)) return err({
1010
- reason: "replayed",
1011
- jti
1012
- });
1013
- await env.OAUTH_KV.put(key, "1", { expirationTtl: ttl });
1014
- return ok(void 0);
1015
- } };
1228
+ function resolveDynamicClientRegistrationMetadata(raw, server) {
1229
+ const metadata = parseOAuthClientMetadata(raw);
1230
+ const capabilities = negotiateDynamicClientRegistrationCapabilities(server, {
1231
+ tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
1232
+ tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
1233
+ grantTypes: metadata.grantTypes ?? ["authorization_code"],
1234
+ responseTypes: metadata.responseTypes ?? ["code"]
1235
+ });
1236
+ return {
1237
+ ...pickDisplayMetadata(metadata),
1238
+ redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
1239
+ ...capabilities,
1240
+ authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
1241
+ };
1016
1242
  }
1017
-
1018
- //#endregion
1019
- //#region src/ema/jwks.ts
1020
1243
  /**
1021
- * Default `EmaJwksProvider`: fetches IdP signing keys with caching and a
1022
- * force-refresh cool-down. The cool-down defends against attackers that
1023
- * spam random `kid` values to amplify load on the IdP's JWKS endpoint.
1024
- *
1025
- * The cache lives inside the returned provider closure, so each
1026
- * `OAuthProvider` instance has its own cache — no cross-instance bleed.
1244
+ * Path component of the raw client ID string. Deliberately not
1245
+ * `new URL().pathname`: WHATWG parsing collapses the `.` and `..` segments
1246
+ * that CIMD §3 requires rejecting, and normalizes `\` and scheme-relative
1247
+ * forms this validation must see verbatim.
1027
1248
  */
1028
- /** Create the default JWKS provider — a closure with its own private cache. */
1029
- function createDefaultJwksProvider(opts = {}) {
1030
- const cache = /* @__PURE__ */ new Map();
1031
- const cacheTtl = opts.cacheTtlSeconds ?? EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS;
1032
- return { async fetch(issuer, { forceRefresh, now }) {
1033
- const cached = cache.get(issuer.issuer);
1034
- if (!forceRefresh && cached && cached.expiresAt > now) return ok(cached.jwks);
1035
- if (forceRefresh && cached && cached.nextForceRefreshAllowedAt > now) return ok(cached.jwks);
1036
- const abortController = new AbortController();
1037
- const timeoutId = setTimeout(() => abortController.abort(), EMA_JWKS_FETCH_TIMEOUT_MS);
1249
+ function rawPath(clientId) {
1250
+ const schemeEnd = clientId.indexOf("://");
1251
+ if (schemeEnd === -1) return "";
1252
+ const authorityStart = schemeEnd + 3;
1253
+ const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
1254
+ if (authorityEndOffset === -1) return "";
1255
+ const pathStart = authorityStart + authorityEndOffset;
1256
+ if (clientId[pathStart] !== "/") return "";
1257
+ const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
1258
+ return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
1259
+ }
1260
+ function validateClientIdentifierUrl(clientId) {
1261
+ if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
1262
+ if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
1263
+ let parsed;
1264
+ try {
1265
+ parsed = new URL(clientId);
1266
+ } catch {
1267
+ throw new Error("Client Identifier URL must be a valid HTTPS URL");
1268
+ }
1269
+ if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
1270
+ if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
1271
+ if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
1272
+ const path = rawPath(clientId);
1273
+ if (!path) throw new Error("Client Identifier URL must contain a path component");
1274
+ for (const segment of path.split("/")) {
1275
+ let decodedSegment;
1038
1276
  try {
1039
- const response = await fetch(issuer.jwksUri, {
1040
- headers: { Accept: "application/json" },
1041
- signal: abortController.signal,
1042
- cf: { cacheEverything: true }
1043
- });
1044
- if (!response.ok) return err({
1045
- reason: "jwks_fetch_failed",
1046
- status: response.status
1047
- });
1048
- const contentLength = response.headers.get("content-length");
1049
- if (contentLength && parseInt(contentLength, 10) > EMA_JWKS_MAX_SIZE_BYTES) return err({
1050
- reason: "jwks_fetch_failed",
1051
- status: response.status
1052
- });
1053
- const rawJwks = await readJsonWithSizeLimit(response, EMA_JWKS_MAX_SIZE_BYTES);
1054
- if (!rawJwks.ok) return err({ reason: "jwks_fetch_failed" });
1055
- if (!Array.isArray(rawJwks.value.keys)) return err({ reason: "jwks_fetch_failed" });
1056
- const jwks = { keys: rawJwks.value.keys };
1057
- cache.set(issuer.issuer, {
1058
- jwks,
1059
- expiresAt: now + cacheTtl,
1060
- nextForceRefreshAllowedAt: now + EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
1061
- });
1062
- return ok(jwks);
1277
+ decodedSegment = decodeURIComponent(segment);
1063
1278
  } catch {
1064
- return err({ reason: "jwks_fetch_failed" });
1065
- } finally {
1066
- clearTimeout(timeoutId);
1279
+ throw new Error("Client Identifier URL contains invalid percent encoding");
1067
1280
  }
1068
- } };
1281
+ if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
1282
+ }
1283
+ }
1284
+ /** Returns whether a client ID has the URL shape this package resolves through CIMD. */
1285
+ function isClientIdMetadataDocumentUrl(clientId) {
1286
+ try {
1287
+ return new URL(clientId).protocol === "https:" && rawPath(clientId) !== "";
1288
+ } catch {
1289
+ return false;
1290
+ }
1291
+ }
1292
+ const PRIVATE_JWK_MEMBERS = new Set([
1293
+ "d",
1294
+ "p",
1295
+ "q",
1296
+ "dp",
1297
+ "dq",
1298
+ "qi",
1299
+ "oth",
1300
+ "k"
1301
+ ]);
1302
+ /** Parses an inline `jwks`, refusing private or symmetric key material, which a public document must never carry. */
1303
+ function parsePublicJwks(value) {
1304
+ if (value === void 0) return void 0;
1305
+ const jwks = requireJsonObject(value);
1306
+ if (!Array.isArray(jwks.keys)) throw new Error("Invalid jwks: keys must be an array");
1307
+ return { keys: jwks.keys.map((value$1) => {
1308
+ const key = requireJsonObject(value$1);
1309
+ if (Object.keys(key).some((member) => PRIVATE_JWK_MEMBERS.has(member))) throw new Error("CIMD documents must not contain private key material");
1310
+ return key;
1311
+ }) };
1069
1312
  }
1070
1313
  /**
1071
- * Streaming JSON reader that rejects responses exceeding `maxBytes`.
1072
- * Bounds memory consumption before we attempt to JSON-parse the body.
1314
+ * The keys and algorithms a document offering `private_key_jwt` supplies, or why they can't be used.
1315
+ * An unusable offer only matters when `private_key_jwt` is the document's sole method.
1073
1316
  */
1074
- async function readJsonWithSizeLimit(response, maxBytes) {
1075
- if (!response.body) return { ok: false };
1076
- const reader = response.body.getReader();
1077
- const chunks = [];
1078
- let total = 0;
1317
+ function resolvePrivateKeyJwtKeys(metadata, jwks) {
1318
+ const { jwksUri } = metadata;
1319
+ if (jwks && jwksUri) return { problem: "jwks and jwks_uri must not both be present" };
1320
+ if (!jwks && !jwksUri) return { problem: "private_key_jwt requires jwks or jwks_uri" };
1321
+ if (jwks && (jwks.keys ?? []).length === 0) return { problem: "private_key_jwt jwks must contain a key" };
1322
+ if (jwksUri) {
1323
+ const url = new URL(jwksUri);
1324
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) return { problem: "private_key_jwt jwks_uri must be an https: URL without userinfo or a fragment" };
1325
+ }
1326
+ const signingAlgorithms = CLIENT_ASSERTION_ALGORITHMS.filter((alg) => (metadata.tokenEndpointAuthSigningAlg === void 0 || metadata.tokenEndpointAuthSigningAlg === alg) && (metadata.tokenEndpointAuthSigningAlgValuesSupported === void 0 || metadata.tokenEndpointAuthSigningAlgValuesSupported.includes(alg)));
1327
+ if (signingAlgorithms.length === 0) return { problem: `private_key_jwt requires a token_endpoint_auth_signing_alg of ${CLIENT_ASSERTION_ALGORITHMS.join(" or ")}` };
1328
+ const algorithms = jwks ? signingAlgorithms.filter((alg) => (jwks.keys ?? []).some((key) => isKeyUsableForAlgorithm(key, alg))) : signingAlgorithms;
1329
+ if (algorithms.length === 0) return { problem: `private_key_jwt jwks has no key for ${signingAlgorithms.join(" or ")}` };
1330
+ return { keys: jwks ? {
1331
+ jwks,
1332
+ algorithms
1333
+ } : {
1334
+ jwksUri,
1335
+ algorithms
1336
+ } };
1337
+ }
1338
+ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
1339
+ const raw = requireJsonObject(value);
1340
+ const metadata = parseOAuthClientMetadata(raw);
1341
+ if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
1342
+ if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
1343
+ const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
1344
+ if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
1345
+ const jwks = parsePublicJwks(raw.jwks);
1346
+ const privateKeyJwt = metadata.tokenEndpointAuthMethod === "private_key_jwt" || !!metadata.tokenEndpointAuthMethodsSupported?.includes("private_key_jwt") ? resolvePrivateKeyJwtKeys(metadata, jwks) : void 0;
1347
+ const clientAssertionKeys = privateKeyJwt && "keys" in privateKeyJwt ? privateKeyJwt.keys : void 0;
1348
+ let capabilities;
1079
1349
  try {
1080
- while (true) {
1081
- const { done, value } = await reader.read();
1082
- if (done) break;
1083
- total += value.byteLength;
1084
- if (total > maxBytes) {
1085
- reader.cancel();
1086
- return { ok: false };
1087
- }
1088
- chunks.push(value);
1350
+ capabilities = negotiateCimdClientCapabilities(server, {
1351
+ tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
1352
+ tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
1353
+ grantTypes: metadata.grantTypes ?? ["authorization_code"],
1354
+ responseTypes: metadata.responseTypes ?? ["code"]
1355
+ }, { privateKeyJwt: clientAssertionKeys !== void 0 });
1356
+ } catch (error) {
1357
+ if (privateKeyJwt && "problem" in privateKeyJwt) throw new Error(privateKeyJwt.problem);
1358
+ throw error;
1359
+ }
1360
+ return {
1361
+ ...pickDisplayMetadata(metadata),
1362
+ clientId: metadata.clientId,
1363
+ clientName: metadata.clientName,
1364
+ redirectUris,
1365
+ ...capabilities,
1366
+ ...capabilities.tokenEndpointAuthMethods.includes("private_key_jwt") ? { clientAssertionKeys } : {}
1367
+ };
1368
+ }
1369
+ function readStreamChunk(reader, signal) {
1370
+ if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
1371
+ return new Promise((resolve, reject) => {
1372
+ const abort = () => {
1373
+ reader.cancel().catch(() => void 0);
1374
+ reject(new DOMException("Aborted", "AbortError"));
1375
+ };
1376
+ signal.addEventListener("abort", abort, { once: true });
1377
+ reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
1378
+ });
1379
+ }
1380
+ async function readJsonWithSizeLimit(response, maxBytes, signal) {
1381
+ const contentLength = response.headers.get("Content-Length");
1382
+ if (contentLength !== null) {
1383
+ const declaredSize = Number(contentLength);
1384
+ if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
1385
+ await response.body?.cancel().catch(() => void 0);
1386
+ throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
1089
1387
  }
1090
- } finally {
1091
- reader.releaseLock();
1092
1388
  }
1093
- const merged = new Uint8Array(total);
1389
+ const reader = response.body?.getReader();
1390
+ if (!reader) throw new Error("Client metadata response body is empty");
1391
+ const chunks = [];
1392
+ let totalSize = 0;
1393
+ while (true) {
1394
+ const { done, value } = await readStreamChunk(reader, signal);
1395
+ if (done) break;
1396
+ if (!value) continue;
1397
+ totalSize += value.length;
1398
+ if (totalSize > maxBytes) {
1399
+ await reader.cancel().catch(() => void 0);
1400
+ throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
1401
+ }
1402
+ chunks.push(value);
1403
+ }
1404
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
1405
+ const bytes = new Uint8Array(totalSize);
1094
1406
  let offset = 0;
1095
1407
  for (const chunk of chunks) {
1096
- merged.set(chunk, offset);
1097
- offset += chunk.byteLength;
1408
+ bytes.set(chunk, offset);
1409
+ offset += chunk.length;
1410
+ }
1411
+ let text;
1412
+ try {
1413
+ text = new TextDecoder("utf-8", {
1414
+ fatal: true,
1415
+ ignoreBOM: false
1416
+ }).decode(bytes);
1417
+ } catch {
1418
+ throw new Error("Client metadata response is not valid UTF-8");
1098
1419
  }
1099
1420
  try {
1100
- const parsed = JSON.parse(new TextDecoder().decode(merged));
1101
- if (typeof parsed !== "object" || parsed === null) return { ok: false };
1102
1421
  return {
1103
- ok: true,
1104
- value: parsed
1422
+ value: JSON.parse(text),
1423
+ bytes
1105
1424
  };
1106
1425
  } catch {
1107
- return { ok: false };
1426
+ throw new Error("Client metadata response is not valid JSON");
1108
1427
  }
1109
1428
  }
1110
-
1111
- //#endregion
1112
- //#region src/ema/parser.ts
1113
- /**
1114
- * Pure JWT parsing for ID-JAG assertions.
1115
- *
1116
- * Splits a compact JWS (`<base64url-header>.<base64url-payload>.<base64url-signature>`)
1117
- * into its three parts, decodes header and payload as JSON, and exposes the
1118
- * raw signing input + signature bytes for downstream signature verification.
1119
- *
1120
- * No I/O. No `this`. Returns `Result<ParsedIdJag, EmaValidationError>`.
1121
- */
1122
- /**
1123
- * Parse a compact JWS assertion.
1124
- *
1125
- * @param assertion The raw assertion string from the token request body.
1126
- * @param maxBytes Reject assertions whose length exceeds this many bytes.
1127
- * Guards against memory exhaustion before any JSON parsing happens.
1128
- */
1129
- function parseIdJag(assertion, maxBytes) {
1130
- if (typeof assertion !== "string" || assertion.length === 0) return err({ reason: "assertion_missing" });
1131
- if (assertion.length > maxBytes) return err({
1132
- reason: "assertion_too_large",
1133
- size: assertion.length,
1134
- max: maxBytes
1135
- });
1136
- const parts = assertion.split(".");
1137
- if (parts.length !== 3 || parts.some((part) => part.length === 0)) return err({ reason: "assertion_malformed" });
1138
- const [encodedHeader, encodedClaims, encodedSignature] = parts;
1139
- let header;
1140
- let rawClaims;
1141
- let signature;
1142
- try {
1143
- header = parseJwtJsonPart(encodedHeader);
1144
- rawClaims = parseJwtJsonPart(encodedClaims);
1145
- signature = base64UrlToBytes(encodedSignature);
1146
- } catch {
1147
- return err({ reason: "assertion_malformed" });
1148
- }
1149
- const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`);
1150
- return ok({
1151
- header,
1152
- rawClaims,
1153
- signingInput,
1154
- signature
1429
+ function fetchCimdOrigin(metadataUrl, signal) {
1430
+ return fetch(metadataUrl, {
1431
+ headers: {
1432
+ Accept: "application/json",
1433
+ "Cache-Control": "no-store",
1434
+ "User-Agent": OUTBOUND_USER_AGENT
1435
+ },
1436
+ signal,
1437
+ cache: "no-store"
1155
1438
  });
1156
1439
  }
1157
- /**
1158
- * Decodes a base64url-encoded string to bytes.
1159
- */
1160
- function base64UrlToBytes(base64Url) {
1161
- const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
1162
- const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
1163
- const binaryString = atob(padded);
1164
- const bytes = new Uint8Array(binaryString.length);
1165
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1166
- return bytes;
1167
- }
1168
- /**
1169
- * Parses a base64url-encoded JWT JSON part into an object.
1170
- */
1171
- function parseJwtJsonPart(encoded) {
1440
+ async function openCimdCache() {
1441
+ if (typeof caches === "undefined") return void 0;
1172
1442
  try {
1173
- const json = new TextDecoder().decode(base64UrlToBytes(encoded));
1174
- const parsed = JSON.parse(json);
1175
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
1176
- return parsed;
1443
+ return await caches.open(CIMD_CACHE_NAME);
1177
1444
  } catch {
1178
- throw new Error("Malformed JWT part");
1445
+ return;
1179
1446
  }
1180
1447
  }
1181
-
1182
- //#endregion
1183
- //#region src/ema/signature.ts
1184
1448
  /**
1185
- * Pick a signing key from a JWKS that matches the assertion header.
1186
- *
1187
- * Filters by:
1188
- * - `kid` (if the assertion header carries one)
1189
- * - JWK `use === 'sig'` (when present)
1190
- * - JWK `key_ops` containing `verify` (when present)
1191
- * - JWK `alg` matching (when present)
1192
- * - `kty` compatible with the requested `alg`
1193
- *
1194
- * If the assertion has a `kid`, the first matching key wins. Without a `kid`,
1195
- * we only return a key if exactly one candidate matches — otherwise the
1196
- * selection is ambiguous and we reject (caller may then force-refresh JWKS).
1449
+ * Shared-cache lifetime derived from the origin's Cache-Control directives,
1450
+ * bounded by the server-side cap the CIMD draft permits (§5.2 "MAY define its
1451
+ * own upper and/or lower bounds on an acceptable cache lifetime"). Returns
1452
+ * undefined when the response must not be stored.
1197
1453
  */
1198
- function selectJwk(jwks, alg, kid) {
1199
- const matching = (jwks.keys ?? []).filter((key) => {
1200
- if (kid && key.kid !== kid) return false;
1201
- if (key.alg && key.alg !== alg) return false;
1202
- if (key.use && key.use !== "sig") return false;
1203
- if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
1204
- if (alg.startsWith("RS") && key.kty !== "RSA") return false;
1205
- if (alg.startsWith("ES") && key.kty !== "EC") return false;
1206
- return true;
1207
- });
1208
- if (kid) {
1209
- const picked = matching[0];
1210
- if (!picked) return err({
1211
- reason: "no_matching_key",
1212
- kid
1213
- });
1214
- return ok(picked);
1454
+ function cacheTtlSeconds(response) {
1455
+ const cacheControl = response.headers.get("Cache-Control");
1456
+ if (cacheControl === null || /(?:^|,)\s*(?:no-cache|no-store|private)\b/i.test(cacheControl)) return void 0;
1457
+ const directive = /(?:^|,)\s*s-maxage\s*=\s*"?(\d+)/i.exec(cacheControl) ?? /(?:^|,)\s*max-age\s*=\s*"?(\d+)/i.exec(cacheControl);
1458
+ if (!directive) return void 0;
1459
+ const ttl = Math.min(Number(directive[1]), CIMD_CACHE_MAX_TTL_SECONDS);
1460
+ return ttl > 0 ? ttl : void 0;
1461
+ }
1462
+ async function cacheValidatedDocument(cache, metadataUrl, response, bytes) {
1463
+ if (!cache) return;
1464
+ const ttl = cacheTtlSeconds(response);
1465
+ if (ttl === void 0) return;
1466
+ const headers = new Headers({ "Cache-Control": `public, max-age=${ttl}` });
1467
+ for (const name of [
1468
+ "Content-Type",
1469
+ "ETag",
1470
+ "Last-Modified"
1471
+ ]) {
1472
+ const value = response.headers.get(name);
1473
+ if (value !== null) headers.set(name, value);
1215
1474
  }
1216
- if (matching.length !== 1) return err({ reason: "no_matching_key" });
1217
- return ok(matching[0]);
1475
+ try {
1476
+ await cache.put(metadataUrl, new Response(bytes, {
1477
+ status: 200,
1478
+ headers
1479
+ }));
1480
+ } catch {}
1218
1481
  }
1219
1482
  /**
1220
- * Verify an ID-JAG's compact-JWS signature using WebCrypto.
1221
- *
1222
- * Returns `false` on any WebCrypto-level failure (import or verify).
1223
- * The caller is responsible for mapping `false` to the appropriate
1224
- * `EmaValidationError`.
1483
+ * Resolves a previously validated cache entry. A stored document that stops
1484
+ * validating (changed server capabilities, stricter rules after an upgrade)
1485
+ * is evicted so the caller re-resolves from origin in the same request;
1486
+ * timeout aborts propagate instead.
1225
1487
  */
1226
- async function verifyIdJagSignature(input) {
1488
+ async function tryResolveFromCache(cache, metadataUrl, server, signal) {
1489
+ let cached;
1227
1490
  try {
1228
- const { importAlgorithm, verifyAlgorithm } = getJwtCryptoAlgorithms(input.alg);
1229
- const key = await crypto.subtle.importKey("jwk", input.jwk, importAlgorithm, false, ["verify"]);
1230
- return await crypto.subtle.verify(verifyAlgorithm, key, input.signature, input.signingInput);
1491
+ cached = await cache?.match(metadataUrl);
1231
1492
  } catch {
1232
- return false;
1493
+ return;
1494
+ }
1495
+ if (!cached) return void 0;
1496
+ try {
1497
+ const { value } = await readJsonWithSizeLimit(cached, CIMD_MAX_SIZE_BYTES, signal);
1498
+ return resolveClientIdMetadataDocument(metadataUrl, value, server);
1499
+ } catch (error) {
1500
+ if (signal.aborted) throw error;
1501
+ try {
1502
+ await cache?.delete(metadataUrl);
1503
+ } catch {}
1504
+ return;
1233
1505
  }
1234
1506
  }
1235
1507
  /**
1236
- * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
1508
+ * Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
1509
+ * Document into the single effective client configuration used by OAuth flows.
1237
1510
  */
1238
- function getJwtCryptoAlgorithms(alg) {
1239
- if (alg === "RS256") {
1240
- const algorithm = {
1241
- name: "RSASSA-PKCS1-v1_5",
1242
- hash: "SHA-256"
1243
- };
1244
- return {
1245
- importAlgorithm: algorithm,
1246
- verifyAlgorithm: algorithm
1247
- };
1511
+ async function fetchClientIdMetadataDocument(metadataUrl, server) {
1512
+ validateClientIdentifierUrl(metadataUrl);
1513
+ const abortController = new AbortController();
1514
+ const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
1515
+ try {
1516
+ const cache = await openCimdCache();
1517
+ const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
1518
+ if (cachedDocument) return cachedDocument;
1519
+ const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
1520
+ if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
1521
+ const { value, bytes } = await readJsonWithSizeLimit(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
1522
+ const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
1523
+ clearTimeout(timeoutId);
1524
+ await cacheValidatedDocument(cache, metadataUrl, response, bytes);
1525
+ return resolved;
1526
+ } catch (error) {
1527
+ if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
1528
+ throw error;
1529
+ } finally {
1530
+ clearTimeout(timeoutId);
1248
1531
  }
1249
- if (alg === "ES256") return {
1250
- importAlgorithm: {
1251
- name: "ECDSA",
1252
- namedCurve: "P-256"
1253
- },
1254
- verifyAlgorithm: {
1255
- name: "ECDSA",
1256
- hash: "SHA-256"
1257
- }
1258
- };
1259
- throw new Error(`Unsupported JWT alg: ${alg}`);
1260
1532
  }
1261
1533
 
1262
1534
  //#endregion
@@ -1289,9 +1561,11 @@ function validateIdJagHeader(header, expectedTyp, supportedAlgs) {
1289
1561
  * validate the returned configuration before signature verification ever
1290
1562
  * runs against it.
1291
1563
  *
1292
- * Every failure here collapses to `issuer_not_trusted` so an attacker
1564
+ * Every trust failure here collapses to `issuer_not_trusted` so an attacker
1293
1565
  * cannot distinguish "unknown IdP" from "resolver returned a malformed
1294
- * config" from "alg not in the IdP's allowlist".
1566
+ * config". An `alg` outside the issuer's allowlist is reported as
1567
+ * `invalid_alg` so the deployer can see it in `onError`; both reach the
1568
+ * client as the same generic `invalid_grant` "Invalid assertion".
1295
1569
  *
1296
1570
  * The resolver's returned `issuer` field MUST equal the input `iss`.
1297
1571
  * Otherwise a buggy resolver could be tricked into returning a config
@@ -1332,8 +1606,8 @@ async function resolveTrustedIssuer(input) {
1332
1606
  iss
1333
1607
  });
1334
1608
  if (!(resolved.algorithms ?? [EMA_DEFAULT_JWT_ALGORITHM]).includes(alg)) return err({
1335
- reason: "issuer_not_trusted",
1336
- iss
1609
+ reason: "invalid_alg",
1610
+ got: alg
1337
1611
  });
1338
1612
  return ok(resolved);
1339
1613
  }
@@ -2172,11 +2446,19 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
2172
2446
  const DEFAULT_AUTHORIZE_PATH = "/authorize";
2173
2447
  const DEFAULT_TOKEN_PATH = "/oauth/token";
2174
2448
  function toPublicClientInfo(client) {
2175
- const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
2449
+ const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, tokenEndpointAuthMethods: _methods, clientAssertionKeys: _keys, ...publicClient } = client;
2176
2450
  return publicClient;
2177
2451
  }
2452
+ /** Token endpoint parameters that carry client authentication (RFC 6749 §2.3, RFC 7521 §4.2). */
2453
+ const CLIENT_AUTHENTICATION_PARAMETERS = new Set([
2454
+ "client_id",
2455
+ "client_secret",
2456
+ "client_assertion",
2457
+ "client_assertion_type"
2458
+ ]);
2178
2459
  function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
2179
2460
  if (presentedMethod === client.tokenEndpointAuthMethod) return true;
2461
+ if (isClientMetadataDocument && client.tokenEndpointAuthMethods?.includes(presentedMethod)) return true;
2180
2462
  const isSecretMethod = (method) => method === "client_secret_basic" || method === "client_secret_post";
2181
2463
  return !isClientMetadataDocument && client.authMethodExplicit === void 0 && isSecretMethod(client.tokenEndpointAuthMethod) && isSecretMethod(presentedMethod);
2182
2464
  }
@@ -2405,6 +2687,7 @@ var OAuthProviderImpl = class {
2405
2687
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
2406
2688
  this.jtiStore = createKvJtiStore();
2407
2689
  }
2690
+ if (this.options.clientIdMetadataDocumentEnabled) this.clientAssertionVerifier = createClientAssertionVerifier();
2408
2691
  }
2409
2692
  /**
2410
2693
  * Validates that an endpoint is either an absolute path or a full URL
@@ -2618,7 +2901,7 @@ var OAuthProviderImpl = class {
2618
2901
  if (parsed instanceof Response) return withCorsHeaders(parsed, request);
2619
2902
  let response;
2620
2903
  if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
2621
- else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
2904
+ else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, parsed.clientAuthMethod, env, url, request);
2622
2905
  if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
2623
2906
  return withCorsHeaders(response, request);
2624
2907
  }
@@ -2804,10 +3087,11 @@ var OAuthProviderImpl = class {
2804
3087
  });
2805
3088
  }
2806
3089
  const processedKeys = /* @__PURE__ */ new Set();
2807
- for (const [key, value] of formData.entries()) {
3090
+ for (const key of formData.keys()) {
2808
3091
  if (processedKeys.has(key)) continue;
2809
3092
  processedKeys.add(key);
2810
- const allValues = formData.getAll(key);
3093
+ const allValues = CLIENT_AUTHENTICATION_PARAMETERS.has(key) ? formData.getAll(key).filter((entry) => entry !== "") : formData.getAll(key);
3094
+ if (allValues.length === 0) continue;
2811
3095
  if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
2812
3096
  description: `Request parameter "${key}" must not be repeated`,
2813
3097
  statusCode: 400
@@ -2816,14 +3100,16 @@ var OAuthProviderImpl = class {
2816
3100
  reason: "repeated_parameter",
2817
3101
  detail: { parameter: key }
2818
3102
  });
2819
- body[key] = allValues.length > 1 ? allValues : value;
3103
+ body[key] = allValues.length > 1 ? allValues : allValues[0];
2820
3104
  }
2821
3105
  const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
2822
3106
  const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
3107
+ const sent = (name) => body[name] !== void 0;
3108
+ const clientAssertionAttempted = sent("client_assertion") || sent("client_assertion_type");
2823
3109
  let clientId = "";
2824
3110
  let clientSecret = "";
2825
3111
  if (basicAuthenticationAttempted) {
2826
- if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
3112
+ if (sent("client_id") || sent("client_secret") || clientAssertionAttempted) return this.createErrorResponse("invalid_request", {
2827
3113
  description: "Client must not use multiple authentication methods",
2828
3114
  statusCode: 400
2829
3115
  }, {
@@ -2836,6 +3122,22 @@ var OAuthProviderImpl = class {
2836
3122
  });
2837
3123
  clientId = basicAuthorization.clientId;
2838
3124
  clientSecret = basicAuthorization.clientSecret;
3125
+ } else if (clientAssertionAttempted) {
3126
+ if (sent("client_secret")) return this.createErrorResponse("invalid_request", {
3127
+ description: "Client must not use multiple authentication methods",
3128
+ statusCode: 400
3129
+ }, {
3130
+ category: "client-authentication",
3131
+ reason: "multiple_authentication_methods"
3132
+ });
3133
+ if (!sent("client_assertion") || !sent("client_assertion_type")) return this.createErrorResponse("invalid_request", {
3134
+ description: "client_assertion and client_assertion_type must be sent together",
3135
+ statusCode: 400
3136
+ }, {
3137
+ category: "client-authentication",
3138
+ reason: "client_assertion_incomplete"
3139
+ });
3140
+ clientId = body.client_id || readClientAssertionIssuer(body.client_assertion) || "";
2839
3141
  } else {
2840
3142
  clientId = body.client_id;
2841
3143
  clientSecret = body.client_secret || "";
@@ -2862,7 +3164,7 @@ var OAuthProviderImpl = class {
2862
3164
  category: "client-authentication",
2863
3165
  reason: "client_not_found"
2864
3166
  });
2865
- const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
3167
+ const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : clientAssertionAttempted ? "private_key_jwt" : sent("client_secret") ? "client_secret_post" : "none";
2866
3168
  const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
2867
3169
  if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
2868
3170
  category: "client-authentication",
@@ -2873,7 +3175,10 @@ var OAuthProviderImpl = class {
2873
3175
  presentedMethod: presentedAuthMethod
2874
3176
  }
2875
3177
  });
2876
- if (presentedAuthMethod !== "none") {
3178
+ if (presentedAuthMethod === "private_key_jwt") {
3179
+ const failure = await this.verifyClientAssertion(body, clientInfo, env, request);
3180
+ if (failure) return failure;
3181
+ } else if (presentedAuthMethod !== "none") {
2877
3182
  if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
2878
3183
  category: "client-authentication",
2879
3184
  reason: "client_secret_missing"
@@ -2891,10 +3196,47 @@ var OAuthProviderImpl = class {
2891
3196
  return {
2892
3197
  body,
2893
3198
  clientInfo,
3199
+ clientAuthMethod: presentedAuthMethod,
2894
3200
  isRevocationRequest
2895
3201
  };
2896
3202
  }
2897
3203
  /**
3204
+ * Authenticates a `private_key_jwt` request (RFC 7523 §2.2). The method check has already
3205
+ * confirmed that the client registered the method, which only a CIMD client with usable keys can.
3206
+ * @returns An `invalid_client` response, or `undefined` once the assertion verified and was consumed
3207
+ */
3208
+ async verifyClientAssertion(body, clientInfo, env, request) {
3209
+ const keys = clientInfo.clientAssertionKeys;
3210
+ const verifier = this.clientAssertionVerifier;
3211
+ if (!keys || !verifier) throw new Error("private_key_jwt client resolved without keys or a verifier");
3212
+ if (body.client_assertion_type !== JWT_BEARER_CLIENT_ASSERTION_TYPE) return this.createInvalidClientResponse("Client authentication failed", false, {
3213
+ category: "client-authentication",
3214
+ reason: "client_assertion_type_unsupported",
3215
+ detail: {
3216
+ clientId: clientInfo.clientId,
3217
+ clientAssertionType: String(body.client_assertion_type)
3218
+ }
3219
+ });
3220
+ const requestUrl = new URL(request.url);
3221
+ const verified = await verifier.verify({
3222
+ assertion: body.client_assertion,
3223
+ clientId: clientInfo.clientId,
3224
+ keys,
3225
+ issuer: this.getAuthorizationServerIssuer(requestUrl),
3226
+ tokenEndpoint: this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl),
3227
+ env
3228
+ });
3229
+ if (verified.ok) return void 0;
3230
+ return this.createInvalidClientResponse("Client authentication failed", false, {
3231
+ category: "client-authentication",
3232
+ reason: "client_assertion_invalid",
3233
+ detail: {
3234
+ clientId: clientInfo.clientId,
3235
+ ...verified.error
3236
+ }
3237
+ }, request);
3238
+ }
3239
+ /**
2898
3240
  * Checks if a URL matches a specific API route
2899
3241
  * @param url - The URL to check
2900
3242
  * @param route - The API route to check against
@@ -2953,6 +3295,7 @@ var OAuthProviderImpl = class {
2953
3295
  const responseTypesSupported = this.serverCapabilities.responseTypes;
2954
3296
  const grantTypesSupported = this.serverCapabilities.grantTypes;
2955
3297
  const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
3298
+ const cimdSupported = !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic();
2956
3299
  const metadata = {
2957
3300
  issuer: this.getAuthorizationServerIssuer(requestUrl),
2958
3301
  authorization_endpoint: authorizeEndpoint,
@@ -2964,11 +3307,12 @@ var OAuthProviderImpl = class {
2964
3307
  response_modes_supported: ["query"],
2965
3308
  grant_types_supported: grantTypesSupported,
2966
3309
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
2967
- token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
3310
+ token_endpoint_auth_methods_supported: [...this.serverCapabilities.tokenEndpointAuthMethods, ...cimdSupported ? ["private_key_jwt"] : []],
3311
+ ...cimdSupported ? { token_endpoint_auth_signing_alg_values_supported: [...CLIENT_ASSERTION_ALGORITHMS] } : {},
2968
3312
  revocation_endpoint: tokenEndpoint,
2969
3313
  code_challenge_methods_supported: this.serverCapabilities.codeChallengeMethods,
2970
3314
  authorization_response_iss_parameter_supported: true,
2971
- client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
3315
+ client_id_metadata_document_supported: cimdSupported
2972
3316
  };
2973
3317
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
2974
3318
  }
@@ -3007,7 +3351,7 @@ var OAuthProviderImpl = class {
3007
3351
  * @param env - Cloudflare Worker environment variables
3008
3352
  * @returns Response with token data or error
3009
3353
  */
3010
- async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
3354
+ async handleTokenRequest(body, clientInfo, clientAuthMethod, env, requestUrl, request) {
3011
3355
  try {
3012
3356
  const grantType = body.grant_type;
3013
3357
  if (!(grantType === GrantType.AUTHORIZATION_CODE || grantType === GrantType.REFRESH_TOKEN || grantType === GrantType.TOKEN_EXCHANGE && !!this.options.allowTokenExchangeGrant || grantType === GrantType.JWT_BEARER && !!this.options.enterpriseManagedAuthorization)) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
@@ -3025,7 +3369,7 @@ var OAuthProviderImpl = class {
3025
3369
  if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
3026
3370
  else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
3027
3371
  else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
3028
- else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
3372
+ else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, clientAuthMethod, env, requestUrl, request);
3029
3373
  throw new Error("Unreachable supported grant type");
3030
3374
  } catch (error) {
3031
3375
  const response = this.createOAuthErrorResponse(error);
@@ -3663,13 +4007,13 @@ var OAuthProviderImpl = class {
3663
4007
  * standard OAuth wire response. All validation logic lives in pure
3664
4008
  * functions in `src/ema/`.
3665
4009
  */
3666
- async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
4010
+ async handleJwtBearerGrant(body, clientInfo, clientAuthMethod, env, requestUrl, request) {
3667
4011
  const enterpriseOptions = this.options.enterpriseManagedAuthorization;
3668
4012
  if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
3669
4013
  category: "enterprise-managed-authorization",
3670
4014
  reason: "grant_disabled"
3671
4015
  });
3672
- if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
4016
+ if (clientAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
3673
4017
  description: "Enterprise-managed authorization requires client authentication",
3674
4018
  statusCode: 401
3675
4019
  }, {