@better-auth/cimd 1.7.0-rc.2 → 1.7.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,47 +1,41 @@
1
- import { checkOAuthClient, extendOAuthProvider, oauthToSchema } from "@better-auth/oauth-provider";
2
- import { BetterAuthError } from "@better-auth/core/error";
3
- import { toExpJWT } from "better-auth/plugins";
1
+ import { extendOAuthProvider, oauthClientMetadataSchema } from "@better-auth/oauth-provider";
2
+ import { isForbiddenCimdClientMetadataField, registerClientMetadataDocument, validatePublicClientJwks } from "@better-auth/oauth-provider/internal";
4
3
  import { APIError } from "better-call";
5
4
  import { isLoopbackHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
5
+ import { isReverseDomainPrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri";
6
+ import { BetterAuthError } from "@better-auth/core/error";
7
+ import { toExpJWT } from "better-auth/plugins";
6
8
  //#region src/validate-metadata-document.ts
7
9
  const DOT_SEGMENT_RE = /\/(?:\.|%2e)(?:\.|%2e)?(?:\/|$|#|\?)/i;
8
- const PROHIBITED_FIELDS = /* @__PURE__ */ new Set(["client_secret", "client_secret_expires_at"]);
9
10
  const SYMMETRIC_AUTH_METHODS = /* @__PURE__ */ new Set([
10
11
  "client_secret_post",
11
12
  "client_secret_basic",
12
13
  "client_secret_jwt"
13
14
  ]);
14
- const ALLOWED_GRANT_TYPES = /* @__PURE__ */ new Set(["authorization_code", "refresh_token"]);
15
- const ALLOWED_RESPONSE_TYPES = /* @__PURE__ */ new Set(["code"]);
16
15
  /**
17
16
  * Detect a URL-formatted client_id (Client ID Metadata Document pattern).
18
17
  *
19
- * HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
20
- * when `allowLoopback` is set. This is a routing predicate, not a security
21
- * gate: it performs no DNS resolution, so callers MUST also run
18
+ * HTTPS URLs match. This is a routing predicate, not a security gate: it
19
+ * performs no DNS resolution, so callers MUST also run
22
20
  * {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
23
21
  */
24
- function isUrlClientId(clientId, options) {
22
+ function isCimdClientIdUrlCandidate(clientId) {
25
23
  let parsed;
26
24
  try {
27
25
  parsed = new URL(clientId);
28
26
  } catch {
29
27
  return false;
30
28
  }
31
- if (parsed.protocol === "https:") return true;
32
- if (parsed.protocol !== "http:") return false;
33
- if (!options?.allowLoopback) return false;
34
- return isLoopbackHost(parsed.hostname);
29
+ return parsed.protocol === "https:";
35
30
  }
36
31
  /**
37
- * Validate a client_id URL per IETF draft §3.
32
+ * Validate a client_id URL per Client ID Metadata Document draft-02 §3.
38
33
  * Returns null on success, an error string on failure.
39
34
  *
40
- * Loopback hosts are rejected unless `allowLoopback` is set; every other
41
- * non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
42
- * rejected.
35
+ * Loopback and every other non-public host (private, link-local,
36
+ * cloud-metadata, IPv6 tunnels) are rejected.
43
37
  */
44
- function validateClientIdUrl(url, options) {
38
+ function validateClientIdUrl(url) {
45
39
  if (DOT_SEGMENT_RE.test(url)) return "client_id URL MUST NOT contain dot segments";
46
40
  if (url.includes("#")) return "client_id URL MUST NOT contain a fragment";
47
41
  let parsed;
@@ -50,28 +44,30 @@ function validateClientIdUrl(url, options) {
50
44
  } catch {
51
45
  return "client_id is not a valid URL";
52
46
  }
53
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "client_id URL must use HTTPS";
47
+ if (parsed.protocol !== "https:") return "client_id URL must use HTTPS";
48
+ const httpsAuthorityPrefix = /^https:\/\//i.exec(url);
49
+ if (!httpsAuthorityPrefix || url.includes("\\")) return "client_id URL MUST use an explicit HTTPS authority form";
50
+ const authorityAndSuffix = url.slice(httpsAuthorityPrefix[0].length);
51
+ const firstPathOrSuffixDelimiter = authorityAndSuffix.search(/[/?#]/);
52
+ if (firstPathOrSuffixDelimiter === 0) return "client_id URL MUST use an explicit HTTPS authority form";
53
+ if (firstPathOrSuffixDelimiter < 0 || authorityAndSuffix[firstPathOrSuffixDelimiter] !== "/") return "client_id URL MUST contain an explicit path component";
54
54
  if (parsed.username || parsed.password) return "client_id URL MUST NOT contain credentials";
55
- if (parsed.pathname === "/" || parsed.pathname === "") return "client_id URL MUST contain a path component";
56
- if (isLoopbackHost(parsed.hostname)) {
57
- if (!options?.allowLoopback) return "client_id URL must not target a loopback address (set allowLoopback to enable local development)";
58
- return null;
59
- }
60
- if (parsed.protocol !== "https:") return "client_id URL must use HTTPS (HTTP is allowed only for loopback in development)";
61
55
  if (!isPublicRoutableHost(parsed.hostname)) return "client_id URL must not target a private or reserved address";
62
56
  return null;
63
57
  }
64
- /** Warning: §3 SHOULD NOT have a query string. */
65
- function checkUrlQueryWarning(url) {
58
+ function getClientIdUrlWarnings(url) {
59
+ const warnings = [];
66
60
  try {
67
- if (new URL(url).search) return "client_id URL SHOULD NOT contain a query string (§3)";
61
+ const parsed = new URL(url);
62
+ if (parsed.pathname === "/") warnings.push("client_id URL path / is NOT RECOMMENDED (§3)");
63
+ if (parsed.search) warnings.push("client_id URL SHOULD NOT contain a query string (§3)");
68
64
  } catch {}
69
- return null;
65
+ return warnings;
70
66
  }
71
- function isAbsoluteHttpUri(uri) {
67
+ function isAbsoluteRedirectUri(uri) {
72
68
  try {
73
69
  const parsed = new URL(uri);
74
- return parsed.protocol === "http:" || parsed.protocol === "https:";
70
+ return parsed.protocol === "http:" || parsed.protocol === "https:" || isReverseDomainPrivateUseRedirectUri(parsed);
75
71
  } catch {
76
72
  return false;
77
73
  }
@@ -79,30 +75,42 @@ function isAbsoluteHttpUri(uri) {
79
75
  /**
80
76
  * Validate a fetched Client ID Metadata Document per §4.1.
81
77
  *
82
- * @param fetchUrl - The URL the document was fetched from.
78
+ * @param clientIdUrl - The URL the document was fetched from.
83
79
  * @param raw - The parsed JSON body of the response.
84
- * @param originBoundFields - Fields whose URL values must share the same origin as the `client_id` URL.
80
+ * @param options - Generic draft-02 validation options and an optional protocol profile.
85
81
  */
86
- function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
82
+ function validateCimdMetadata(clientIdUrl, raw, options = {}) {
87
83
  if (!raw || typeof raw !== "object") return {
88
84
  valid: false,
89
85
  error: "metadata document is not a JSON object"
90
86
  };
91
- const doc = raw;
87
+ for (const field of Object.keys(raw)) if (isForbiddenCimdClientMetadataField(field)) return {
88
+ valid: false,
89
+ error: `metadata document MUST NOT contain "${field}"`
90
+ };
91
+ const parsedMetadata = oauthClientMetadataSchema.strip().safeParse(raw);
92
+ if (!parsedMetadata.success) {
93
+ const issue = parsedMetadata.error.issues[0];
94
+ return {
95
+ valid: false,
96
+ error: `${issue?.path.join(".") || "metadata document"}: ${issue?.message ?? "invalid client metadata"}`
97
+ };
98
+ }
99
+ const doc = parsedMetadata.data;
92
100
  const warnings = [];
93
- if (doc.client_id !== fetchUrl) return {
101
+ if (doc.client_id !== clientIdUrl) return {
94
102
  valid: false,
95
103
  error: `client_id "${String(doc.client_id)}" does not match the metadata document URL`
96
104
  };
97
- for (const field of PROHIBITED_FIELDS) if (field in doc) return {
105
+ if (options.metadataProfile === "mcp-2026-07-28" && !doc.client_name?.trim()) return {
98
106
  valid: false,
99
- error: `metadata document MUST NOT contain "${field}"`
107
+ error: "client_name must be a non-empty string"
100
108
  };
101
- const ALLOWED_AUTH_METHODS = /* @__PURE__ */ new Set(["none", "private_key_jwt"]);
102
- if (doc.token_endpoint_auth_method !== void 0 && typeof doc.token_endpoint_auth_method !== "string") return {
109
+ for (const field of ["backchannel_logout_uri", "backchannel_logout_session_required"]) if (doc[field] !== void 0) return {
103
110
  valid: false,
104
- error: "token_endpoint_auth_method must be a string"
111
+ error: `metadata document MUST NOT contain "${field}"`
105
112
  };
113
+ const ALLOWED_AUTH_METHODS = /* @__PURE__ */ new Set(["none", "private_key_jwt"]);
106
114
  if (typeof doc.token_endpoint_auth_method === "string") {
107
115
  if (SYMMETRIC_AUTH_METHODS.has(doc.token_endpoint_auth_method)) return {
108
116
  valid: false,
@@ -117,19 +125,42 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
117
125
  error: "private_key_jwt requires either jwks or jwks_uri in the metadata document"
118
126
  };
119
127
  }
120
- if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0 || !doc.redirect_uris.every((uri) => typeof uri === "string" && isAbsoluteHttpUri(uri))) return {
121
- valid: false,
122
- error: "redirect_uris must be a non-empty array of absolute HTTP(S) URIs"
123
- };
124
- if (doc.grant_types !== void 0 && !(Array.isArray(doc.grant_types) && doc.grant_types.every((g) => typeof g === "string" && ALLOWED_GRANT_TYPES.has(g)))) return {
128
+ if (doc.jwks) {
129
+ if (!validatePublicClientJwks(doc.jwks).valid) return {
130
+ valid: false,
131
+ error: "jwks must contain only structurally valid public keys"
132
+ };
133
+ }
134
+ if (doc.jwks_uri) try {
135
+ const jwksUri = new URL(doc.jwks_uri);
136
+ if (jwksUri.username || jwksUri.password) return {
137
+ valid: false,
138
+ error: "jwks_uri must not contain credentials"
139
+ };
140
+ if (doc.jwks_uri.includes("#")) return {
141
+ valid: false,
142
+ error: "jwks_uri must not contain a fragment"
143
+ };
144
+ } catch {
145
+ return {
146
+ valid: false,
147
+ error: "jwks_uri must be a valid URL"
148
+ };
149
+ }
150
+ if (options.metadataProfile === "mcp-2026-07-28" && !doc.redirect_uris) return {
125
151
  valid: false,
126
- error: `grant_types must be a subset of [${[...ALLOWED_GRANT_TYPES].map((g) => `"${g}"`).join(", ")}]`
152
+ error: "redirect_uris must be a non-empty array of absolute HTTP(S) or private-use URIs"
127
153
  };
128
- if (doc.response_types !== void 0 && !(Array.isArray(doc.response_types) && doc.response_types.every((r) => typeof r === "string" && ALLOWED_RESPONSE_TYPES.has(r)))) return {
154
+ if (doc.redirect_uris && !doc.redirect_uris.every((uri) => isAbsoluteRedirectUri(uri))) return {
129
155
  valid: false,
130
- error: "response_types must be a subset of [\"code\"]"
156
+ error: "redirect_uris must be a non-empty array of absolute HTTP(S) or private-use URIs"
131
157
  };
132
- for (const field of ["client_uri", "logo_uri"]) {
158
+ for (const field of [
159
+ "client_uri",
160
+ "logo_uri",
161
+ "tos_uri",
162
+ "policy_uri"
163
+ ]) {
133
164
  if (doc[field] !== void 0 && typeof doc[field] !== "string") return {
134
165
  valid: false,
135
166
  error: `${field} must be a string`
@@ -140,6 +171,10 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
140
171
  valid: false,
141
172
  error: `${field} must use HTTP(S)`
142
173
  };
174
+ if (parsed.username || parsed.password) return {
175
+ valid: false,
176
+ error: `${field} must not contain credentials`
177
+ };
143
178
  if (!isPublicRoutableHost(parsed.hostname)) return {
144
179
  valid: false,
145
180
  error: `${field} must not point to a private or reserved address`
@@ -151,14 +186,10 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
151
186
  };
152
187
  }
153
188
  }
154
- const fieldsToCheck = originBoundFields ?? [
155
- "redirect_uris",
156
- "post_logout_redirect_uris",
157
- "client_uri"
158
- ];
189
+ const fieldsToCheck = options.originBoundFields ?? ["post_logout_redirect_uris", "client_uri"];
159
190
  let clientIdOrigin;
160
191
  try {
161
- clientIdOrigin = new URL(fetchUrl).origin;
192
+ clientIdOrigin = new URL(clientIdUrl).origin;
162
193
  } catch {
163
194
  return {
164
195
  valid: false,
@@ -190,21 +221,27 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
190
221
  error: `${key} contains an invalid URL: "${val}"`
191
222
  };
192
223
  }
193
- if (uri.protocol !== "https:" && uri.protocol !== "http:") return {
224
+ const isRedirectField = key === "redirect_uris" || key === "post_logout_redirect_uris";
225
+ const isPrivateUseRedirect = isRedirectField && isReverseDomainPrivateUseRedirectUri(uri);
226
+ if (uri.protocol !== "https:" && uri.protocol !== "http:" && !isPrivateUseRedirect) return {
194
227
  valid: false,
195
- error: `all values for ${key} must use HTTP(S)`
228
+ error: `all values for ${key} must use HTTP(S) or an authority-free private-use scheme`
196
229
  };
197
- const localhostAllowed = (key === "redirect_uris" || key === "post_logout_redirect_uris") && isLoopbackHost(uri.hostname);
230
+ if (isPrivateUseRedirect) continue;
231
+ const localhostAllowed = isRedirectField && isLoopbackHost(uri.hostname);
198
232
  if (uri.origin !== clientIdOrigin && !localhostAllowed) return {
199
233
  valid: false,
200
234
  error: `${key} value "${val}" must have the same origin as client_id (${clientIdOrigin})`
201
235
  };
202
236
  }
203
237
  }
204
- const queryWarning = checkUrlQueryWarning(fetchUrl);
205
- if (queryWarning) warnings.push(queryWarning);
238
+ warnings.push(...getClientIdUrlWarnings(clientIdUrl));
206
239
  return {
207
240
  valid: true,
241
+ metadata: {
242
+ ...doc,
243
+ token_endpoint_auth_method: doc.token_endpoint_auth_method ?? "none"
244
+ },
208
245
  ...warnings.length > 0 ? { warnings } : {}
209
246
  };
210
247
  }
@@ -212,311 +249,463 @@ function validateCimdMetadata(fetchUrl, raw, originBoundFields) {
212
249
  //#region src/client-store.ts
213
250
  const FETCH_TIMEOUT_MS = 5e3;
214
251
  const MAX_RESPONSE_BYTES = 5 * 1024;
215
- /**
216
- * Accepts `application/json` and the draft's `application/<AS-defined>+json`
217
- * form. Parameters (charset, etc.) are allowed after the subtype.
218
- */
219
252
  const JSON_CONTENT_TYPE_RE = /^application\/(?:[-\w.]+\+)?json\s*(?:;|$)/i;
220
- function tooLargeError() {
253
+ const FETCH_FAILURE_DESCRIPTION = "Failed to fetch metadata document (network error or redirect blocked)";
254
+ const CIMD_CLIENT_DISCOVERY_ID = "cimd";
255
+ function invalidClient$1(description) {
221
256
  return new APIError("BAD_REQUEST", {
222
257
  error: "invalid_client",
223
- error_description: `Metadata document exceeds ${MAX_RESPONSE_BYTES / 1024}KB size limit`
258
+ error_description: description
224
259
  });
225
260
  }
226
- /**
227
- * Stream a Response body into a decoded string, aborting as soon as the
228
- * running byte count exceeds `max`. Guarantees no more than `max + one
229
- * chunk` bytes ever sit in memory before the request is canceled.
230
- */
231
- async function readBodyWithLimit(response, max) {
261
+ function tooLargeError() {
262
+ return invalidClient$1(`Metadata document exceeds ${MAX_RESPONSE_BYTES / 1024}KB size limit`);
263
+ }
264
+ async function readBodyWithLimit(response, maximumBytes, signal) {
232
265
  const reader = response.body?.getReader();
233
266
  if (!reader) {
234
267
  const text = await response.text();
235
- if (new TextEncoder().encode(text).byteLength > max) throw tooLargeError();
268
+ if (new TextEncoder().encode(text).byteLength > maximumBytes) throw tooLargeError();
236
269
  return text;
237
270
  }
238
271
  const chunks = [];
239
- let total = 0;
240
- while (true) {
241
- const { done, value } = await reader.read();
242
- if (done) break;
243
- total += value.byteLength;
244
- if (total > max) {
245
- await reader.cancel();
246
- throw tooLargeError();
272
+ let byteLength = 0;
273
+ let rejectOnAbort;
274
+ const aborted = new Promise((_resolve, reject) => {
275
+ rejectOnAbort = reject;
276
+ });
277
+ const abortRead = () => {
278
+ rejectOnAbort?.(/* @__PURE__ */ new Error("Metadata document body read aborted"));
279
+ reader.cancel().catch(() => {});
280
+ };
281
+ if (signal.aborted) abortRead();
282
+ else signal.addEventListener("abort", abortRead, { once: true });
283
+ try {
284
+ while (true) {
285
+ const { done, value } = await Promise.race([reader.read(), aborted]);
286
+ if (done) break;
287
+ byteLength += value.byteLength;
288
+ if (byteLength > maximumBytes) {
289
+ await reader.cancel();
290
+ throw tooLargeError();
291
+ }
292
+ chunks.push(value);
247
293
  }
248
- chunks.push(value);
294
+ } finally {
295
+ signal.removeEventListener("abort", abortRead);
296
+ reader.releaseLock();
249
297
  }
250
- const merged = new Uint8Array(total);
298
+ const body = new Uint8Array(byteLength);
251
299
  let offset = 0;
252
300
  for (const chunk of chunks) {
253
- merged.set(chunk, offset);
301
+ body.set(chunk, offset);
254
302
  offset += chunk.byteLength;
255
303
  }
256
- return new TextDecoder().decode(merged);
304
+ return new TextDecoder().decode(body);
257
305
  }
258
- /**
259
- * RFC 7591 / CIMD fields accepted from external metadata documents.
260
- *
261
- * Security-sensitive fields — `require_pkce`, `disabled`, `skip_consent`,
262
- * `enable_end_session` — are deliberately excluded. An attacker-controlled
263
- * document MUST NOT be able to weaken the server's PKCE policy or escalate
264
- * admin-only flags.
265
- */
266
- const ALLOWED_METADATA_FIELDS = /* @__PURE__ */ new Set([
267
- "client_id",
268
- "redirect_uris",
269
- "token_endpoint_auth_method",
270
- "grant_types",
271
- "response_types",
272
- "client_name",
273
- "client_uri",
274
- "logo_uri",
275
- "scope",
276
- "contacts",
277
- "tos_uri",
278
- "policy_uri",
279
- "software_id",
280
- "software_version",
281
- "software_statement",
282
- "post_logout_redirect_uris",
283
- "subject_type",
284
- "type",
285
- "jwks",
286
- "jwks_uri"
287
- ]);
288
- /**
289
- * Extract only recognized RFC 7591 / CIMD fields from the metadata document.
290
- * Prevents arbitrary attacker-controlled fields from leaking into the DB.
291
- */
292
- function toOAuthClientBody(metadata) {
293
- const filtered = {};
294
- for (const key of ALLOWED_METADATA_FIELDS) if (key in metadata) filtered[key] = metadata[key];
306
+ function readResponseCacheHeaders(headers) {
295
307
  return {
296
- ...filtered,
297
- token_endpoint_auth_method: filtered.token_endpoint_auth_method ?? "none"
308
+ cacheControl: headers.get("cache-control") ?? void 0,
309
+ vary: headers.get("vary") ?? void 0,
310
+ expires: headers.get("expires") ?? void 0,
311
+ date: headers.get("date") ?? void 0,
312
+ age: headers.get("age") ?? void 0,
313
+ etag: headers.get("etag") ?? void 0,
314
+ lastModified: headers.get("last-modified") ?? void 0
298
315
  };
299
316
  }
300
317
  /**
301
- * Create a new client from a Client ID Metadata Document.
302
- * Called when a URL-format client_id is encountered for the first time.
318
+ * Fetch and validate one Client ID Metadata Document.
303
319
  *
304
- * Writes the DB record directly rather than routing through
305
- * `createOAuthClientEndpoint`, because CIMD clients must use the URL
306
- * as their `clientId` (not a generated random ID).
320
+ * Conditional validators are accepted only from the resolver's previously
321
+ * validated cache entry. A 304 response therefore carries no metadata and must
322
+ * be joined with that entry by the caller.
307
323
  */
308
- async function createMetadataDocumentClient(ctx, clientIdUrl, cimdOptions, oauthOptions) {
309
- const metadata = await fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions);
310
- const oauthClient = toOAuthClientBody(metadata);
311
- await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
312
- const isPrivateKeyJwt = oauthClient.token_endpoint_auth_method === "private_key_jwt";
313
- const iat = Math.floor(Date.now() / 1e3);
314
- const schema = oauthToSchema({
315
- ...oauthClient,
316
- disabled: void 0,
317
- skip_consent: void 0,
318
- enable_end_session: void 0,
319
- jwks: isPrivateKeyJwt ? oauthClient.jwks : void 0,
320
- jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : void 0,
321
- client_id: clientIdUrl,
322
- client_secret: void 0,
323
- client_secret_expires_at: void 0,
324
- client_id_issued_at: iat,
325
- public: !isPrivateKeyJwt
326
- });
327
- const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
328
- let client;
324
+ async function fetchClientMetadataDocument(ctx, clientIdUrl, cimdOptions, validators) {
325
+ const urlError = validateClientIdUrl(clientIdUrl);
326
+ if (urlError) throw invalidClient$1(urlError);
327
+ if (cimdOptions.isMetadataDocumentUrlAllowed) {
328
+ if (!await cimdOptions.isMetadataDocumentUrlAllowed(clientIdUrl, ctx)) throw invalidClient$1("client_id URL is not permitted by the server's fetch policy");
329
+ }
330
+ const requestHeaders = new Headers({ Accept: "application/json" });
331
+ if (validators?.etag) requestHeaders.set("If-None-Match", validators.etag);
332
+ if (validators?.lastModified) requestHeaders.set("If-Modified-Since", validators.lastModified);
333
+ let response;
334
+ const controller = new AbortController();
335
+ let didTimeOut = false;
336
+ const timeout = setTimeout(() => {
337
+ didTimeOut = true;
338
+ controller.abort();
339
+ }, FETCH_TIMEOUT_MS);
329
340
  try {
330
- client = await ctx.context.adapter.create({
331
- model,
332
- data: {
333
- ...schema,
334
- createdAt: /* @__PURE__ */ new Date(iat * 1e3),
335
- updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
341
+ try {
342
+ response = await cimdOptions.fetchClientMetadataResource(clientIdUrl, {
343
+ headers: requestHeaders,
344
+ redirect: "error",
345
+ signal: controller.signal
346
+ });
347
+ } catch {
348
+ throw invalidClient$1(didTimeOut ? `Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms` : FETCH_FAILURE_DESCRIPTION);
349
+ }
350
+ if (response.redirected) throw invalidClient$1("Metadata document fetch must not follow redirects");
351
+ const cacheHeaders = readResponseCacheHeaders(response.headers);
352
+ if (response.status === 304) {
353
+ if (!validators?.etag && !validators?.lastModified) throw invalidClient$1("Metadata document returned 304 without a conditional validator");
354
+ return {
355
+ status: "not-modified",
356
+ cacheHeaders
357
+ };
358
+ }
359
+ if (response.status !== 200) throw invalidClient$1(`Metadata document fetch returned HTTP ${response.status}`);
360
+ const contentType = response.headers.get("content-type") ?? "";
361
+ if (!JSON_CONTENT_TYPE_RE.test(contentType)) throw invalidClient$1(`Metadata document must be JSON (got Content-Type "${contentType || "(none)"}")`);
362
+ let bodyText;
363
+ try {
364
+ const contentLength = response.headers.get("content-length");
365
+ if (contentLength) {
366
+ const declaredBytes = Number.parseInt(contentLength, 10);
367
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_RESPONSE_BYTES) {
368
+ await response.body?.cancel();
369
+ throw tooLargeError();
370
+ }
336
371
  }
372
+ bodyText = await readBodyWithLimit(response, MAX_RESPONSE_BYTES, controller.signal);
373
+ } catch (error) {
374
+ if (didTimeOut) throw invalidClient$1(`Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms`);
375
+ if (error instanceof APIError) throw error;
376
+ throw invalidClient$1(FETCH_FAILURE_DESCRIPTION);
377
+ }
378
+ let rawMetadata;
379
+ try {
380
+ rawMetadata = JSON.parse(bodyText);
381
+ } catch {
382
+ throw invalidClient$1("Metadata document is not valid JSON");
383
+ }
384
+ const validation = validateCimdMetadata(clientIdUrl, rawMetadata, {
385
+ originBoundFields: cimdOptions.originBoundFields,
386
+ metadataProfile: cimdOptions.metadataProfile
337
387
  });
338
- } catch (err) {
339
- const existing = await ctx.context.adapter.findOne({
340
- model,
341
- where: [{
342
- field: "clientId",
343
- value: clientIdUrl
344
- }]
345
- });
346
- if (existing) return existing;
347
- throw err;
388
+ if (!validation.valid) throw invalidClient$1(validation.error);
389
+ for (const warning of validation.warnings ?? []) ctx.context.logger.warn(`cimd metadata document warning: ${warning}`);
390
+ return {
391
+ status: "modified",
392
+ metadata: validation.metadata,
393
+ cacheHeaders
394
+ };
395
+ } finally {
396
+ clearTimeout(timeout);
348
397
  }
349
- await cimdOptions.onClientCreated?.({
350
- client,
351
- metadata,
352
- ctx
353
- });
354
- return client;
355
- }
356
- /**
357
- * Refresh an existing client by re-fetching its metadata document.
358
- *
359
- * Admin-controlled fields (`disabled`, `skip_consent`, `enable_end_session`)
360
- * are never overwritten from the document — they are read from `existing`
361
- * and preserved so admin decisions survive a refresh.
362
- */
363
- async function refreshMetadataDocumentClient(ctx, clientIdUrl, existing, cimdOptions, oauthOptions) {
364
- const metadata = await fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions);
365
- const oauthClient = toOAuthClientBody(metadata);
366
- await checkOAuthClient(oauthClient, oauthOptions, { isRegister: true });
367
- const isPrivateKeyJwt = oauthClient.token_endpoint_auth_method === "private_key_jwt";
368
- const schema = oauthToSchema({
369
- ...oauthClient,
370
- jwks: isPrivateKeyJwt ? oauthClient.jwks : void 0,
371
- jwks_uri: isPrivateKeyJwt ? oauthClient.jwks_uri : void 0,
372
- client_id: clientIdUrl,
373
- client_secret: void 0,
374
- client_secret_expires_at: void 0,
375
- public: !isPrivateKeyJwt
376
- });
377
- const preservedAdminFields = {
378
- disabled: existing.disabled,
379
- skipConsent: existing.skipConsent,
380
- enableEndSession: existing.enableEndSession
381
- };
382
- const model = oauthOptions.schema?.oauthClient?.modelName ?? "oauthClient";
383
- const client = await ctx.context.adapter.update({
384
- model,
385
- where: [{
386
- field: "clientId",
387
- value: clientIdUrl
388
- }],
389
- update: {
390
- ...schema,
391
- ...preservedAdminFields,
392
- updatedAt: /* @__PURE__ */ new Date(Math.floor(Date.now() / 1e3) * 1e3)
393
- }
394
- });
395
- if (!client) throw new APIError("BAD_REQUEST", {
396
- error: "invalid_client",
397
- error_description: "client no longer exists"
398
- });
399
- await cimdOptions.onClientRefreshed?.({
400
- client,
401
- metadata,
402
- ctx
403
- });
404
- return client;
405
398
  }
406
- /**
407
- * Fetch a Client ID Metadata Document, validate it against the spec,
408
- * and return the parsed metadata.
409
- */
410
- async function fetchAndValidateMetadataDocument(ctx, clientIdUrl, cimdOptions) {
411
- const urlError = validateClientIdUrl(clientIdUrl, { allowLoopback: cimdOptions.allowLoopback });
412
- if (urlError) throw new APIError("BAD_REQUEST", {
413
- error: "invalid_client",
414
- error_description: urlError
399
+ async function persistMetadataDocumentClient(ctx, clientIdUrl, metadata, cimdOptions, oauthOptions, existingClient) {
400
+ const previousClient = existingClient ? { ...existingClient } : void 0;
401
+ const result = await registerClientMetadataDocument(ctx, oauthOptions, {
402
+ clientId: clientIdUrl,
403
+ clientDiscoveryId: CIMD_CLIENT_DISCOVERY_ID,
404
+ metadata: {
405
+ ...metadata,
406
+ client_id: metadata.client_id,
407
+ redirect_uris: metadata.redirect_uris ?? []
408
+ },
409
+ existingClient
415
410
  });
416
- if (cimdOptions.allowFetch) {
417
- if (!await cimdOptions.allowFetch(clientIdUrl, ctx)) throw new APIError("BAD_REQUEST", {
418
- error: "invalid_client",
419
- error_description: "client_id URL is not permitted by the server's fetch policy"
411
+ if (result.created) try {
412
+ await cimdOptions.onClientCreated?.({
413
+ client: result.client,
414
+ clientMetadataDocument: metadata,
415
+ context: ctx
420
416
  });
417
+ } catch (error) {
418
+ ctx.context.logger.error("cimd onClientCreated notification failed", error);
421
419
  }
422
- let response;
423
- try {
424
- response = await fetch(clientIdUrl, {
425
- headers: { Accept: "application/json" },
426
- redirect: "error",
427
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
428
- });
429
- } catch (err) {
430
- throw new APIError("BAD_REQUEST", {
431
- error: "invalid_client",
432
- error_description: err instanceof DOMException && err.name === "TimeoutError" ? `Metadata document fetch timed out after ${FETCH_TIMEOUT_MS}ms` : "Failed to fetch metadata document (network error or redirect blocked)"
420
+ else try {
421
+ if (previousClient) await cimdOptions.onClientRefreshed?.({
422
+ client: result.client,
423
+ previousClient,
424
+ clientMetadataDocument: metadata,
425
+ context: ctx
433
426
  });
427
+ } catch (error) {
428
+ ctx.context.logger.error("cimd onClientRefreshed notification failed", error);
434
429
  }
435
- if (!response.ok) throw new APIError("BAD_REQUEST", {
430
+ return result.client;
431
+ }
432
+ //#endregion
433
+ //#region src/resolver.ts
434
+ const FETCH_BUDGET_WINDOW_MS = 6e4;
435
+ const DEFAULT_METADATA_FETCH_POLICY = {
436
+ minimumFetchInterval: 1,
437
+ maximumConcurrentFetches: 16,
438
+ maximumConcurrentFetchesPerOrigin: 4,
439
+ maximumFetchesPerMinute: 120,
440
+ maximumFetchesPerOriginPerMinute: 30
441
+ };
442
+ function invalidClient(description) {
443
+ return new APIError("BAD_REQUEST", {
436
444
  error: "invalid_client",
437
- error_description: `Metadata document fetch returned HTTP ${response.status}`
445
+ error_description: description
438
446
  });
439
- const contentType = response.headers.get("content-type") ?? "";
440
- if (!JSON_CONTENT_TYPE_RE.test(contentType)) throw new APIError("BAD_REQUEST", {
441
- error: "invalid_client",
442
- error_description: `Metadata document must be JSON (got Content-Type "${contentType || "(none)"}")`
447
+ }
448
+ function createMetadataFetchUnavailableError(description) {
449
+ return new APIError("TOO_MANY_REQUESTS", {
450
+ error: "temporarily_unavailable",
451
+ error_description: description
443
452
  });
444
- const contentLengthHeader = response.headers.get("content-length");
445
- if (contentLengthHeader) {
446
- const declared = Number.parseInt(contentLengthHeader, 10);
447
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
448
- await response.body?.cancel();
449
- throw tooLargeError();
450
- }
453
+ }
454
+ function parseDurationMs(value, optionName) {
455
+ if (typeof value === "number") {
456
+ if (!Number.isFinite(value) || value < 0) throw new BetterAuthError(`cimd metadataFetchPolicy.${optionName} must be a non-negative number of seconds or duration string`);
457
+ return value * 1e3;
451
458
  }
452
- const bodyText = await readBodyWithLimit(response, MAX_RESPONSE_BYTES);
453
- let data;
454
459
  try {
455
- data = JSON.parse(bodyText);
460
+ const nowSeconds = Math.floor(Date.now() / 1e3);
461
+ const durationMs = (toExpJWT(value, nowSeconds) - nowSeconds) * 1e3;
462
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error("negative duration");
463
+ return durationMs;
456
464
  } catch {
457
- throw new APIError("BAD_REQUEST", {
458
- error: "invalid_client",
459
- error_description: "Metadata document is not valid JSON"
460
- });
465
+ throw new BetterAuthError(`cimd metadataFetchPolicy.${optionName} must be a non-negative number of seconds or duration string`);
461
466
  }
462
- const validation = validateCimdMetadata(clientIdUrl, data, cimdOptions.originBoundFields);
463
- if (!validation.valid) throw new APIError("BAD_REQUEST", {
464
- error: "invalid_client",
465
- error_description: validation.error ?? "Invalid metadata document"
466
- });
467
- return data;
468
467
  }
469
- //#endregion
470
- //#region src/resolver.ts
471
- function toDate(value) {
472
- if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
473
- if (typeof value === "number" && Number.isFinite(value)) {
474
- const parsed = new Date(value);
475
- return Number.isFinite(parsed.getTime()) ? parsed : null;
476
- }
477
- if (typeof value === "bigint") {
478
- const parsed = new Date(Number(value));
479
- return Number.isFinite(parsed.getTime()) ? parsed : null;
468
+ function requirePositiveInteger(value, optionName) {
469
+ if (!Number.isInteger(value) || value < 1) throw new BetterAuthError(`cimd metadataFetchPolicy.${optionName} must be a positive integer`);
470
+ return value;
471
+ }
472
+ function resolveMetadataFetchPolicy(options) {
473
+ const policy = options.metadataFetchPolicy;
474
+ return {
475
+ minimumFetchIntervalMs: parseDurationMs(policy?.minimumFetchInterval ?? DEFAULT_METADATA_FETCH_POLICY.minimumFetchInterval, "minimumFetchInterval"),
476
+ maximumConcurrentFetches: requirePositiveInteger(policy?.maximumConcurrentFetches ?? DEFAULT_METADATA_FETCH_POLICY.maximumConcurrentFetches, "maximumConcurrentFetches"),
477
+ maximumConcurrentFetchesPerOrigin: requirePositiveInteger(policy?.maximumConcurrentFetchesPerOrigin ?? DEFAULT_METADATA_FETCH_POLICY.maximumConcurrentFetchesPerOrigin, "maximumConcurrentFetchesPerOrigin"),
478
+ maximumFetchesPerMinute: requirePositiveInteger(policy?.maximumFetchesPerMinute ?? DEFAULT_METADATA_FETCH_POLICY.maximumFetchesPerMinute, "maximumFetchesPerMinute"),
479
+ maximumFetchesPerOriginPerMinute: requirePositiveInteger(policy?.maximumFetchesPerOriginPerMinute ?? DEFAULT_METADATA_FETCH_POLICY.maximumFetchesPerOriginPerMinute, "maximumFetchesPerOriginPerMinute")
480
+ };
481
+ }
482
+ function pruneFetchStartTimesMs(fetchStartTimesMs, nowMs) {
483
+ const windowStart = nowMs - FETCH_BUDGET_WINDOW_MS;
484
+ let firstRetainedIndex = 0;
485
+ while (firstRetainedIndex < fetchStartTimesMs.length && fetchStartTimesMs[firstRetainedIndex] <= windowStart) firstRetainedIndex += 1;
486
+ if (firstRetainedIndex > 0) fetchStartTimesMs.splice(0, firstRetainedIndex);
487
+ }
488
+ function resolveMetadataRevalidationIntervalMs(metadataRevalidationInterval, nowMs) {
489
+ if (typeof metadataRevalidationInterval === "number") return Math.max(0, metadataRevalidationInterval * 1e3);
490
+ const nowSeconds = Math.floor(nowMs / 1e3);
491
+ return Math.max(0, (toExpJWT(metadataRevalidationInterval, nowSeconds) - nowSeconds) * 1e3);
492
+ }
493
+ function parseCacheControl(value) {
494
+ const directives = /* @__PURE__ */ new Map();
495
+ const duplicates = /* @__PURE__ */ new Set();
496
+ for (const rawDirective of value?.split(",") ?? []) {
497
+ const [rawName, ...rawValue] = rawDirective.trim().split("=");
498
+ const name = rawName?.toLowerCase();
499
+ if (!name) continue;
500
+ if (directives.has(name)) duplicates.add(name);
501
+ const joinedValue = rawValue.join("=").trim();
502
+ directives.set(name, joinedValue ? joinedValue.replace(/^"|"$/g, "") : true);
480
503
  }
481
- if (typeof value === "string") {
482
- const asNumber = Number(value);
483
- if (Number.isFinite(asNumber)) {
484
- const parsed = new Date(asNumber);
485
- return Number.isFinite(parsed.getTime()) ? parsed : null;
486
- }
487
- const parsed = new Date(value);
488
- return Number.isFinite(parsed.getTime()) ? parsed : null;
504
+ return {
505
+ directives,
506
+ duplicates
507
+ };
508
+ }
509
+ function parseNonNegativeSeconds(value) {
510
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
511
+ const seconds = Number(value);
512
+ return Number.isSafeInteger(seconds) ? seconds : null;
513
+ }
514
+ function computeExpiresAt(cacheHeaders, metadataRevalidationInterval, nowMs) {
515
+ const { directives, duplicates } = parseCacheControl(cacheHeaders.cacheControl);
516
+ const variesByEverything = cacheHeaders.vary?.split(",").some((field) => field.trim() === "*");
517
+ if (directives.has("no-store") || directives.has("private") || variesByEverything) return {
518
+ cacheable: false,
519
+ expiresAt: nowMs
520
+ };
521
+ const operatorLifetime = resolveMetadataRevalidationIntervalMs(metadataRevalidationInterval, nowMs);
522
+ if (directives.has("no-cache")) return {
523
+ cacheable: true,
524
+ expiresAt: nowMs
525
+ };
526
+ const ageSeconds = parseNonNegativeSeconds(cacheHeaders.age) ?? 0;
527
+ const responseDate = cacheHeaders.date ? Date.parse(cacheHeaders.date) : nowMs;
528
+ const apparentAge = Number.isFinite(responseDate) ? Math.max(0, nowMs - responseDate) : 0;
529
+ const currentAge = Math.max(apparentAge, ageSeconds * 1e3);
530
+ let originLifetime = null;
531
+ const hasSharedMaxAge = directives.has("s-maxage");
532
+ const sharedMaxAge = parseNonNegativeSeconds(directives.get("s-maxage"));
533
+ const hasMaxAge = directives.has("max-age");
534
+ const privateMaxAge = parseNonNegativeSeconds(directives.get("max-age"));
535
+ const applicableFreshnessIsInvalid = hasSharedMaxAge ? sharedMaxAge === null || duplicates.has("s-maxage") : hasMaxAge && (privateMaxAge === null || duplicates.has("max-age"));
536
+ const maxAge = hasSharedMaxAge ? sharedMaxAge : privateMaxAge;
537
+ if (applicableFreshnessIsInvalid) originLifetime = 0;
538
+ else if (maxAge !== null) originLifetime = Math.max(0, maxAge * 1e3 - currentAge);
539
+ else if (cacheHeaders.expires) {
540
+ const expires = Date.parse(cacheHeaders.expires);
541
+ if (Number.isFinite(expires)) originLifetime = Math.max(0, expires - (Number.isFinite(responseDate) ? responseDate : nowMs) - currentAge);
489
542
  }
490
- return null;
543
+ return {
544
+ cacheable: true,
545
+ expiresAt: nowMs + Math.min(operatorLifetime, originLifetime ?? operatorLifetime)
546
+ };
547
+ }
548
+ function mergeCacheHeaders(previous, revalidated) {
549
+ return {
550
+ cacheControl: revalidated.cacheControl ?? previous.cacheControl,
551
+ vary: revalidated.vary ?? previous.vary,
552
+ expires: revalidated.expires ?? previous.expires,
553
+ date: revalidated.date ?? previous.date,
554
+ age: revalidated.age ?? previous.age,
555
+ etag: revalidated.etag ?? previous.etag,
556
+ lastModified: revalidated.lastModified ?? previous.lastModified
557
+ };
491
558
  }
492
- function isStale(existing, refreshRate) {
493
- const updatedAt = toDate(existing.updatedAt) ?? toDate(existing.createdAt) ?? /* @__PURE__ */ new Date(0);
494
- const updatedSec = Math.floor(updatedAt.getTime() / 1e3);
495
- return (typeof refreshRate === "number" ? updatedSec + refreshRate : toExpJWT(refreshRate, updatedSec)) < Math.floor(Date.now() / 1e3);
559
+ function createCacheEntry(metadata, cacheHeaders, metadataRevalidationInterval, nowMs) {
560
+ const freshness = computeExpiresAt(cacheHeaders, metadataRevalidationInterval, nowMs);
561
+ if (!freshness.cacheable) return null;
562
+ return {
563
+ metadata,
564
+ expiresAt: freshness.expiresAt,
565
+ etag: cacheHeaders.etag,
566
+ lastModified: cacheHeaders.lastModified,
567
+ responseCacheHeaders: cacheHeaders
568
+ };
496
569
  }
497
570
  /**
498
- * Build the `resolve` function for a CIMD {@link ClientDiscovery}.
571
+ * Build the resolver for one plugin-owned CIMD metadata cache.
499
572
  *
500
- * Exposed for advanced composition. Most users should call
501
- * {@link cimdClientDiscovery} (to contribute a complete discovery through
502
- * `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the
503
- * `cimd()` plugin.
573
+ * The cache lives in this closure, so separate `cimd()` plugin instances never
574
+ * share trust state or conditional validators.
504
575
  */
505
- function createCimdResolver(cimdOptions = {}) {
506
- const refreshRate = cimdOptions.refreshRate ?? "60m";
507
- return async (ctx, clientId, existing) => {
508
- if (!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })) return null;
576
+ function createCimdResolver(cimdOptions) {
577
+ const metadataRevalidationInterval = cimdOptions.metadataRevalidationInterval ?? "60m";
578
+ const maxCacheEntries = cimdOptions.maxCacheEntries ?? 1e3;
579
+ if (!Number.isInteger(maxCacheEntries) || maxCacheEntries < 1) throw new BetterAuthError("cimd maxCacheEntries must be a positive integer");
580
+ const fetchPolicy = resolveMetadataFetchPolicy(cimdOptions);
581
+ const metadataCache = /* @__PURE__ */ new Map();
582
+ const inFlightResolutionByClientId = /* @__PURE__ */ new Map();
583
+ const lastFetchStartAtMsByClientId = /* @__PURE__ */ new Map();
584
+ const fetchStateByOrigin = /* @__PURE__ */ new Map();
585
+ const globalFetchStartTimesMs = [];
586
+ let activeFetchCount = 0;
587
+ const readCacheEntry = (clientId) => {
588
+ const entry = metadataCache.get(clientId);
589
+ if (!entry) return void 0;
590
+ metadataCache.delete(clientId);
591
+ metadataCache.set(clientId, entry);
592
+ return entry;
593
+ };
594
+ const storeCacheEntry = (clientId, entry) => {
595
+ metadataCache.delete(clientId);
596
+ while (metadataCache.size >= maxCacheEntries) {
597
+ const leastRecentlyUsedClientId = metadataCache.keys().next().value;
598
+ if (typeof leastRecentlyUsedClientId !== "string") break;
599
+ metadataCache.delete(leastRecentlyUsedClientId);
600
+ }
601
+ metadataCache.set(clientId, entry);
602
+ };
603
+ const ensureClientFetchStateCapacity = (clientId, nowMs) => {
604
+ if (lastFetchStartAtMsByClientId.has(clientId)) return;
605
+ while (lastFetchStartAtMsByClientId.size >= maxCacheEntries) {
606
+ let evicted = false;
607
+ for (const [candidateClientId, candidateLastFetchStartAtMs] of lastFetchStartAtMsByClientId) {
608
+ if (fetchPolicy.minimumFetchIntervalMs > 0 && nowMs - candidateLastFetchStartAtMs < fetchPolicy.minimumFetchIntervalMs) continue;
609
+ lastFetchStartAtMsByClientId.delete(candidateClientId);
610
+ evicted = true;
611
+ break;
612
+ }
613
+ if (evicted) continue;
614
+ throw createMetadataFetchUnavailableError("metadata fetch client state is at capacity");
615
+ }
616
+ };
617
+ const readOrCreateOriginFetchState = (origin, nowMs) => {
618
+ const existingState = fetchStateByOrigin.get(origin);
619
+ if (existingState) {
620
+ pruneFetchStartTimesMs(existingState.fetchStartTimesMs, nowMs);
621
+ fetchStateByOrigin.delete(origin);
622
+ fetchStateByOrigin.set(origin, existingState);
623
+ return existingState;
624
+ }
625
+ while (fetchStateByOrigin.size >= maxCacheEntries) {
626
+ let evicted = false;
627
+ for (const [candidateOrigin, candidateState] of fetchStateByOrigin) {
628
+ pruneFetchStartTimesMs(candidateState.fetchStartTimesMs, nowMs);
629
+ if (candidateState.activeFetchCount > 0 || candidateState.fetchStartTimesMs.length > 0) continue;
630
+ fetchStateByOrigin.delete(candidateOrigin);
631
+ evicted = true;
632
+ break;
633
+ }
634
+ if (evicted) continue;
635
+ throw createMetadataFetchUnavailableError("metadata fetch origin state is at capacity");
636
+ }
637
+ const createdState = {
638
+ activeFetchCount: 0,
639
+ fetchStartTimesMs: []
640
+ };
641
+ fetchStateByOrigin.set(origin, createdState);
642
+ return createdState;
643
+ };
644
+ const acquireMetadataFetchPermit = (clientId) => {
645
+ const nowMs = Date.now();
646
+ const lastFetchStartAtMs = lastFetchStartAtMsByClientId.get(clientId);
647
+ if (fetchPolicy.minimumFetchIntervalMs > 0 && lastFetchStartAtMs !== void 0 && nowMs - lastFetchStartAtMs < fetchPolicy.minimumFetchIntervalMs) throw createMetadataFetchUnavailableError("metadata document fetch is within the per-client minimum interval");
648
+ ensureClientFetchStateCapacity(clientId, nowMs);
649
+ const origin = new URL(clientId).origin;
650
+ const originState = readOrCreateOriginFetchState(origin, nowMs);
651
+ pruneFetchStartTimesMs(globalFetchStartTimesMs, nowMs);
652
+ if (activeFetchCount >= fetchPolicy.maximumConcurrentFetches) throw createMetadataFetchUnavailableError("global metadata fetch concurrency limit exceeded");
653
+ if (originState.activeFetchCount >= fetchPolicy.maximumConcurrentFetchesPerOrigin) throw createMetadataFetchUnavailableError("metadata fetch concurrency limit exceeded for client origin");
654
+ if (globalFetchStartTimesMs.length >= fetchPolicy.maximumFetchesPerMinute) throw createMetadataFetchUnavailableError("global metadata fetch rate limit exceeded");
655
+ if (originState.fetchStartTimesMs.length >= fetchPolicy.maximumFetchesPerOriginPerMinute) throw createMetadataFetchUnavailableError("metadata fetch rate limit exceeded for client origin");
656
+ lastFetchStartAtMsByClientId.delete(clientId);
657
+ lastFetchStartAtMsByClientId.set(clientId, nowMs);
658
+ globalFetchStartTimesMs.push(nowMs);
659
+ originState.fetchStartTimesMs.push(nowMs);
660
+ activeFetchCount += 1;
661
+ originState.activeFetchCount += 1;
662
+ let released = false;
663
+ return () => {
664
+ if (released) return;
665
+ released = true;
666
+ activeFetchCount -= 1;
667
+ originState.activeFetchCount -= 1;
668
+ };
669
+ };
670
+ return async (ctx, clientId, existingClient) => {
671
+ if (!isCimdClientIdUrlCandidate(clientId)) return null;
509
672
  const provider = ctx.context.getPlugin("oauth-provider");
510
673
  if (!provider) throw new BetterAuthError("cimd discovery invoked without the oauth-provider plugin installed");
511
674
  const oauthOptions = provider.options;
512
- if (!existing) return await createMetadataDocumentClient(ctx, clientId, cimdOptions, oauthOptions);
513
- if (isStale(existing, refreshRate)) return await refreshMetadataDocumentClient(ctx, clientId, existing, cimdOptions, oauthOptions);
514
- return existing;
675
+ const cachedEntry = readCacheEntry(clientId);
676
+ const nowMs = Date.now();
677
+ if (cachedEntry && cachedEntry.expiresAt > nowMs) {
678
+ if (existingClient) return existingClient;
679
+ return persistMetadataDocumentClient(ctx, clientId, cachedEntry.metadata, cimdOptions, oauthOptions);
680
+ }
681
+ const inFlightResolution = inFlightResolutionByClientId.get(clientId);
682
+ if (inFlightResolution) return inFlightResolution;
683
+ const releaseMetadataFetchPermit = acquireMetadataFetchPermit(clientId);
684
+ const resolution = (async () => {
685
+ const fetched = await fetchClientMetadataDocument(ctx, clientId, cimdOptions, cachedEntry ? {
686
+ etag: cachedEntry.etag,
687
+ lastModified: cachedEntry.lastModified
688
+ } : void 0);
689
+ if (fetched.status === "not-modified" && !cachedEntry) throw invalidClient("Metadata document returned 304 without a validated cached document");
690
+ const metadata = fetched.status === "modified" ? fetched.metadata : cachedEntry.metadata;
691
+ const storedClient = await persistMetadataDocumentClient(ctx, clientId, metadata, cimdOptions, oauthOptions, existingClient ?? void 0);
692
+ const nextEntry = createCacheEntry(metadata, fetched.status === "modified" ? fetched.cacheHeaders : mergeCacheHeaders(cachedEntry.responseCacheHeaders, fetched.cacheHeaders), metadataRevalidationInterval, Date.now());
693
+ if (nextEntry) storeCacheEntry(clientId, nextEntry);
694
+ else metadataCache.delete(clientId);
695
+ return storedClient;
696
+ })();
697
+ inFlightResolutionByClientId.set(clientId, resolution);
698
+ try {
699
+ return await resolution;
700
+ } finally {
701
+ if (inFlightResolutionByClientId.get(clientId) === resolution) inFlightResolutionByClientId.delete(clientId);
702
+ releaseMetadataFetchPermit();
703
+ }
515
704
  };
516
705
  }
517
706
  //#endregion
518
707
  //#region src/version.ts
519
- const PACKAGE_VERSION = "1.7.0-rc.2";
708
+ const PACKAGE_VERSION = "1.7.0-rc.4";
520
709
  //#endregion
521
710
  //#region src/index.ts
522
711
  /**
@@ -527,13 +716,12 @@ const PACKAGE_VERSION = "1.7.0-rc.2";
527
716
  * install the {@link cimd} plugin instead, which contributes this discovery
528
717
  * alongside whatever else is configured.
529
718
  */
530
- function cimdClientDiscovery(options = {}) {
531
- const resolver = createCimdResolver(options);
532
- const allowLoopback = options.allowLoopback ?? false;
719
+ function createCimdClientDiscovery(options) {
533
720
  return {
534
- id: "cimd",
535
- matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
536
- resolve: resolver,
721
+ id: CIMD_CLIENT_DISCOVERY_ID,
722
+ matches: isCimdClientIdUrlCandidate,
723
+ resolve: createCimdResolver(options),
724
+ fetchClientMetadataResource: options.fetchClientMetadataResource,
537
725
  discoveryMetadata: { client_id_metadata_document_supported: true }
538
726
  };
539
727
  }
@@ -543,13 +731,14 @@ function cimdClientDiscovery(options = {}) {
543
731
  * Adds unauthenticated dynamic client discovery over HTTPS to an
544
732
  * `oauth-provider` instance. Clients identify themselves by providing
545
733
  * an HTTPS URL as their `client_id`; the plugin fetches and validates
546
- * the document at that URL, then creates a public client record.
734
+ * the document at that URL, then creates a client record whose authentication
735
+ * behavior is determined by `token_endpoint_auth_method`.
547
736
  *
548
- * See {@link https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ | the IETF draft}
549
- * and {@link https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow | the MCP authorization spec}.
737
+ * See {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02 | Client ID Metadata Document draft-02}
738
+ * and {@link https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents | the MCP authorization spec}.
550
739
  */
551
- const cimd = (options = {}) => {
552
- const discovery = cimdClientDiscovery(options);
740
+ const cimd = (options) => {
741
+ const discovery = createCimdClientDiscovery(options);
553
742
  return {
554
743
  id: "cimd",
555
744
  version: PACKAGE_VERSION,
@@ -559,4 +748,4 @@ const cimd = (options = {}) => {
559
748
  };
560
749
  };
561
750
  //#endregion
562
- export { cimd, cimdClientDiscovery, createCimdResolver, isUrlClientId, validateCimdMetadata, validateClientIdUrl };
751
+ export { cimd, createCimdClientDiscovery, isCimdClientIdUrlCandidate, validateCimdMetadata, validateClientIdUrl };