fastmcp 4.16.14 → 4.17.1

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,9 +72,11 @@ 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
- registeredAt: storedDateSchema
78
+ registeredAt: storedDateSchema,
79
+ source: z.literal("cimd").optional()
78
80
  });
79
81
  var upstreamTokenSetStorageSchema = z.object({
80
82
  accessToken: z.string(),
@@ -172,10 +174,18 @@ var OAuthProxyStateStore = class {
172
174
  `${STORAGE_KEY_PREFIX.transaction}${transactionId}`
173
175
  );
174
176
  }
177
+ /**
178
+ * A client carrying `expiresAt` is a cached resolution rather than a
179
+ * permanent registration (CIMD), so a lapsed copy is dropped and reported as
180
+ * a miss — the caller re-resolves it from the source of truth.
181
+ */
175
182
  async getRegisteredClientByClientId(clientId) {
176
183
  const cached = this.registeredClientsByClientId.get(clientId);
177
184
  if (cached) {
178
- return cached;
185
+ if (!cached.expiresAt || !this.isExpired(cached.expiresAt)) {
186
+ return cached;
187
+ }
188
+ this.registeredClientsByClientId.delete(clientId);
179
189
  }
180
190
  const stored = await this.tokenStorage.get(
181
191
  `${STORAGE_KEY_PREFIX.client}${clientId}`
@@ -184,6 +194,10 @@ var OAuthProxyStateStore = class {
184
194
  if (!parsed.success) {
185
195
  return null;
186
196
  }
197
+ if (parsed.data.expiresAt && this.isExpired(parsed.data.expiresAt)) {
198
+ await this.tokenStorage.delete(`${STORAGE_KEY_PREFIX.client}${clientId}`);
199
+ return null;
200
+ }
187
201
  this.cacheRegisteredClient(parsed.data);
188
202
  return parsed.data;
189
203
  }
@@ -205,12 +219,6 @@ var OAuthProxyStateStore = class {
205
219
  }
206
220
  return parsed.data;
207
221
  }
208
- async isTransactionCallbackRegistered(transaction) {
209
- const registeredClient = await this.getRegisteredClientByClientId(
210
- transaction.clientId
211
- );
212
- return registeredClient?.redirectUris.includes(transaction.clientCallbackUrl) ?? false;
213
- }
214
222
  /**
215
223
  * Record that an authorization code has been redeemed, so a later attempt
216
224
  * gets "already used" rather than looking like an unknown code. Keyed and
@@ -233,7 +241,8 @@ var OAuthProxyStateStore = class {
233
241
  async saveRegisteredClient(client) {
234
242
  await this.tokenStorage.save(
235
243
  `${STORAGE_KEY_PREFIX.client}${client.clientId}`,
236
- client
244
+ client,
245
+ client.expiresAt ? this.getTtlSeconds(client.expiresAt) : void 0
237
246
  );
238
247
  }
239
248
  async saveTransaction(transaction) {
@@ -276,6 +285,173 @@ var DEFAULT_AUTHORIZATION_CODE_TTL = 300;
276
285
  var DEFAULT_TRANSACTION_TTL = 600;
277
286
  var DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 1e4;
278
287
 
288
+ // src/auth/utils/cimd.ts
289
+ var CIMD_FETCH_TIMEOUT_MS = 5e3;
290
+ var CIMD_CLIENT_TTL_MS = 15 * 60 * 1e3;
291
+ var CIMD_MAX_RESPONSE_BYTES = 65536;
292
+ async function resolveCimdClient(clientId, requestedRedirectUri, validateRedirectUri) {
293
+ let url;
294
+ try {
295
+ url = new URL(clientId);
296
+ } catch {
297
+ return null;
298
+ }
299
+ if (url.protocol !== "https:" || url.search) {
300
+ return null;
301
+ }
302
+ if (isPrivateOrLoopbackHost(url.hostname)) {
303
+ return null;
304
+ }
305
+ let response;
306
+ try {
307
+ response = await fetch(clientId, {
308
+ redirect: "error",
309
+ signal: AbortSignal.timeout(CIMD_FETCH_TIMEOUT_MS)
310
+ });
311
+ } catch {
312
+ return null;
313
+ }
314
+ if (!response.ok) {
315
+ return null;
316
+ }
317
+ const text = await readBoundedText(response);
318
+ if (text === null) {
319
+ return null;
320
+ }
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(text);
324
+ } catch {
325
+ return null;
326
+ }
327
+ if (typeof parsed !== "object" || parsed === null) {
328
+ return null;
329
+ }
330
+ const doc = parsed;
331
+ if (doc.client_id !== clientId) {
332
+ return null;
333
+ }
334
+ if (!isStringArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
335
+ return null;
336
+ }
337
+ const redirectUris = doc.redirect_uris;
338
+ if (requestedRedirectUri && !redirectUris.includes(requestedRedirectUri)) {
339
+ return null;
340
+ }
341
+ if (!redirectUris.every((uri) => validateRedirectUri(uri))) {
342
+ return null;
343
+ }
344
+ const metadata = {
345
+ client_name: optionalString(doc.client_name),
346
+ client_uri: optionalString(doc.client_uri),
347
+ logo_uri: optionalString(doc.logo_uri),
348
+ policy_uri: optionalString(doc.policy_uri),
349
+ scope: optionalString(doc.scope),
350
+ tos_uri: optionalString(doc.tos_uri)
351
+ };
352
+ return {
353
+ callbackUrl: redirectUris[0],
354
+ clientId,
355
+ // CIMD is a public-client mechanism secured by PKCE; there is no secret.
356
+ clientSecret: void 0,
357
+ expiresAt: new Date(Date.now() + CIMD_CLIENT_TTL_MS),
358
+ metadata,
359
+ redirectUris,
360
+ registeredAt: /* @__PURE__ */ new Date(),
361
+ source: "cimd"
362
+ };
363
+ }
364
+ function expandIPv6(host) {
365
+ const sides = host.split("::");
366
+ if (sides.length > 2) {
367
+ return null;
368
+ }
369
+ const head = sides[0] ? sides[0].split(":") : [];
370
+ const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
371
+ if (sides.length === 1 && head.length !== 8) {
372
+ return null;
373
+ }
374
+ const missing = 8 - head.length - tail.length;
375
+ if (missing < 0) {
376
+ return null;
377
+ }
378
+ const parts = [...head, ...Array(missing).fill("0"), ...tail];
379
+ if (parts.length !== 8) {
380
+ return null;
381
+ }
382
+ const groups = parts.map((g) => Number.parseInt(g || "0", 16));
383
+ return groups.every((g) => !Number.isNaN(g)) ? groups : null;
384
+ }
385
+ function isPrivateOrLoopbackHost(hostname) {
386
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
387
+ if (host === "localhost" || host === "0.0.0.0") {
388
+ return true;
389
+ }
390
+ if (host.includes(":")) {
391
+ return isPrivateOrLoopbackIPv6(host);
392
+ }
393
+ 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);
394
+ }
395
+ function isPrivateOrLoopbackIPv6(host) {
396
+ const groups = expandIPv6(host);
397
+ if (!groups) {
398
+ return false;
399
+ }
400
+ if (groups.every((g) => g === 0)) {
401
+ return true;
402
+ }
403
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) {
404
+ return true;
405
+ }
406
+ const [first] = groups;
407
+ if ((first & 65472) === 65152) {
408
+ return true;
409
+ }
410
+ if ((first & 65024) === 64512) {
411
+ return true;
412
+ }
413
+ if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && (groups[5] === 0 || groups[5] === 65535)) {
414
+ const ipv4 = `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
415
+ return isPrivateOrLoopbackHost(ipv4);
416
+ }
417
+ return false;
418
+ }
419
+ function isStringArray(value) {
420
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
421
+ }
422
+ function optionalString(value) {
423
+ return typeof value === "string" ? value : void 0;
424
+ }
425
+ async function readBoundedText(response) {
426
+ const contentLength = response.headers.get("content-length");
427
+ if (contentLength && Number(contentLength) > CIMD_MAX_RESPONSE_BYTES) {
428
+ return null;
429
+ }
430
+ const reader = response.body?.getReader();
431
+ if (!reader) {
432
+ return null;
433
+ }
434
+ const chunks = [];
435
+ let totalBytes = 0;
436
+ try {
437
+ for (; ; ) {
438
+ const { done, value } = await reader.read();
439
+ if (done) {
440
+ break;
441
+ }
442
+ totalBytes += value.byteLength;
443
+ if (totalBytes > CIMD_MAX_RESPONSE_BYTES) {
444
+ await reader.cancel();
445
+ return null;
446
+ }
447
+ chunks.push(value);
448
+ }
449
+ } catch {
450
+ return null;
451
+ }
452
+ return Buffer.concat(chunks).toString("utf-8");
453
+ }
454
+
279
455
  // src/auth/utils/claimsExtractor.ts
280
456
  var ClaimsExtractor = class {
281
457
  config;
@@ -1155,9 +1331,13 @@ var OAuthProxy = class {
1155
1331
  ownedTokenStorage = null;
1156
1332
  /**
1157
1333
  * Keyed by proxy-issued client_id for authorize/token-exchange lookups and
1158
- * for the defence-in-depth callback checks. A registration never changes
1334
+ * for the defence-in-depth callback checks. A DCR registration never changes
1159
1335
  * after it is written, so caching it locally cannot go stale; it is also
1160
1336
  * persisted, so another instance can hydrate it.
1337
+ *
1338
+ * A CIMD client is not a registration but a snapshot of a document that can
1339
+ * change under us, so it is cached with an `expiresAt` and re-resolved once
1340
+ * that passes.
1161
1341
  */
1162
1342
  registeredClientsByClientId = /* @__PURE__ */ new Map();
1163
1343
  stateStore;
@@ -1167,6 +1347,7 @@ var OAuthProxy = class {
1167
1347
  allowPlainPkce: true,
1168
1348
  authorizationCodeTtl: DEFAULT_AUTHORIZATION_CODE_TTL,
1169
1349
  consentRequired: true,
1350
+ enableCimd: false,
1170
1351
  enableTokenSwap: true,
1171
1352
  // Enabled by default for security
1172
1353
  redirectPath: "/oauth/callback",
@@ -1223,7 +1404,10 @@ var OAuthProxy = class {
1223
1404
  "Only 'code' response type is supported"
1224
1405
  );
1225
1406
  }
1226
- const registeredClient = await this.stateStore.getRegisteredClientByClientId(params.client_id);
1407
+ const registeredClient = await this.resolveClient(
1408
+ params.client_id,
1409
+ params.redirect_uri
1410
+ );
1227
1411
  if (!registeredClient) {
1228
1412
  throw new OAuthProxyError("invalid_client", "Unknown client_id");
1229
1413
  }
@@ -1245,6 +1429,20 @@ var OAuthProxy = class {
1245
1429
  "code_challenge_method 'plain' is not supported; use S256"
1246
1430
  );
1247
1431
  }
1432
+ if (registeredClient.source === "cimd") {
1433
+ if (!params.code_challenge) {
1434
+ throw new OAuthProxyError(
1435
+ "invalid_request",
1436
+ "code_challenge is required for Client ID Metadata Document clients"
1437
+ );
1438
+ }
1439
+ if (params.code_challenge_method !== "S256") {
1440
+ throw new OAuthProxyError(
1441
+ "invalid_request",
1442
+ "code_challenge_method must be S256 for Client ID Metadata Document clients"
1443
+ );
1444
+ }
1445
+ }
1248
1446
  const transaction = await this.createTransaction(params);
1249
1447
  if (this.config.consentRequired && !transaction.consentGiven) {
1250
1448
  return this.consentManager.createConsentResponse(
@@ -1275,7 +1473,7 @@ var OAuthProxy = class {
1275
1473
  "Only authorization_code grant type is supported"
1276
1474
  );
1277
1475
  }
1278
- const registeredClient = await this.stateStore.getRegisteredClientByClientId(request.client_id);
1476
+ const registeredClient = await this.resolveClient(request.client_id);
1279
1477
  if (!registeredClient) {
1280
1478
  throw new OAuthProxyError("invalid_client", "Unknown client_id");
1281
1479
  }
@@ -1297,6 +1495,12 @@ var OAuthProxy = class {
1297
1495
  if (clientCode.clientId !== request.client_id) {
1298
1496
  throw new OAuthProxyError("invalid_client", "Client ID mismatch");
1299
1497
  }
1498
+ if (registeredClient.source === "cimd" && !clientCode.codeChallenge) {
1499
+ throw new OAuthProxyError(
1500
+ "invalid_grant",
1501
+ "PKCE is required for Client ID Metadata Document clients"
1502
+ );
1503
+ }
1300
1504
  if (clientCode.codeChallenge) {
1301
1505
  if (!request.code_verifier) {
1302
1506
  throw new OAuthProxyError(
@@ -1363,6 +1567,7 @@ var OAuthProxy = class {
1363
1567
  getAuthorizationServerMetadata() {
1364
1568
  return {
1365
1569
  authorizationEndpoint: `${this.config.baseUrl}/oauth/authorize`,
1570
+ ...this.config.enableCimd ? { clientIdMetadataDocumentSupported: true } : {},
1366
1571
  codeChallengeMethodsSupported: this.config.allowPlainPkce ? ["S256", "plain"] : ["S256"],
1367
1572
  grantTypesSupported: ["authorization_code", "refresh_token"],
1368
1573
  issuer: this.config.baseUrl,
@@ -1398,7 +1603,7 @@ var OAuthProxy = class {
1398
1603
  if (!transaction) {
1399
1604
  throw new OAuthProxyError("invalid_request", "Invalid or expired state");
1400
1605
  }
1401
- if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) {
1606
+ if (!await this.isTransactionCallbackRegistered(transaction)) {
1402
1607
  throw new OAuthProxyError(
1403
1608
  "invalid_request",
1404
1609
  "Transaction callback URL is not registered"
@@ -1444,7 +1649,7 @@ var OAuthProxy = class {
1444
1649
  }
1445
1650
  if (action === "deny") {
1446
1651
  await this.stateStore.deleteTransaction(transactionId);
1447
- if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) {
1652
+ if (!await this.isTransactionCallbackRegistered(transaction)) {
1448
1653
  throw new OAuthProxyError(
1449
1654
  "invalid_request",
1450
1655
  "Transaction callback URL is not registered"
@@ -2030,6 +2235,21 @@ var OAuthProxy = class {
2030
2235
  }
2031
2236
  return response;
2032
2237
  }
2238
+ /**
2239
+ * Defence in depth for the callback and consent-denial redirects: the
2240
+ * transaction's stored callback URL must still be registered for its client.
2241
+ *
2242
+ * Resolution goes through {@link resolveClient}, so a CIMD registration whose
2243
+ * cached copy lapsed mid-flow is re-fetched rather than mistaken for one that
2244
+ * was revoked.
2245
+ */
2246
+ async isTransactionCallbackRegistered(transaction) {
2247
+ const registeredClient = await this.resolveClient(
2248
+ transaction.clientId,
2249
+ transaction.clientCallbackUrl
2250
+ );
2251
+ return registeredClient?.redirectUris.includes(transaction.clientCallbackUrl) ?? false;
2252
+ }
2033
2253
  /**
2034
2254
  * Match URI against pattern (supports wildcards)
2035
2255
  */
@@ -2157,6 +2377,32 @@ var OAuthProxy = class {
2157
2377
  tokenType: tokens.token_type || "Bearer"
2158
2378
  };
2159
2379
  }
2380
+ /**
2381
+ * Resolve the client behind a `client_id`: a stored registration if there is
2382
+ * one, otherwise — when CIMD is enabled — the client's metadata document.
2383
+ *
2384
+ * A resolved CIMD client is cached like a DCR registration but carries the
2385
+ * `expiresAt` the resolver stamped on it, so the store drops it once it
2386
+ * lapses and the next call re-fetches the document. Every lookup goes
2387
+ * through here, so a lapse always means "re-resolve", never "unknown
2388
+ * client" mid-flow.
2389
+ */
2390
+ async resolveClient(clientId, requestedRedirectUri) {
2391
+ const registeredClient = await this.stateStore.getRegisteredClientByClientId(clientId);
2392
+ if (registeredClient || !this.config.enableCimd) {
2393
+ return registeredClient;
2394
+ }
2395
+ const resolved = await resolveCimdClient(
2396
+ clientId,
2397
+ requestedRedirectUri,
2398
+ (uri) => this.validateRedirectUri(uri)
2399
+ );
2400
+ if (resolved) {
2401
+ this.stateStore.cacheRegisteredClient(resolved);
2402
+ await this.stateStore.saveRegisteredClient(resolved);
2403
+ }
2404
+ return resolved;
2405
+ }
2160
2406
  /**
2161
2407
  * Put a claimed refresh-token mapping back after a rotation failed, keeping
2162
2408
  * whatever lifetime it had left.
@@ -2830,4 +3076,4 @@ export {
2830
3076
  DiskStore,
2831
3077
  JWKSVerifier
2832
3078
  };
2833
- //# sourceMappingURL=chunk-IUDWO3A2.js.map
3079
+ //# sourceMappingURL=chunk-KWRV7CHJ.js.map