@frontmcp/auth 1.9.2 → 1.9.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/index.js CHANGED
@@ -25,6 +25,245 @@ var __copyProps = (to, from, except, desc) => {
25
25
  };
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
 
28
+ // libs/auth/src/cimd/cimd-redis.cache.ts
29
+ var cimd_redis_cache_exports = {};
30
+ __export(cimd_redis_cache_exports, {
31
+ RedisCimdCache: () => RedisCimdCache
32
+ });
33
+ var import_utils43, import_utils44, RedisCimdCache;
34
+ var init_cimd_redis_cache = __esm({
35
+ "libs/auth/src/cimd/cimd-redis.cache.ts"() {
36
+ "use strict";
37
+ import_utils43 = require("@frontmcp/utils");
38
+ import_utils44 = require("@frontmcp/utils");
39
+ init_cimd_cache();
40
+ RedisCimdCache = class {
41
+ redis;
42
+ keyPrefix;
43
+ config;
44
+ constructor(config) {
45
+ if (!config.redis) {
46
+ throw new Error("Redis configuration is required for RedisCimdCache");
47
+ }
48
+ this.config = {
49
+ defaultTtlMs: config.defaultTtlMs ?? 36e5,
50
+ maxTtlMs: config.maxTtlMs ?? 864e5,
51
+ minTtlMs: config.minTtlMs ?? 6e4
52
+ };
53
+ this.keyPrefix = config.redis.keyPrefix ?? "cimd:";
54
+ const redisOptions = {};
55
+ if (config.redis.url) {
56
+ redisOptions.url = config.redis.url;
57
+ } else if (config.redis.host) {
58
+ redisOptions.config = {
59
+ host: config.redis.host,
60
+ port: config.redis.port,
61
+ password: config.redis.password,
62
+ db: config.redis.db,
63
+ tls: config.redis.tls
64
+ };
65
+ }
66
+ this.redis = new import_utils43.RedisStorageAdapter(redisOptions);
67
+ }
68
+ /**
69
+ * Connect to Redis.
70
+ * Must be called before using any cache operations.
71
+ */
72
+ async connect() {
73
+ await this.redis.connect();
74
+ }
75
+ /**
76
+ * Generate a Redis key for a client ID.
77
+ * Uses SHA-256 hash to handle URLs with special characters.
78
+ */
79
+ cacheKey(clientId) {
80
+ const hash = (0, import_utils44.sha256Hex)(clientId);
81
+ return `${this.keyPrefix}${hash}`;
82
+ }
83
+ /**
84
+ * Get a cached entry by client_id.
85
+ *
86
+ * @param clientId - The client_id URL
87
+ * @returns The cached entry if valid, or undefined
88
+ */
89
+ async get(clientId) {
90
+ const key = this.cacheKey(clientId);
91
+ const data = await this.redis.get(key);
92
+ if (!data) {
93
+ return void 0;
94
+ }
95
+ try {
96
+ const entry = JSON.parse(data);
97
+ if (entry.expiresAt < Date.now()) {
98
+ return void 0;
99
+ }
100
+ return entry;
101
+ } catch {
102
+ await this.redis.delete(key);
103
+ return void 0;
104
+ }
105
+ }
106
+ /**
107
+ * Get a stale entry for conditional revalidation.
108
+ *
109
+ * @param clientId - The client_id URL
110
+ * @returns The stale entry (even if expired), or undefined if not cached
111
+ */
112
+ async getStale(clientId) {
113
+ const key = this.cacheKey(clientId);
114
+ const data = await this.redis.get(key);
115
+ if (!data) {
116
+ return void 0;
117
+ }
118
+ try {
119
+ return JSON.parse(data);
120
+ } catch {
121
+ return void 0;
122
+ }
123
+ }
124
+ /**
125
+ * Store a document in the cache.
126
+ *
127
+ * @param clientId - The client_id URL
128
+ * @param document - The metadata document
129
+ * @param headers - HTTP response headers
130
+ */
131
+ async set(clientId, document, headers) {
132
+ const cacheHeaders = extractCacheHeaders(headers);
133
+ const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
134
+ const now = Date.now();
135
+ const entry = {
136
+ document,
137
+ expiresAt: now + ttlMs,
138
+ etag,
139
+ lastModified,
140
+ cachedAt: now
141
+ };
142
+ const key = this.cacheKey(clientId);
143
+ const value = JSON.stringify(entry);
144
+ const redisTtlSeconds = Math.ceil((ttlMs + this.config.maxTtlMs * 2) / 1e3);
145
+ await this.redis.set(key, value, { ttlSeconds: redisTtlSeconds });
146
+ }
147
+ /**
148
+ * Update an existing cache entry (after 304 Not Modified).
149
+ *
150
+ * @param clientId - The client_id URL
151
+ * @param headers - New HTTP headers with updated cache directives
152
+ */
153
+ async revalidate(clientId, headers) {
154
+ const key = this.cacheKey(clientId);
155
+ const data = await this.redis.get(key);
156
+ if (!data) {
157
+ return false;
158
+ }
159
+ try {
160
+ const existing = JSON.parse(data);
161
+ const cacheHeaders = extractCacheHeaders(headers);
162
+ const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
163
+ existing.expiresAt = Date.now() + ttlMs;
164
+ if (etag) existing.etag = etag;
165
+ if (lastModified) existing.lastModified = lastModified;
166
+ const value = JSON.stringify(existing);
167
+ const redisTtlSeconds = Math.ceil((ttlMs + this.config.maxTtlMs * 2) / 1e3);
168
+ await this.redis.set(key, value, { ttlSeconds: redisTtlSeconds });
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+ /**
175
+ * Delete a cache entry.
176
+ *
177
+ * @param clientId - The client_id URL
178
+ * @returns true if an entry was deleted
179
+ */
180
+ async delete(clientId) {
181
+ const key = this.cacheKey(clientId);
182
+ return this.redis.delete(key);
183
+ }
184
+ /**
185
+ * Get conditional request headers for a cached entry.
186
+ *
187
+ * @param clientId - The client_id URL
188
+ * @returns Headers for conditional request, or undefined if not cached
189
+ */
190
+ async getConditionalHeaders(clientId) {
191
+ const key = this.cacheKey(clientId);
192
+ const data = await this.redis.get(key);
193
+ if (!data) {
194
+ return void 0;
195
+ }
196
+ try {
197
+ const entry = JSON.parse(data);
198
+ const headers = {};
199
+ if (entry.etag) {
200
+ headers["If-None-Match"] = entry.etag;
201
+ }
202
+ if (entry.lastModified) {
203
+ headers["If-Modified-Since"] = entry.lastModified;
204
+ }
205
+ return Object.keys(headers).length > 0 ? headers : void 0;
206
+ } catch {
207
+ return void 0;
208
+ }
209
+ }
210
+ /**
211
+ * Clear all cached entries.
212
+ * Uses Redis SCAN to find and delete all keys with our prefix.
213
+ */
214
+ async clear() {
215
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
216
+ if (keys.length > 0) {
217
+ await this.redis.mdelete(keys);
218
+ }
219
+ }
220
+ /**
221
+ * Get the number of cached entries.
222
+ * Uses Redis SCAN to count keys with our prefix.
223
+ */
224
+ async size() {
225
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
226
+ return keys.length;
227
+ }
228
+ /**
229
+ * Remove expired entries.
230
+ *
231
+ * Note: Redis handles expiration automatically via TTL.
232
+ * This method is primarily for explicit cleanup of entries that are
233
+ * well past their HTTP cache expiration but still within Redis TTL.
234
+ *
235
+ * @returns Number of entries removed
236
+ */
237
+ async cleanup() {
238
+ const now = Date.now();
239
+ let removed = 0;
240
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
241
+ for (const key of keys) {
242
+ const data = await this.redis.get(key);
243
+ if (!data) continue;
244
+ try {
245
+ const entry = JSON.parse(data);
246
+ if (entry.expiresAt + this.config.maxTtlMs * 2 < now) {
247
+ await this.redis.delete(key);
248
+ removed++;
249
+ }
250
+ } catch {
251
+ await this.redis.delete(key);
252
+ removed++;
253
+ }
254
+ }
255
+ return removed;
256
+ }
257
+ /**
258
+ * Close the Redis connection.
259
+ */
260
+ async close() {
261
+ await this.redis.disconnect();
262
+ }
263
+ };
264
+ }
265
+ });
266
+
28
267
  // libs/auth/src/cimd/cimd.cache.ts
29
268
  function extractCacheHeaders(headers) {
30
269
  return {
@@ -90,6 +329,19 @@ function parseCacheControlHeader(value) {
90
329
  }
91
330
  return result;
92
331
  }
332
+ async function createCimdCache(config) {
333
+ const cacheType = config?.type ?? "memory";
334
+ if (cacheType === "redis") {
335
+ if (!config?.redis) {
336
+ throw new Error('Redis configuration is required when cache type is "redis"');
337
+ }
338
+ const { RedisCimdCache: RedisCimdCache2 } = await Promise.resolve().then(() => (init_cimd_redis_cache(), cimd_redis_cache_exports));
339
+ const cache = new RedisCimdCache2(config);
340
+ await cache.connect();
341
+ return cache;
342
+ }
343
+ return new InMemoryCimdCache(config);
344
+ }
93
345
  var InMemoryCimdCache;
94
346
  var init_cimd_cache = __esm({
95
347
  "libs/auth/src/cimd/cimd.cache.ts"() {
@@ -962,6 +1214,7 @@ var TRANSPARENT_JWT_ALGS = [
962
1214
  ];
963
1215
  var MAX_JWKS_FETCH_BYTES = 1048576;
964
1216
  var MAX_JWKS_FETCH_HOPS = 3;
1217
+ var UNKNOWN_KID_REFETCH_COOLDOWN_MS = 6e4;
965
1218
  var MISSING_EXP_ERROR = 'missing required "exp" claim';
966
1219
  function isMissingExpError(error) {
967
1220
  if (typeof error !== "object" || error === null) return false;
@@ -982,6 +1235,8 @@ var JwksService = class {
982
1235
  orchestratorKey;
983
1236
  // Provider JWKS cache (providerId -> jwks + fetchedAt)
984
1237
  providerJwks = /* @__PURE__ */ new Map();
1238
+ // The last key refetch an unknown `kid` started, per provider (see refetchForUnknownKid)
1239
+ unknownKidRefetches = /* @__PURE__ */ new Map();
985
1240
  // Track if key has been initialized (for async loading)
986
1241
  keyInitialized = false;
987
1242
  // Promise guard to prevent concurrent key generation
@@ -1042,6 +1297,9 @@ var JwksService = class {
1042
1297
  let jwks;
1043
1298
  try {
1044
1299
  jwks = await this.getJwksForProvider(p);
1300
+ if (kid && !p.jwks?.keys?.length && !jwks?.keys?.some((key) => key.kid === kid)) {
1301
+ jwks = await this.refetchForUnknownKid(p) ?? jwks;
1302
+ }
1045
1303
  if (!jwks?.keys?.length) continue;
1046
1304
  const JWKS = (0, import_jose.createLocalJWKSet)(jwks);
1047
1305
  const issuerConstraint = this.issuerConstraintFor(p);
@@ -1224,6 +1482,35 @@ var JwksService = class {
1224
1482
  if (cached && Date.now() - cached.fetchedAt < this.opts.providerJwksTtlMs) {
1225
1483
  return cached.jwks;
1226
1484
  }
1485
+ const fetched = await this.fetchProviderJwks(ref);
1486
+ if (fetched) return fetched;
1487
+ const maxStaleMs = Math.max(this.opts.providerJwksTtlMs * 4, 6e4);
1488
+ if (cached && Date.now() - cached.fetchedAt < maxStaleMs) {
1489
+ return cached.jwks;
1490
+ }
1491
+ return void 0;
1492
+ }
1493
+ /**
1494
+ * Fetch a provider's keys again for a token whose `kid` the cached keys don't have (a key the
1495
+ * provider started using since), ahead of the cache's TTL. At most one refetch per provider per
1496
+ * {@link UNKNOWN_KID_REFETCH_COOLDOWN_MS}: tokens that arrive meanwhile wait for the one in flight or
1497
+ * use its result, so a flood of tokens with made-up `kid`s can't hammer the provider. Only freshly
1498
+ * fetched keys are returned: a failed refetch never revives cached keys past the stale window.
1499
+ */
1500
+ refetchForUnknownKid(ref) {
1501
+ let refetch = this.unknownKidRefetches.get(ref.id);
1502
+ if (!refetch || Date.now() - refetch.startedAt >= UNKNOWN_KID_REFETCH_COOLDOWN_MS) {
1503
+ refetch = { startedAt: Date.now(), done: this.fetchProviderJwks(ref) };
1504
+ this.unknownKidRefetches.set(ref.id, refetch);
1505
+ }
1506
+ return refetch.done;
1507
+ }
1508
+ /**
1509
+ * Fetch a provider's keys, and cache them: from `jwksUri`, else the conventional location, else
1510
+ * the `jwks_uri` of the issuer's OAuth or OpenID metadata. Every URL goes through the same scheme
1511
+ * and SSRF checks (`fetchJson`), `jwks_uri` included.
1512
+ */
1513
+ async fetchProviderJwks(ref) {
1227
1514
  if (ref.jwksUri) {
1228
1515
  const fromUri = await this.tryFetchJwks(ref.id, ref.jwksUri);
1229
1516
  if (fromUri?.keys?.length) return fromUri;
@@ -1239,10 +1526,6 @@ var JwksService = class {
1239
1526
  if (fromMeta?.keys?.length) return fromMeta;
1240
1527
  }
1241
1528
  }
1242
- const maxStaleMs = Math.max(this.opts.providerJwksTtlMs * 4, 6e4);
1243
- if (cached && Date.now() - cached.fetchedAt < maxStaleMs) {
1244
- return cached.jwks;
1245
- }
1246
1529
  return void 0;
1247
1530
  }
1248
1531
  // ===========================================================================
@@ -2209,7 +2492,8 @@ function buildRefreshTokenRecord(params) {
2209
2492
  customClaims: params.customClaims,
2210
2493
  federatedLoginUsed: params.federatedLoginUsed,
2211
2494
  selectedProviderIds: params.selectedProviderIds,
2212
- skippedProviderIds: params.skippedProviderIds
2495
+ skippedProviderIds: params.skippedProviderIds,
2496
+ providerTokensId: params.providerTokensId
2213
2497
  };
2214
2498
  }
2215
2499
  function verifyPkce(codeVerifier, challenge) {
@@ -5200,7 +5484,49 @@ var VercelKvSessionStore = class {
5200
5484
  };
5201
5485
 
5202
5486
  // libs/auth/src/session/orchestrated-token.store.ts
5487
+ var import_utils30 = require("@frontmcp/utils");
5488
+
5489
+ // libs/auth/src/session/orchestrated-token.crypto.ts
5203
5490
  var import_utils29 = require("@frontmcp/utils");
5491
+ function recordDropAt(record) {
5492
+ if (!record.refreshToken) return record.expiresAt;
5493
+ return Math.max(record.expiresAt ?? 0, record.updatedAt + REFRESH_TOKEN_TTL_MS);
5494
+ }
5495
+ function hasLiveAccessToken(record, refreshSkewMs = 0) {
5496
+ if (record.expiresAt === void 0) return true;
5497
+ return record.expiresAt - (record.refreshToken ? refreshSkewMs : 0) > Date.now();
5498
+ }
5499
+ var HKDF_SALT = new TextEncoder().encode("frontmcp-token-store");
5500
+ function deriveKeyForRecord(masterKey, compositeKey, cache) {
5501
+ const cached = cache.get(compositeKey);
5502
+ if (cached) {
5503
+ return cached;
5504
+ }
5505
+ const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
5506
+ const derived = (0, import_utils29.hkdfSha256)(masterKey, HKDF_SALT, info, 32);
5507
+ cache.set(compositeKey, derived);
5508
+ return derived;
5509
+ }
5510
+ function encryptRecord(key, record) {
5511
+ const plaintext = JSON.stringify(record);
5512
+ const iv = (0, import_utils29.randomBytes)(12);
5513
+ const { ciphertext, tag } = (0, import_utils29.encryptAesGcm)(key, new TextEncoder().encode(plaintext), iv);
5514
+ return JSON.stringify({
5515
+ iv: Buffer.from(iv).toString("base64url"),
5516
+ tag: Buffer.from(tag).toString("base64url"),
5517
+ data: Buffer.from(ciphertext).toString("base64url")
5518
+ });
5519
+ }
5520
+ function decryptRecord(key, encrypted) {
5521
+ const { iv, tag, data } = JSON.parse(encrypted);
5522
+ const ivBytes = Buffer.from(iv, "base64url");
5523
+ const tagBytes = Buffer.from(tag, "base64url");
5524
+ const ciphertextBytes = Buffer.from(data, "base64url");
5525
+ const plaintext = (0, import_utils29.decryptAesGcm)(key, ciphertextBytes, ivBytes, tagBytes);
5526
+ return JSON.parse(new TextDecoder().decode(plaintext));
5527
+ }
5528
+
5529
+ // libs/auth/src/session/orchestrated-token.store.ts
5204
5530
  var InMemoryOrchestratedTokenStore = class {
5205
5531
  /** Token storage: Map<compositeKey, ProviderTokenRecord> */
5206
5532
  tokens = /* @__PURE__ */ new Map();
@@ -5212,9 +5538,12 @@ var InMemoryOrchestratedTokenStore = class {
5212
5538
  cleanupTimer;
5213
5539
  /** Default TTL for records */
5214
5540
  defaultTtlMs;
5541
+ /** How long before its expiry a refreshable access token stops being returned */
5542
+ refreshSkewMs;
5215
5543
  constructor(options = {}) {
5216
5544
  this.encryptionKey = options.encryptionKey;
5217
5545
  this.defaultTtlMs = options.defaultTtlMs;
5546
+ this.refreshSkewMs = options.refreshSkewMs ?? 0;
5218
5547
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
5219
5548
  this.cleanupTimer = setInterval(() => {
5220
5549
  void this.cleanup();
@@ -5242,7 +5571,7 @@ var InMemoryOrchestratedTokenStore = class {
5242
5571
  }
5243
5572
  const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
5244
5573
  const salt = new TextEncoder().encode("frontmcp-token-store");
5245
- const derivedKey = (0, import_utils29.hkdfSha256)(this.encryptionKey, salt, info, 32);
5574
+ const derivedKey = (0, import_utils30.hkdfSha256)(this.encryptionKey, salt, info, 32);
5246
5575
  this.derivedKeys.set(compositeKey, derivedKey);
5247
5576
  return derivedKey;
5248
5577
  }
@@ -5252,8 +5581,8 @@ var InMemoryOrchestratedTokenStore = class {
5252
5581
  async encryptRecord(compositeKey, record) {
5253
5582
  const key = await this.deriveKeyForRecord(compositeKey);
5254
5583
  const plaintext = JSON.stringify(record);
5255
- const iv = (0, import_utils29.randomBytes)(12);
5256
- const { ciphertext, tag } = (0, import_utils29.encryptAesGcm)(key, new TextEncoder().encode(plaintext), iv);
5584
+ const iv = (0, import_utils30.randomBytes)(12);
5585
+ const { ciphertext, tag } = (0, import_utils30.encryptAesGcm)(key, new TextEncoder().encode(plaintext), iv);
5257
5586
  return JSON.stringify({
5258
5587
  iv: Buffer.from(iv).toString("base64url"),
5259
5588
  tag: Buffer.from(tag).toString("base64url"),
@@ -5269,7 +5598,7 @@ var InMemoryOrchestratedTokenStore = class {
5269
5598
  const ivBytes = Buffer.from(iv, "base64url");
5270
5599
  const tagBytes = Buffer.from(tag, "base64url");
5271
5600
  const ciphertextBytes = Buffer.from(data, "base64url");
5272
- const plaintext = (0, import_utils29.decryptAesGcm)(key, ciphertextBytes, ivBytes, tagBytes);
5601
+ const plaintext = (0, import_utils30.decryptAesGcm)(key, ciphertextBytes, ivBytes, tagBytes);
5273
5602
  return JSON.parse(new TextDecoder().decode(plaintext));
5274
5603
  }
5275
5604
  /**
@@ -5295,18 +5624,20 @@ var InMemoryOrchestratedTokenStore = class {
5295
5624
  } else {
5296
5625
  record = stored;
5297
5626
  }
5298
- if (record.expiresAt && record.expiresAt < Date.now()) {
5627
+ const dropAt = recordDropAt(record);
5628
+ if (dropAt && dropAt < Date.now()) {
5299
5629
  this.tokens.delete(key);
5300
5630
  return null;
5301
5631
  }
5302
5632
  return record;
5303
5633
  }
5304
5634
  /**
5305
- * Retrieve decrypted access token for a provider
5635
+ * Retrieve decrypted access token for a provider, or null once it has expired (a record with a
5636
+ * refresh token keeps that, to renew it)
5306
5637
  */
5307
5638
  async getAccessToken(authorizationId, providerId) {
5308
5639
  const record = await this.getRecord(authorizationId, providerId);
5309
- return record?.accessToken ?? null;
5640
+ return record && hasLiveAccessToken(record, this.refreshSkewMs) ? record.accessToken : null;
5310
5641
  }
5311
5642
  /**
5312
5643
  * Retrieve decrypted refresh token for a provider
@@ -5399,7 +5730,8 @@ var InMemoryOrchestratedTokenStore = class {
5399
5730
  } else {
5400
5731
  record = stored;
5401
5732
  }
5402
- if (record?.expiresAt && record.expiresAt < now) {
5733
+ const dropAt = record ? recordDropAt(record) : void 0;
5734
+ if (dropAt && dropAt < now) {
5403
5735
  keysToDelete.push(key);
5404
5736
  }
5405
5737
  }
@@ -5439,74 +5771,48 @@ var InMemoryOrchestratedTokenStore = class {
5439
5771
  * @param toAuthId - Target authorization ID (e.g., "def456")
5440
5772
  */
5441
5773
  async migrateTokens(fromAuthId, toAuthId) {
5442
- const prefix = `${fromAuthId}:`;
5443
- const keysToMigrate = [];
5444
- for (const key of this.tokens.keys()) {
5445
- if (key.startsWith(prefix)) {
5446
- keysToMigrate.push(key);
5447
- }
5774
+ for (const oldKey of await this.copyRecords(fromAuthId, toAuthId)) {
5775
+ this.tokens.delete(oldKey);
5776
+ this.derivedKeys.delete(oldKey);
5448
5777
  }
5449
- for (const oldKey of keysToMigrate) {
5450
- const providerId = oldKey.slice(prefix.length);
5451
- const newKey = this.buildKey(toAuthId, providerId);
5778
+ }
5779
+ /**
5780
+ * Copy tokens from one authorization ID to another, keeping them under the source ID too.
5781
+ */
5782
+ async copyTokens(fromAuthId, toAuthId) {
5783
+ await this.copyRecords(fromAuthId, toAuthId);
5784
+ }
5785
+ /** Copy every record of `fromAuthId` to `toAuthId`, skipping a corrupted one; returns the keys copied. */
5786
+ async copyRecords(fromAuthId, toAuthId) {
5787
+ const prefix = `${fromAuthId}:`;
5788
+ const sourceKeys = [...this.tokens.keys()].filter((key) => key.startsWith(prefix));
5789
+ const copiedKeys = [];
5790
+ for (const oldKey of sourceKeys) {
5452
5791
  const stored = this.tokens.get(oldKey);
5453
- if (!stored) {
5454
- continue;
5455
- }
5792
+ if (!stored) continue;
5793
+ const newKey = this.buildKey(toAuthId, oldKey.slice(prefix.length));
5456
5794
  if (this.encryptionKey) {
5457
5795
  try {
5458
5796
  const record = await this.decryptRecord(oldKey, stored);
5459
- const encrypted = await this.encryptRecord(newKey, record);
5460
- this.tokens.set(newKey, encrypted);
5797
+ this.tokens.set(newKey, await this.encryptRecord(newKey, record));
5461
5798
  } catch {
5462
5799
  continue;
5463
5800
  }
5464
5801
  } else {
5465
5802
  this.tokens.set(newKey, stored);
5466
5803
  }
5467
- this.tokens.delete(oldKey);
5468
- this.derivedKeys.delete(oldKey);
5804
+ copiedKeys.push(oldKey);
5469
5805
  }
5806
+ return copiedKeys;
5470
5807
  }
5471
5808
  };
5472
5809
 
5473
- // libs/auth/src/session/orchestrated-token.crypto.ts
5474
- var import_utils30 = require("@frontmcp/utils");
5475
- var HKDF_SALT = new TextEncoder().encode("frontmcp-token-store");
5476
- function deriveKeyForRecord(masterKey, compositeKey, cache) {
5477
- const cached = cache.get(compositeKey);
5478
- if (cached) {
5479
- return cached;
5480
- }
5481
- const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
5482
- const derived = (0, import_utils30.hkdfSha256)(masterKey, HKDF_SALT, info, 32);
5483
- cache.set(compositeKey, derived);
5484
- return derived;
5485
- }
5486
- function encryptRecord(key, record) {
5487
- const plaintext = JSON.stringify(record);
5488
- const iv = (0, import_utils30.randomBytes)(12);
5489
- const { ciphertext, tag } = (0, import_utils30.encryptAesGcm)(key, new TextEncoder().encode(plaintext), iv);
5490
- return JSON.stringify({
5491
- iv: Buffer.from(iv).toString("base64url"),
5492
- tag: Buffer.from(tag).toString("base64url"),
5493
- data: Buffer.from(ciphertext).toString("base64url")
5494
- });
5495
- }
5496
- function decryptRecord(key, encrypted) {
5497
- const { iv, tag, data } = JSON.parse(encrypted);
5498
- const ivBytes = Buffer.from(iv, "base64url");
5499
- const tagBytes = Buffer.from(tag, "base64url");
5500
- const ciphertextBytes = Buffer.from(data, "base64url");
5501
- const plaintext = (0, import_utils30.decryptAesGcm)(key, ciphertextBytes, ivBytes, tagBytes);
5502
- return JSON.parse(new TextDecoder().decode(plaintext));
5503
- }
5504
-
5505
5810
  // libs/auth/src/session/storage-orchestrated-token.store.ts
5506
5811
  var StorageOrchestratedTokenStore = class {
5507
5812
  storage;
5508
5813
  encryptionKey;
5509
5814
  defaultTtlMs;
5815
+ refreshSkewMs;
5510
5816
  namespace;
5511
5817
  storageIsNamespaced;
5512
5818
  /** HKDF-derived subkey cache, keyed by composite key. */
@@ -5514,6 +5820,7 @@ var StorageOrchestratedTokenStore = class {
5514
5820
  constructor(storage, options = {}) {
5515
5821
  this.encryptionKey = options.encryptionKey;
5516
5822
  this.defaultTtlMs = options.defaultTtlMs;
5823
+ this.refreshSkewMs = options.refreshSkewMs ?? 0;
5517
5824
  this.namespace = options.namespace ?? "otok";
5518
5825
  this.storageIsNamespaced = this.isNamespacedStorage(storage);
5519
5826
  this.storage = this.storageIsNamespaced ? storage.namespace(this.namespace) : storage;
@@ -5523,7 +5830,7 @@ var StorageOrchestratedTokenStore = class {
5523
5830
  // ============================================
5524
5831
  async getAccessToken(authorizationId, providerId) {
5525
5832
  const record = await this.getRecord(authorizationId, providerId);
5526
- return record?.accessToken ?? null;
5833
+ return record && hasLiveAccessToken(record, this.refreshSkewMs) ? record.accessToken : null;
5527
5834
  }
5528
5835
  async getRefreshToken(authorizationId, providerId) {
5529
5836
  const record = await this.getRecord(authorizationId, providerId);
@@ -5540,7 +5847,7 @@ var StorageOrchestratedTokenStore = class {
5540
5847
  updatedAt: now
5541
5848
  };
5542
5849
  const value = this.encryptionKey ? encryptRecord(this.keyFor(compositeKey), record) : JSON.stringify(record);
5543
- await this.storage.set(this.storageKey(compositeKey), value, this.ttlOptions(record.expiresAt));
5850
+ await this.storage.set(this.storageKey(compositeKey), value, this.ttlOptions(recordDropAt(record)));
5544
5851
  }
5545
5852
  async deleteTokens(authorizationId, providerId) {
5546
5853
  const compositeKey = this.compositeKey(authorizationId, providerId);
@@ -5566,12 +5873,37 @@ var StorageOrchestratedTokenStore = class {
5566
5873
  return providerIds;
5567
5874
  }
5568
5875
  async migrateTokens(fromAuthId, toAuthId) {
5876
+ for (const oldComposite of await this.copyRecords(fromAuthId, toAuthId)) {
5877
+ await this.storage.delete(this.storageKey(oldComposite));
5878
+ this.derivedKeys.delete(oldComposite);
5879
+ }
5880
+ }
5881
+ async copyTokens(fromAuthId, toAuthId) {
5882
+ await this.copyRecords(fromAuthId, toAuthId);
5883
+ }
5884
+ /**
5885
+ * Delete all tokens for an authorization id.
5886
+ */
5887
+ async deleteAllForAuthorization(authorizationId) {
5888
+ const prefix = `${authorizationId}:`;
5889
+ const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5890
+ for (const compositeKey of compositeKeys) {
5891
+ if (!compositeKey.startsWith(prefix)) continue;
5892
+ await this.storage.delete(this.storageKey(compositeKey));
5893
+ this.derivedKeys.delete(compositeKey);
5894
+ }
5895
+ }
5896
+ // ============================================
5897
+ // Internals
5898
+ // ============================================
5899
+ /** Copy every record of `fromAuthId` to `toAuthId`, skipping a corrupted one; returns the composite keys copied. */
5900
+ async copyRecords(fromAuthId, toAuthId) {
5569
5901
  const prefix = `${fromAuthId}:`;
5570
5902
  const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5903
+ const copiedKeys = [];
5571
5904
  for (const oldComposite of compositeKeys) {
5572
5905
  if (!oldComposite.startsWith(prefix)) continue;
5573
- const providerId = oldComposite.slice(prefix.length);
5574
- const newComposite = this.compositeKey(toAuthId, providerId);
5906
+ const newComposite = this.compositeKey(toAuthId, oldComposite.slice(prefix.length));
5575
5907
  const stored = await this.storage.get(this.storageKey(oldComposite));
5576
5908
  if (stored === null) continue;
5577
5909
  if (this.encryptionKey) {
@@ -5582,35 +5914,20 @@ var StorageOrchestratedTokenStore = class {
5582
5914
  continue;
5583
5915
  }
5584
5916
  const value = encryptRecord(this.keyFor(newComposite), record);
5585
- await this.storage.set(this.storageKey(newComposite), value, this.ttlOptions(record.expiresAt));
5917
+ await this.storage.set(this.storageKey(newComposite), value, this.ttlOptions(recordDropAt(record)));
5586
5918
  } else {
5587
- let expiresAt;
5919
+ let dropAt;
5588
5920
  try {
5589
- expiresAt = JSON.parse(stored).expiresAt;
5921
+ dropAt = recordDropAt(JSON.parse(stored));
5590
5922
  } catch {
5591
- expiresAt = void 0;
5923
+ dropAt = void 0;
5592
5924
  }
5593
- await this.storage.set(this.storageKey(newComposite), stored, this.ttlOptions(expiresAt));
5925
+ await this.storage.set(this.storageKey(newComposite), stored, this.ttlOptions(dropAt));
5594
5926
  }
5595
- await this.storage.delete(this.storageKey(oldComposite));
5596
- this.derivedKeys.delete(oldComposite);
5597
- }
5598
- }
5599
- /**
5600
- * Delete all tokens for an authorization id.
5601
- */
5602
- async deleteAllForAuthorization(authorizationId) {
5603
- const prefix = `${authorizationId}:`;
5604
- const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5605
- for (const compositeKey of compositeKeys) {
5606
- if (!compositeKey.startsWith(prefix)) continue;
5607
- await this.storage.delete(this.storageKey(compositeKey));
5608
- this.derivedKeys.delete(compositeKey);
5927
+ copiedKeys.push(oldComposite);
5609
5928
  }
5929
+ return copiedKeys;
5610
5930
  }
5611
- // ============================================
5612
- // Internals
5613
- // ============================================
5614
5931
  async getRecord(authorizationId, providerId) {
5615
5932
  const compositeKey = this.compositeKey(authorizationId, providerId);
5616
5933
  const stored = await this.storage.get(this.storageKey(compositeKey));
@@ -5633,7 +5950,8 @@ var StorageOrchestratedTokenStore = class {
5633
5950
  return null;
5634
5951
  }
5635
5952
  }
5636
- if (record.expiresAt && record.expiresAt < Date.now()) {
5953
+ const dropAt = recordDropAt(record);
5954
+ if (dropAt && dropAt < Date.now()) {
5637
5955
  await this.storage.delete(this.storageKey(compositeKey));
5638
5956
  return null;
5639
5957
  }
@@ -7085,7 +7403,7 @@ var OrchestratedAuthorization = class _OrchestratedAuthorization extends Authori
7085
7403
  const result = await this.#onTokenRefresh(providerId, refreshToken);
7086
7404
  await this.#tokenStore.storeTokens(this.id, providerId, {
7087
7405
  accessToken: result.accessToken,
7088
- refreshToken: result.refreshToken,
7406
+ refreshToken: result.refreshToken ?? refreshToken,
7089
7407
  expiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1e3 : void 0
7090
7408
  });
7091
7409
  const currentState = this.#providerStates.get(providerId);
@@ -7847,14 +8165,14 @@ var cimdConfigSchema = import_lazy_zod9.z.object({
7847
8165
  init_cimd_cache();
7848
8166
 
7849
8167
  // libs/auth/src/cimd/cimd.service.ts
7850
- var import_utils43 = require("@frontmcp/utils");
8168
+ var import_utils45 = require("@frontmcp/utils");
7851
8169
  init_cimd_cache();
7852
8170
  var CimdService = class {
7853
8171
  config;
7854
8172
  cacheConfig;
7855
8173
  securityConfig;
7856
8174
  networkConfig;
7857
- cache;
8175
+ cacheBackend;
7858
8176
  logger;
7859
8177
  /**
7860
8178
  * Whether CIMD is enabled.
@@ -7874,7 +8192,6 @@ var CimdService = class {
7874
8192
  this.cacheConfig = cimdCacheConfigSchema.parse(this.config.cache ?? {});
7875
8193
  this.securityConfig = cimdSecurityConfigSchema.parse(this.config.security ?? {});
7876
8194
  this.networkConfig = cimdNetworkConfigSchema.parse(this.config.network ?? {});
7877
- this.cache = new InMemoryCimdCache(this.cacheConfig);
7878
8195
  this.logger.debug("CimdService initialized", {
7879
8196
  enabled: this.config.enabled,
7880
8197
  cacheDefaultTtlMs: this.cacheConfig.defaultTtlMs,
@@ -7886,6 +8203,34 @@ var CimdService = class {
7886
8203
  );
7887
8204
  }
7888
8205
  }
8206
+ /**
8207
+ * Create the cache now (connecting to Redis with `cache.type: 'redis'`), so a misconfigured or
8208
+ * unreachable cache fails at startup rather than on the first CIMD client.
8209
+ */
8210
+ async initialize() {
8211
+ await this.cache();
8212
+ }
8213
+ /**
8214
+ * The cache `cache.type` selects, created once through the `createCimdCache` factory. A failed
8215
+ * creation is retried on the next use.
8216
+ */
8217
+ cache() {
8218
+ if (!this.cacheBackend) {
8219
+ this.cacheBackend = createCimdCache(this.cacheConfig).catch((error) => {
8220
+ this.cacheBackend = void 0;
8221
+ throw error;
8222
+ });
8223
+ }
8224
+ return this.cacheBackend;
8225
+ }
8226
+ /** Close the cache's connection (Redis with `cache.type: 'redis'`), if one was created. */
8227
+ async dispose() {
8228
+ const backend = this.cacheBackend;
8229
+ this.cacheBackend = void 0;
8230
+ if (!backend) return;
8231
+ const createdBackend = await backend.catch(() => void 0);
8232
+ await createdBackend?.close?.();
8233
+ }
7889
8234
  /**
7890
8235
  * Check if a client_id is a CIMD URL.
7891
8236
  *
@@ -7912,7 +8257,8 @@ var CimdService = class {
7912
8257
  };
7913
8258
  }
7914
8259
  validateClientIdUrl(clientId, this.securityConfig);
7915
- const cached = await this.cache.get(clientId);
8260
+ const cache = await this.cache();
8261
+ const cached = await cache.get(clientId);
7916
8262
  if (cached) {
7917
8263
  this.logger.debug(`Cache hit for CIMD client: ${clientId}`);
7918
8264
  return {
@@ -7930,8 +8276,8 @@ var CimdService = class {
7930
8276
  if (this.securityConfig.warnOnLocalhostRedirects && hasOnlyLocalhostRedirectUris(document.redirect_uris)) {
7931
8277
  this.logger.warn(`CIMD client "${clientId}" has only localhost redirect URIs - this may be a development client`);
7932
8278
  }
7933
- await this.cache.set(clientId, document, headers);
7934
- const entry = await this.cache.get(clientId);
8279
+ await cache.set(clientId, document, headers);
8280
+ const entry = await cache.get(clientId);
7935
8281
  return {
7936
8282
  isCimdClient: true,
7937
8283
  metadata: document,
@@ -7961,11 +8307,12 @@ var CimdService = class {
7961
8307
  * @param clientId - Optional client_id to clear; clears all if not provided
7962
8308
  */
7963
8309
  async clearCache(clientId) {
8310
+ const cache = await this.cache();
7964
8311
  if (clientId) {
7965
- await this.cache.delete(clientId);
8312
+ await cache.delete(clientId);
7966
8313
  this.logger.debug(`Cache cleared for: ${clientId}`);
7967
8314
  } else {
7968
- await this.cache.clear();
8315
+ await cache.clear();
7969
8316
  this.logger.debug("Cache cleared");
7970
8317
  }
7971
8318
  }
@@ -7973,8 +8320,9 @@ var CimdService = class {
7973
8320
  * Get cache statistics.
7974
8321
  */
7975
8322
  async getCacheStats() {
8323
+ const cache = await this.cache();
7976
8324
  return {
7977
- size: await this.cache.size()
8325
+ size: await cache.size()
7978
8326
  };
7979
8327
  }
7980
8328
  /**
@@ -7986,7 +8334,8 @@ var CimdService = class {
7986
8334
  const controller = new AbortController();
7987
8335
  const timer = setTimeout(() => controller.abort(), this.networkConfig.timeoutMs);
7988
8336
  try {
7989
- const conditionalHeaders = await this.cache.getConditionalHeaders(clientId);
8337
+ const cache = await this.cache();
8338
+ const conditionalHeaders = await cache.getConditionalHeaders(clientId);
7990
8339
  const originalOrigin = new URL(clientId).origin;
7991
8340
  const maxRedirects = this.networkConfig.maxRedirects;
7992
8341
  let currentUrl = clientId;
@@ -8009,9 +8358,9 @@ var CimdService = class {
8009
8358
  redirect: "manual"
8010
8359
  });
8011
8360
  if (response.status === 304) {
8012
- const staleEntry = await this.cache.getStale(clientId);
8361
+ const staleEntry = await cache.getStale(clientId);
8013
8362
  if (staleEntry) {
8014
- await this.cache.revalidate(clientId, response.headers);
8363
+ await cache.revalidate(clientId, response.headers);
8015
8364
  this.logger.debug(`CIMD document not modified: ${clientId}`);
8016
8365
  return {
8017
8366
  document: staleEntry.document,
@@ -8022,7 +8371,7 @@ var CimdService = class {
8022
8371
  httpStatus: 304
8023
8372
  });
8024
8373
  }
8025
- if ((0, import_utils43.isRedirectResponse)(response)) {
8374
+ if ((0, import_utils45.isRedirectResponse)(response)) {
8026
8375
  const location = response.headers.get("location");
8027
8376
  if (!location) {
8028
8377
  throw new CimdFetchError(clientId, "Redirect response missing Location header", {
@@ -8519,7 +8868,7 @@ var tokenRefreshConfigSchema = import_lazy_zod10.z.object({
8519
8868
  * Refresh token before expiry by this many seconds
8520
8869
  * @default 60
8521
8870
  */
8522
- skewSeconds: import_lazy_zod10.z.number().default(60)
8871
+ skewSeconds: import_lazy_zod10.z.number().nonnegative().default(60)
8523
8872
  });
8524
8873
  var skippedAppBehaviorSchema = import_lazy_zod10.z.enum(["anonymous", "require-auth"]);
8525
8874
  var consentConfigSchema = import_lazy_zod10.z.object({
@@ -8632,7 +8981,7 @@ var publicAuthOptionsSchema = import_lazy_zod11.z.object({
8632
8981
  * Anonymous session TTL in seconds
8633
8982
  * @default 3600 (1 hour)
8634
8983
  */
8635
- sessionTtl: import_lazy_zod11.z.number().default(3600),
8984
+ sessionTtl: import_lazy_zod11.z.number().int().positive().default(3600),
8636
8985
  /**
8637
8986
  * Scopes granted to anonymous sessions
8638
8987
  * @default ['anonymous']
@@ -9702,7 +10051,7 @@ var CredentialCache = class {
9702
10051
  var AUTH_PROVIDERS_ACCESSOR = /* @__PURE__ */ Symbol.for("frontmcp:AUTH_PROVIDERS_ACCESSOR");
9703
10052
 
9704
10053
  // libs/auth/src/vault/auth-providers.accessor.impl.ts
9705
- var import_utils46 = require("@frontmcp/utils");
10054
+ var import_utils48 = require("@frontmcp/utils");
9706
10055
  var AuthProvidersAccessorImpl = class {
9707
10056
  constructor(registry, vault, cache, loader, context, logger) {
9708
10057
  this.registry = registry;
@@ -9917,7 +10266,7 @@ var AuthProvidersAccessorImpl = class {
9917
10266
  return { [credential.headerName]: credential.key };
9918
10267
  case "basic": {
9919
10268
  const value = `${credential.username}:${credential.password}`;
9920
- const encoded = credential.encodedValue ?? (0, import_utils46.base64Encode)(new TextEncoder().encode(value));
10269
+ const encoded = credential.encodedValue ?? (0, import_utils48.base64Encode)(new TextEncoder().encode(value));
9921
10270
  return { Authorization: `Basic ${encoded}` };
9922
10271
  }
9923
10272
  case "custom":
@@ -10477,7 +10826,7 @@ var LazyCredentialLoader = class {
10477
10826
  };
10478
10827
 
10479
10828
  // libs/auth/src/authorities/authorities.context.ts
10480
- var import_utils47 = require("@frontmcp/utils");
10829
+ var import_utils49 = require("@frontmcp/utils");
10481
10830
  function resolveDotPath(obj, path) {
10482
10831
  if (path in obj) {
10483
10832
  return obj[path];
@@ -10520,7 +10869,7 @@ function signedInSubject(rawSub, mappedSub) {
10520
10869
  }
10521
10870
  var runtimeEnv = new Proxy(
10522
10871
  {},
10523
- { get: (_target, name) => typeof name === "string" ? (0, import_utils47.getEnv)(name) : void 0 }
10872
+ { get: (_target, name) => typeof name === "string" ? (0, import_utils49.getEnv)(name) : void 0 }
10524
10873
  );
10525
10874
  var AuthoritiesContextBuilder = class {
10526
10875
  claimsMapping;
@@ -11625,8 +11974,16 @@ var AuthoritiesEvaluatorRegistry = class {
11625
11974
  };
11626
11975
 
11627
11976
  // libs/auth/src/authorities/authorities.errors.ts
11977
+ var import_utils50 = require("@frontmcp/utils");
11628
11978
  var FORBIDDEN_CODE = -32003;
11629
11979
  var AuthorityDeniedError = class extends Error {
11980
+ /**
11981
+ * `instanceof AuthorityDeniedError` also recognises a denial from another copy of this package in the
11982
+ * process, so every copy of `@frontmcp/sdk` answers it as a FORBIDDEN error (#802).
11983
+ */
11984
+ static [Symbol.hasInstance](value) {
11985
+ return (0, import_utils50.isBrandedInstance)(this, value);
11986
+ }
11630
11987
  /** MCP error code for JSON-RPC responses */
11631
11988
  mcpErrorCode = FORBIDDEN_CODE;
11632
11989
  /** HTTP status code equivalent */
@@ -11671,6 +12028,7 @@ var AuthorityDeniedError = class extends Error {
11671
12028
  };
11672
12029
  }
11673
12030
  };
12031
+ (0, import_utils50.brandClass)(AuthorityDeniedError, "@frontmcp/auth:AuthorityDeniedError");
11674
12032
 
11675
12033
  // libs/auth/src/fetch/fetch-credential-middleware.ts
11676
12034
  var bearerApplier = {