fastmcp 4.16.13 → 4.17.0

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.
@@ -72,6 +72,7 @@ var proxyDcrClientStorageSchema = z.object({
72
72
  callbackUrl: z.string(),
73
73
  clientId: z.string(),
74
74
  clientSecret: z.string().optional(),
75
+ expiresAt: storedDateSchema.optional(),
75
76
  metadata: dcrClientMetadataSchema.optional(),
76
77
  redirectUris: z.array(z.string()),
77
78
  registeredAt: storedDateSchema
@@ -172,10 +173,18 @@ var OAuthProxyStateStore = class {
172
173
  `${STORAGE_KEY_PREFIX.transaction}${transactionId}`
173
174
  );
174
175
  }
176
+ /**
177
+ * A client carrying `expiresAt` is a cached resolution rather than a
178
+ * permanent registration (CIMD), so a lapsed copy is dropped and reported as
179
+ * a miss — the caller re-resolves it from the source of truth.
180
+ */
175
181
  async getRegisteredClientByClientId(clientId) {
176
182
  const cached = this.registeredClientsByClientId.get(clientId);
177
183
  if (cached) {
178
- return cached;
184
+ if (!cached.expiresAt || !this.isExpired(cached.expiresAt)) {
185
+ return cached;
186
+ }
187
+ this.registeredClientsByClientId.delete(clientId);
179
188
  }
180
189
  const stored = await this.tokenStorage.get(
181
190
  `${STORAGE_KEY_PREFIX.client}${clientId}`
@@ -184,6 +193,10 @@ var OAuthProxyStateStore = class {
184
193
  if (!parsed.success) {
185
194
  return null;
186
195
  }
196
+ if (parsed.data.expiresAt && this.isExpired(parsed.data.expiresAt)) {
197
+ await this.tokenStorage.delete(`${STORAGE_KEY_PREFIX.client}${clientId}`);
198
+ return null;
199
+ }
187
200
  this.cacheRegisteredClient(parsed.data);
188
201
  return parsed.data;
189
202
  }
@@ -205,12 +218,6 @@ var OAuthProxyStateStore = class {
205
218
  }
206
219
  return parsed.data;
207
220
  }
208
- async isTransactionCallbackRegistered(transaction) {
209
- const registeredClient = await this.getRegisteredClientByClientId(
210
- transaction.clientId
211
- );
212
- return registeredClient?.redirectUris.includes(transaction.clientCallbackUrl) ?? false;
213
- }
214
221
  /**
215
222
  * Record that an authorization code has been redeemed, so a later attempt
216
223
  * gets "already used" rather than looking like an unknown code. Keyed and
@@ -233,7 +240,8 @@ var OAuthProxyStateStore = class {
233
240
  async saveRegisteredClient(client) {
234
241
  await this.tokenStorage.save(
235
242
  `${STORAGE_KEY_PREFIX.client}${client.clientId}`,
236
- client
243
+ client,
244
+ client.expiresAt ? this.getTtlSeconds(client.expiresAt) : void 0
237
245
  );
238
246
  }
239
247
  async saveTransaction(transaction) {
@@ -276,6 +284,172 @@ var DEFAULT_AUTHORIZATION_CODE_TTL = 300;
276
284
  var DEFAULT_TRANSACTION_TTL = 600;
277
285
  var DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 1e4;
278
286
 
287
+ // src/auth/utils/cimd.ts
288
+ var CIMD_FETCH_TIMEOUT_MS = 5e3;
289
+ var CIMD_CLIENT_TTL_MS = 15 * 60 * 1e3;
290
+ var CIMD_MAX_RESPONSE_BYTES = 65536;
291
+ async function resolveCimdClient(clientId, requestedRedirectUri, validateRedirectUri) {
292
+ let url;
293
+ try {
294
+ url = new URL(clientId);
295
+ } catch {
296
+ return null;
297
+ }
298
+ if (url.protocol !== "https:" || url.search) {
299
+ return null;
300
+ }
301
+ if (isPrivateOrLoopbackHost(url.hostname)) {
302
+ return null;
303
+ }
304
+ let response;
305
+ try {
306
+ response = await fetch(clientId, {
307
+ redirect: "error",
308
+ signal: AbortSignal.timeout(CIMD_FETCH_TIMEOUT_MS)
309
+ });
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (!response.ok) {
314
+ return null;
315
+ }
316
+ const text = await readBoundedText(response);
317
+ if (text === null) {
318
+ return null;
319
+ }
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(text);
323
+ } catch {
324
+ return null;
325
+ }
326
+ if (typeof parsed !== "object" || parsed === null) {
327
+ return null;
328
+ }
329
+ const doc = parsed;
330
+ if (doc.client_id !== clientId) {
331
+ return null;
332
+ }
333
+ if (!isStringArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
334
+ return null;
335
+ }
336
+ const redirectUris = doc.redirect_uris;
337
+ if (requestedRedirectUri && !redirectUris.includes(requestedRedirectUri)) {
338
+ return null;
339
+ }
340
+ if (!redirectUris.every((uri) => validateRedirectUri(uri))) {
341
+ return null;
342
+ }
343
+ const metadata = {
344
+ client_name: optionalString(doc.client_name),
345
+ client_uri: optionalString(doc.client_uri),
346
+ logo_uri: optionalString(doc.logo_uri),
347
+ policy_uri: optionalString(doc.policy_uri),
348
+ scope: optionalString(doc.scope),
349
+ tos_uri: optionalString(doc.tos_uri)
350
+ };
351
+ return {
352
+ callbackUrl: redirectUris[0],
353
+ clientId,
354
+ // CIMD is a public-client mechanism secured by PKCE; there is no secret.
355
+ clientSecret: void 0,
356
+ expiresAt: new Date(Date.now() + CIMD_CLIENT_TTL_MS),
357
+ metadata,
358
+ redirectUris,
359
+ registeredAt: /* @__PURE__ */ new Date()
360
+ };
361
+ }
362
+ function expandIPv6(host) {
363
+ const sides = host.split("::");
364
+ if (sides.length > 2) {
365
+ return null;
366
+ }
367
+ const head = sides[0] ? sides[0].split(":") : [];
368
+ const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
369
+ if (sides.length === 1 && head.length !== 8) {
370
+ return null;
371
+ }
372
+ const missing = 8 - head.length - tail.length;
373
+ if (missing < 0) {
374
+ return null;
375
+ }
376
+ const parts = [...head, ...Array(missing).fill("0"), ...tail];
377
+ if (parts.length !== 8) {
378
+ return null;
379
+ }
380
+ const groups = parts.map((g) => Number.parseInt(g || "0", 16));
381
+ return groups.every((g) => !Number.isNaN(g)) ? groups : null;
382
+ }
383
+ function isPrivateOrLoopbackHost(hostname) {
384
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
385
+ if (host === "localhost" || host === "0.0.0.0") {
386
+ return true;
387
+ }
388
+ if (host.includes(":")) {
389
+ return isPrivateOrLoopbackIPv6(host);
390
+ }
391
+ return host === "127.0.0.1" || /^169\.254\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
392
+ }
393
+ function isPrivateOrLoopbackIPv6(host) {
394
+ const groups = expandIPv6(host);
395
+ if (!groups) {
396
+ return false;
397
+ }
398
+ if (groups.every((g) => g === 0)) {
399
+ return true;
400
+ }
401
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) {
402
+ return true;
403
+ }
404
+ const [first] = groups;
405
+ if ((first & 65472) === 65152) {
406
+ return true;
407
+ }
408
+ if ((first & 65024) === 64512) {
409
+ return true;
410
+ }
411
+ if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && (groups[5] === 0 || groups[5] === 65535)) {
412
+ const ipv4 = `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
413
+ return isPrivateOrLoopbackHost(ipv4);
414
+ }
415
+ return false;
416
+ }
417
+ function isStringArray(value) {
418
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
419
+ }
420
+ function optionalString(value) {
421
+ return typeof value === "string" ? value : void 0;
422
+ }
423
+ async function readBoundedText(response) {
424
+ const contentLength = response.headers.get("content-length");
425
+ if (contentLength && Number(contentLength) > CIMD_MAX_RESPONSE_BYTES) {
426
+ return null;
427
+ }
428
+ const reader = response.body?.getReader();
429
+ if (!reader) {
430
+ return null;
431
+ }
432
+ const chunks = [];
433
+ let totalBytes = 0;
434
+ try {
435
+ for (; ; ) {
436
+ const { done, value } = await reader.read();
437
+ if (done) {
438
+ break;
439
+ }
440
+ totalBytes += value.byteLength;
441
+ if (totalBytes > CIMD_MAX_RESPONSE_BYTES) {
442
+ await reader.cancel();
443
+ return null;
444
+ }
445
+ chunks.push(value);
446
+ }
447
+ } catch {
448
+ return null;
449
+ }
450
+ return Buffer.concat(chunks).toString("utf-8");
451
+ }
452
+
279
453
  // src/auth/utils/claimsExtractor.ts
280
454
  var ClaimsExtractor = class {
281
455
  config;
@@ -1155,9 +1329,13 @@ var OAuthProxy = class {
1155
1329
  ownedTokenStorage = null;
1156
1330
  /**
1157
1331
  * Keyed by proxy-issued client_id for authorize/token-exchange lookups and
1158
- * for the defence-in-depth callback checks. A registration never changes
1332
+ * for the defence-in-depth callback checks. A DCR registration never changes
1159
1333
  * after it is written, so caching it locally cannot go stale; it is also
1160
1334
  * persisted, so another instance can hydrate it.
1335
+ *
1336
+ * A CIMD client is not a registration but a snapshot of a document that can
1337
+ * change under us, so it is cached with an `expiresAt` and re-resolved once
1338
+ * that passes.
1161
1339
  */
1162
1340
  registeredClientsByClientId = /* @__PURE__ */ new Map();
1163
1341
  stateStore;
@@ -1167,6 +1345,7 @@ var OAuthProxy = class {
1167
1345
  allowPlainPkce: true,
1168
1346
  authorizationCodeTtl: DEFAULT_AUTHORIZATION_CODE_TTL,
1169
1347
  consentRequired: true,
1348
+ enableCimd: false,
1170
1349
  enableTokenSwap: true,
1171
1350
  // Enabled by default for security
1172
1351
  redirectPath: "/oauth/callback",
@@ -1223,7 +1402,10 @@ var OAuthProxy = class {
1223
1402
  "Only 'code' response type is supported"
1224
1403
  );
1225
1404
  }
1226
- const registeredClient = await this.stateStore.getRegisteredClientByClientId(params.client_id);
1405
+ const registeredClient = await this.resolveClient(
1406
+ params.client_id,
1407
+ params.redirect_uri
1408
+ );
1227
1409
  if (!registeredClient) {
1228
1410
  throw new OAuthProxyError("invalid_client", "Unknown client_id");
1229
1411
  }
@@ -1275,7 +1457,7 @@ var OAuthProxy = class {
1275
1457
  "Only authorization_code grant type is supported"
1276
1458
  );
1277
1459
  }
1278
- const registeredClient = await this.stateStore.getRegisteredClientByClientId(request.client_id);
1460
+ const registeredClient = await this.resolveClient(request.client_id);
1279
1461
  if (!registeredClient) {
1280
1462
  throw new OAuthProxyError("invalid_client", "Unknown client_id");
1281
1463
  }
@@ -1363,6 +1545,7 @@ var OAuthProxy = class {
1363
1545
  getAuthorizationServerMetadata() {
1364
1546
  return {
1365
1547
  authorizationEndpoint: `${this.config.baseUrl}/oauth/authorize`,
1548
+ ...this.config.enableCimd ? { clientIdMetadataDocumentSupported: true } : {},
1366
1549
  codeChallengeMethodsSupported: this.config.allowPlainPkce ? ["S256", "plain"] : ["S256"],
1367
1550
  grantTypesSupported: ["authorization_code", "refresh_token"],
1368
1551
  issuer: this.config.baseUrl,
@@ -1398,7 +1581,7 @@ var OAuthProxy = class {
1398
1581
  if (!transaction) {
1399
1582
  throw new OAuthProxyError("invalid_request", "Invalid or expired state");
1400
1583
  }
1401
- if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) {
1584
+ if (!await this.isTransactionCallbackRegistered(transaction)) {
1402
1585
  throw new OAuthProxyError(
1403
1586
  "invalid_request",
1404
1587
  "Transaction callback URL is not registered"
@@ -1444,7 +1627,7 @@ var OAuthProxy = class {
1444
1627
  }
1445
1628
  if (action === "deny") {
1446
1629
  await this.stateStore.deleteTransaction(transactionId);
1447
- if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) {
1630
+ if (!await this.isTransactionCallbackRegistered(transaction)) {
1448
1631
  throw new OAuthProxyError(
1449
1632
  "invalid_request",
1450
1633
  "Transaction callback URL is not registered"
@@ -2030,6 +2213,21 @@ var OAuthProxy = class {
2030
2213
  }
2031
2214
  return response;
2032
2215
  }
2216
+ /**
2217
+ * Defence in depth for the callback and consent-denial redirects: the
2218
+ * transaction's stored callback URL must still be registered for its client.
2219
+ *
2220
+ * Resolution goes through {@link resolveClient}, so a CIMD registration whose
2221
+ * cached copy lapsed mid-flow is re-fetched rather than mistaken for one that
2222
+ * was revoked.
2223
+ */
2224
+ async isTransactionCallbackRegistered(transaction) {
2225
+ const registeredClient = await this.resolveClient(
2226
+ transaction.clientId,
2227
+ transaction.clientCallbackUrl
2228
+ );
2229
+ return registeredClient?.redirectUris.includes(transaction.clientCallbackUrl) ?? false;
2230
+ }
2033
2231
  /**
2034
2232
  * Match URI against pattern (supports wildcards)
2035
2233
  */
@@ -2157,6 +2355,32 @@ var OAuthProxy = class {
2157
2355
  tokenType: tokens.token_type || "Bearer"
2158
2356
  };
2159
2357
  }
2358
+ /**
2359
+ * Resolve the client behind a `client_id`: a stored registration if there is
2360
+ * one, otherwise — when CIMD is enabled — the client's metadata document.
2361
+ *
2362
+ * A resolved CIMD client is cached like a DCR registration but carries the
2363
+ * `expiresAt` the resolver stamped on it, so the store drops it once it
2364
+ * lapses and the next call re-fetches the document. Every lookup goes
2365
+ * through here, so a lapse always means "re-resolve", never "unknown
2366
+ * client" mid-flow.
2367
+ */
2368
+ async resolveClient(clientId, requestedRedirectUri) {
2369
+ const registeredClient = await this.stateStore.getRegisteredClientByClientId(clientId);
2370
+ if (registeredClient || !this.config.enableCimd) {
2371
+ return registeredClient;
2372
+ }
2373
+ const resolved = await resolveCimdClient(
2374
+ clientId,
2375
+ requestedRedirectUri,
2376
+ (uri) => this.validateRedirectUri(uri)
2377
+ );
2378
+ if (resolved) {
2379
+ this.stateStore.cacheRegisteredClient(resolved);
2380
+ await this.stateStore.saveRegisteredClient(resolved);
2381
+ }
2382
+ return resolved;
2383
+ }
2160
2384
  /**
2161
2385
  * Put a claimed refresh-token mapping back after a rotation failed, keeping
2162
2386
  * whatever lifetime it had left.
@@ -2222,11 +2446,15 @@ var OAuthProxy = class {
2222
2446
  * theft. Do not loosen the default beyond loopback addresses.
2223
2447
  */
2224
2448
  validateRedirectUri(uri) {
2449
+ let parsed;
2225
2450
  try {
2226
- new URL(uri);
2451
+ parsed = new URL(uri);
2227
2452
  } catch {
2228
2453
  return false;
2229
2454
  }
2455
+ if (parsed.username !== "" || parsed.password !== "") {
2456
+ return false;
2457
+ }
2230
2458
  const patterns = this.config.allowedRedirectUriPatterns;
2231
2459
  if (Array.isArray(patterns) && patterns.length === 0) {
2232
2460
  return false;
@@ -2826,4 +3054,4 @@ export {
2826
3054
  DiskStore,
2827
3055
  JWKSVerifier
2828
3056
  };
2829
- //# sourceMappingURL=chunk-V5SKPW6E.js.map
3057
+ //# sourceMappingURL=chunk-UKKIJDLP.js.map