@better-auth/cimd 1.7.0-rc.2 → 1.7.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.mts +148 -75
- package/dist/index.mjs +512 -323
- package/dist/node.d.mts +14 -0
- package/dist/node.mjs +66 -0
- package/package.json +18 -7
package/dist/index.mjs
CHANGED
|
@@ -1,47 +1,41 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
41
|
-
*
|
|
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
|
|
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:"
|
|
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
|
-
|
|
65
|
-
|
|
58
|
+
function getClientIdUrlWarnings(url) {
|
|
59
|
+
const warnings = [];
|
|
66
60
|
try {
|
|
67
|
-
|
|
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
|
|
65
|
+
return warnings;
|
|
70
66
|
}
|
|
71
|
-
function
|
|
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
|
|
78
|
+
* @param clientIdUrl - The URL the document was fetched from.
|
|
83
79
|
* @param raw - The parsed JSON body of the response.
|
|
84
|
-
* @param
|
|
80
|
+
* @param options - Generic draft-02 validation options and an optional protocol profile.
|
|
85
81
|
*/
|
|
86
|
-
function validateCimdMetadata(
|
|
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
|
|
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 !==
|
|
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
|
-
|
|
105
|
+
if (options.metadataProfile === "mcp-2026-07-28" && !doc.client_name?.trim()) return {
|
|
98
106
|
valid: false,
|
|
99
|
-
error:
|
|
107
|
+
error: "client_name must be a non-empty string"
|
|
100
108
|
};
|
|
101
|
-
const
|
|
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:
|
|
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 (
|
|
121
|
-
valid
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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:
|
|
152
|
+
error: "redirect_uris must be a non-empty array of absolute HTTP(S) or private-use URIs"
|
|
127
153
|
};
|
|
128
|
-
if (doc.
|
|
154
|
+
if (doc.redirect_uris && !doc.redirect_uris.every((uri) => isAbsoluteRedirectUri(uri))) return {
|
|
129
155
|
valid: false,
|
|
130
|
-
error: "
|
|
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 [
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
258
|
+
error_description: description
|
|
224
259
|
});
|
|
225
260
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 >
|
|
268
|
+
if (new TextEncoder().encode(text).byteLength > maximumBytes) throw tooLargeError();
|
|
236
269
|
return text;
|
|
237
270
|
}
|
|
238
271
|
const chunks = [];
|
|
239
|
-
let
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
294
|
+
} finally {
|
|
295
|
+
signal.removeEventListener("abort", abortRead);
|
|
296
|
+
reader.releaseLock();
|
|
249
297
|
}
|
|
250
|
-
const
|
|
298
|
+
const body = new Uint8Array(byteLength);
|
|
251
299
|
let offset = 0;
|
|
252
300
|
for (const chunk of chunks) {
|
|
253
|
-
|
|
301
|
+
body.set(chunk, offset);
|
|
254
302
|
offset += chunk.byteLength;
|
|
255
303
|
}
|
|
256
|
-
return new TextDecoder().decode(
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 (
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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:
|
|
445
|
+
error_description: description
|
|
438
446
|
});
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
447
|
+
}
|
|
448
|
+
function createMetadataFetchUnavailableError(description) {
|
|
449
|
+
return new APIError("TOO_MANY_REQUESTS", {
|
|
450
|
+
error: "temporarily_unavailable",
|
|
451
|
+
error_description: description
|
|
443
452
|
});
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (Number.isFinite(
|
|
448
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
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
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
return
|
|
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
|
|
571
|
+
* Build the resolver for one plugin-owned CIMD metadata cache.
|
|
499
572
|
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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.
|
|
708
|
+
const PACKAGE_VERSION = "1.7.0-rc.3";
|
|
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
|
|
531
|
-
const resolver = createCimdResolver(options);
|
|
532
|
-
const allowLoopback = options.allowLoopback ?? false;
|
|
719
|
+
function createCimdClientDiscovery(options) {
|
|
533
720
|
return {
|
|
534
|
-
id:
|
|
535
|
-
matches:
|
|
536
|
-
resolve:
|
|
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
|
|
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
|
|
549
|
-
* and {@link https://modelcontextprotocol.io/specification/
|
|
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 =
|
|
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,
|
|
751
|
+
export { cimd, createCimdClientDiscovery, isCimdClientIdUrlCandidate, validateCimdMetadata, validateClientIdUrl };
|