@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/esm/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  var __frontmcpModule = typeof process !== "undefined" && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("module") : undefined;
2
2
  const require = __frontmcpModule ? __frontmcpModule.createRequire(import.meta.url || 'file:///') : undefined;
3
3
  // __frontmcpCreateRequire
4
+ var __defProp = Object.defineProperty;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
6
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
6
7
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -16,6 +17,249 @@ var __esm = (fn, res, err) => function __init() {
16
17
  throw err = [e], e;
17
18
  }
18
19
  };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, { get: all[name], enumerable: true });
23
+ };
24
+
25
+ // libs/auth/src/cimd/cimd-redis.cache.ts
26
+ var cimd_redis_cache_exports = {};
27
+ __export(cimd_redis_cache_exports, {
28
+ RedisCimdCache: () => RedisCimdCache
29
+ });
30
+ import { RedisStorageAdapter as RedisStorageAdapter3 } from "@frontmcp/utils";
31
+ import { sha256Hex as sha256Hex5 } from "@frontmcp/utils";
32
+ var RedisCimdCache;
33
+ var init_cimd_redis_cache = __esm({
34
+ "libs/auth/src/cimd/cimd-redis.cache.ts"() {
35
+ "use strict";
36
+ init_cimd_cache();
37
+ RedisCimdCache = class {
38
+ redis;
39
+ keyPrefix;
40
+ config;
41
+ constructor(config) {
42
+ if (!config.redis) {
43
+ throw new Error("Redis configuration is required for RedisCimdCache");
44
+ }
45
+ this.config = {
46
+ defaultTtlMs: config.defaultTtlMs ?? 36e5,
47
+ maxTtlMs: config.maxTtlMs ?? 864e5,
48
+ minTtlMs: config.minTtlMs ?? 6e4
49
+ };
50
+ this.keyPrefix = config.redis.keyPrefix ?? "cimd:";
51
+ const redisOptions = {};
52
+ if (config.redis.url) {
53
+ redisOptions.url = config.redis.url;
54
+ } else if (config.redis.host) {
55
+ redisOptions.config = {
56
+ host: config.redis.host,
57
+ port: config.redis.port,
58
+ password: config.redis.password,
59
+ db: config.redis.db,
60
+ tls: config.redis.tls
61
+ };
62
+ }
63
+ this.redis = new RedisStorageAdapter3(redisOptions);
64
+ }
65
+ /**
66
+ * Connect to Redis.
67
+ * Must be called before using any cache operations.
68
+ */
69
+ async connect() {
70
+ await this.redis.connect();
71
+ }
72
+ /**
73
+ * Generate a Redis key for a client ID.
74
+ * Uses SHA-256 hash to handle URLs with special characters.
75
+ */
76
+ cacheKey(clientId) {
77
+ const hash = sha256Hex5(clientId);
78
+ return `${this.keyPrefix}${hash}`;
79
+ }
80
+ /**
81
+ * Get a cached entry by client_id.
82
+ *
83
+ * @param clientId - The client_id URL
84
+ * @returns The cached entry if valid, or undefined
85
+ */
86
+ async get(clientId) {
87
+ const key = this.cacheKey(clientId);
88
+ const data = await this.redis.get(key);
89
+ if (!data) {
90
+ return void 0;
91
+ }
92
+ try {
93
+ const entry = JSON.parse(data);
94
+ if (entry.expiresAt < Date.now()) {
95
+ return void 0;
96
+ }
97
+ return entry;
98
+ } catch {
99
+ await this.redis.delete(key);
100
+ return void 0;
101
+ }
102
+ }
103
+ /**
104
+ * Get a stale entry for conditional revalidation.
105
+ *
106
+ * @param clientId - The client_id URL
107
+ * @returns The stale entry (even if expired), or undefined if not cached
108
+ */
109
+ async getStale(clientId) {
110
+ const key = this.cacheKey(clientId);
111
+ const data = await this.redis.get(key);
112
+ if (!data) {
113
+ return void 0;
114
+ }
115
+ try {
116
+ return JSON.parse(data);
117
+ } catch {
118
+ return void 0;
119
+ }
120
+ }
121
+ /**
122
+ * Store a document in the cache.
123
+ *
124
+ * @param clientId - The client_id URL
125
+ * @param document - The metadata document
126
+ * @param headers - HTTP response headers
127
+ */
128
+ async set(clientId, document, headers) {
129
+ const cacheHeaders = extractCacheHeaders(headers);
130
+ const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
131
+ const now = Date.now();
132
+ const entry = {
133
+ document,
134
+ expiresAt: now + ttlMs,
135
+ etag,
136
+ lastModified,
137
+ cachedAt: now
138
+ };
139
+ const key = this.cacheKey(clientId);
140
+ const value = JSON.stringify(entry);
141
+ const redisTtlSeconds = Math.ceil((ttlMs + this.config.maxTtlMs * 2) / 1e3);
142
+ await this.redis.set(key, value, { ttlSeconds: redisTtlSeconds });
143
+ }
144
+ /**
145
+ * Update an existing cache entry (after 304 Not Modified).
146
+ *
147
+ * @param clientId - The client_id URL
148
+ * @param headers - New HTTP headers with updated cache directives
149
+ */
150
+ async revalidate(clientId, headers) {
151
+ const key = this.cacheKey(clientId);
152
+ const data = await this.redis.get(key);
153
+ if (!data) {
154
+ return false;
155
+ }
156
+ try {
157
+ const existing = JSON.parse(data);
158
+ const cacheHeaders = extractCacheHeaders(headers);
159
+ const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
160
+ existing.expiresAt = Date.now() + ttlMs;
161
+ if (etag) existing.etag = etag;
162
+ if (lastModified) existing.lastModified = lastModified;
163
+ const value = JSON.stringify(existing);
164
+ const redisTtlSeconds = Math.ceil((ttlMs + this.config.maxTtlMs * 2) / 1e3);
165
+ await this.redis.set(key, value, { ttlSeconds: redisTtlSeconds });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ /**
172
+ * Delete a cache entry.
173
+ *
174
+ * @param clientId - The client_id URL
175
+ * @returns true if an entry was deleted
176
+ */
177
+ async delete(clientId) {
178
+ const key = this.cacheKey(clientId);
179
+ return this.redis.delete(key);
180
+ }
181
+ /**
182
+ * Get conditional request headers for a cached entry.
183
+ *
184
+ * @param clientId - The client_id URL
185
+ * @returns Headers for conditional request, or undefined if not cached
186
+ */
187
+ async getConditionalHeaders(clientId) {
188
+ const key = this.cacheKey(clientId);
189
+ const data = await this.redis.get(key);
190
+ if (!data) {
191
+ return void 0;
192
+ }
193
+ try {
194
+ const entry = JSON.parse(data);
195
+ const headers = {};
196
+ if (entry.etag) {
197
+ headers["If-None-Match"] = entry.etag;
198
+ }
199
+ if (entry.lastModified) {
200
+ headers["If-Modified-Since"] = entry.lastModified;
201
+ }
202
+ return Object.keys(headers).length > 0 ? headers : void 0;
203
+ } catch {
204
+ return void 0;
205
+ }
206
+ }
207
+ /**
208
+ * Clear all cached entries.
209
+ * Uses Redis SCAN to find and delete all keys with our prefix.
210
+ */
211
+ async clear() {
212
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
213
+ if (keys.length > 0) {
214
+ await this.redis.mdelete(keys);
215
+ }
216
+ }
217
+ /**
218
+ * Get the number of cached entries.
219
+ * Uses Redis SCAN to count keys with our prefix.
220
+ */
221
+ async size() {
222
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
223
+ return keys.length;
224
+ }
225
+ /**
226
+ * Remove expired entries.
227
+ *
228
+ * Note: Redis handles expiration automatically via TTL.
229
+ * This method is primarily for explicit cleanup of entries that are
230
+ * well past their HTTP cache expiration but still within Redis TTL.
231
+ *
232
+ * @returns Number of entries removed
233
+ */
234
+ async cleanup() {
235
+ const now = Date.now();
236
+ let removed = 0;
237
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
238
+ for (const key of keys) {
239
+ const data = await this.redis.get(key);
240
+ if (!data) continue;
241
+ try {
242
+ const entry = JSON.parse(data);
243
+ if (entry.expiresAt + this.config.maxTtlMs * 2 < now) {
244
+ await this.redis.delete(key);
245
+ removed++;
246
+ }
247
+ } catch {
248
+ await this.redis.delete(key);
249
+ removed++;
250
+ }
251
+ }
252
+ return removed;
253
+ }
254
+ /**
255
+ * Close the Redis connection.
256
+ */
257
+ async close() {
258
+ await this.redis.disconnect();
259
+ }
260
+ };
261
+ }
262
+ });
19
263
 
20
264
  // libs/auth/src/cimd/cimd.cache.ts
21
265
  function extractCacheHeaders(headers) {
@@ -82,6 +326,19 @@ function parseCacheControlHeader(value) {
82
326
  }
83
327
  return result;
84
328
  }
329
+ async function createCimdCache(config) {
330
+ const cacheType = config?.type ?? "memory";
331
+ if (cacheType === "redis") {
332
+ if (!config?.redis) {
333
+ throw new Error('Redis configuration is required when cache type is "redis"');
334
+ }
335
+ const { RedisCimdCache: RedisCimdCache2 } = await Promise.resolve().then(() => (init_cimd_redis_cache(), cimd_redis_cache_exports));
336
+ const cache = new RedisCimdCache2(config);
337
+ await cache.connect();
338
+ return cache;
339
+ }
340
+ return new InMemoryCimdCache(config);
341
+ }
85
342
  var InMemoryCimdCache;
86
343
  var init_cimd_cache = __esm({
87
344
  "libs/auth/src/cimd/cimd.cache.ts"() {
@@ -620,6 +877,7 @@ var TRANSPARENT_JWT_ALGS = [
620
877
  ];
621
878
  var MAX_JWKS_FETCH_BYTES = 1048576;
622
879
  var MAX_JWKS_FETCH_HOPS = 3;
880
+ var UNKNOWN_KID_REFETCH_COOLDOWN_MS = 6e4;
623
881
  var MISSING_EXP_ERROR = 'missing required "exp" claim';
624
882
  function isMissingExpError(error) {
625
883
  if (typeof error !== "object" || error === null) return false;
@@ -640,6 +898,8 @@ var JwksService = class {
640
898
  orchestratorKey;
641
899
  // Provider JWKS cache (providerId -> jwks + fetchedAt)
642
900
  providerJwks = /* @__PURE__ */ new Map();
901
+ // The last key refetch an unknown `kid` started, per provider (see refetchForUnknownKid)
902
+ unknownKidRefetches = /* @__PURE__ */ new Map();
643
903
  // Track if key has been initialized (for async loading)
644
904
  keyInitialized = false;
645
905
  // Promise guard to prevent concurrent key generation
@@ -700,6 +960,9 @@ var JwksService = class {
700
960
  let jwks;
701
961
  try {
702
962
  jwks = await this.getJwksForProvider(p);
963
+ if (kid && !p.jwks?.keys?.length && !jwks?.keys?.some((key) => key.kid === kid)) {
964
+ jwks = await this.refetchForUnknownKid(p) ?? jwks;
965
+ }
703
966
  if (!jwks?.keys?.length) continue;
704
967
  const JWKS = createLocalJWKSet(jwks);
705
968
  const issuerConstraint = this.issuerConstraintFor(p);
@@ -882,6 +1145,35 @@ var JwksService = class {
882
1145
  if (cached && Date.now() - cached.fetchedAt < this.opts.providerJwksTtlMs) {
883
1146
  return cached.jwks;
884
1147
  }
1148
+ const fetched = await this.fetchProviderJwks(ref);
1149
+ if (fetched) return fetched;
1150
+ const maxStaleMs = Math.max(this.opts.providerJwksTtlMs * 4, 6e4);
1151
+ if (cached && Date.now() - cached.fetchedAt < maxStaleMs) {
1152
+ return cached.jwks;
1153
+ }
1154
+ return void 0;
1155
+ }
1156
+ /**
1157
+ * Fetch a provider's keys again for a token whose `kid` the cached keys don't have (a key the
1158
+ * provider started using since), ahead of the cache's TTL. At most one refetch per provider per
1159
+ * {@link UNKNOWN_KID_REFETCH_COOLDOWN_MS}: tokens that arrive meanwhile wait for the one in flight or
1160
+ * use its result, so a flood of tokens with made-up `kid`s can't hammer the provider. Only freshly
1161
+ * fetched keys are returned: a failed refetch never revives cached keys past the stale window.
1162
+ */
1163
+ refetchForUnknownKid(ref) {
1164
+ let refetch = this.unknownKidRefetches.get(ref.id);
1165
+ if (!refetch || Date.now() - refetch.startedAt >= UNKNOWN_KID_REFETCH_COOLDOWN_MS) {
1166
+ refetch = { startedAt: Date.now(), done: this.fetchProviderJwks(ref) };
1167
+ this.unknownKidRefetches.set(ref.id, refetch);
1168
+ }
1169
+ return refetch.done;
1170
+ }
1171
+ /**
1172
+ * Fetch a provider's keys, and cache them: from `jwksUri`, else the conventional location, else
1173
+ * the `jwks_uri` of the issuer's OAuth or OpenID metadata. Every URL goes through the same scheme
1174
+ * and SSRF checks (`fetchJson`), `jwks_uri` included.
1175
+ */
1176
+ async fetchProviderJwks(ref) {
885
1177
  if (ref.jwksUri) {
886
1178
  const fromUri = await this.tryFetchJwks(ref.id, ref.jwksUri);
887
1179
  if (fromUri?.keys?.length) return fromUri;
@@ -897,10 +1189,6 @@ var JwksService = class {
897
1189
  if (fromMeta?.keys?.length) return fromMeta;
898
1190
  }
899
1191
  }
900
- const maxStaleMs = Math.max(this.opts.providerJwksTtlMs * 4, 6e4);
901
- if (cached && Date.now() - cached.fetchedAt < maxStaleMs) {
902
- return cached.jwks;
903
- }
904
1192
  return void 0;
905
1193
  }
906
1194
  // ===========================================================================
@@ -1867,7 +2155,8 @@ function buildRefreshTokenRecord(params) {
1867
2155
  customClaims: params.customClaims,
1868
2156
  federatedLoginUsed: params.federatedLoginUsed,
1869
2157
  selectedProviderIds: params.selectedProviderIds,
1870
- skippedProviderIds: params.skippedProviderIds
2158
+ skippedProviderIds: params.skippedProviderIds,
2159
+ providerTokensId: params.providerTokensId
1871
2160
  };
1872
2161
  }
1873
2162
  function verifyPkce(codeVerifier, challenge) {
@@ -4885,7 +5174,49 @@ var VercelKvSessionStore = class {
4885
5174
  };
4886
5175
 
4887
5176
  // libs/auth/src/session/orchestrated-token.store.ts
4888
- import { encryptAesGcm as encryptAesGcm4, decryptAesGcm as decryptAesGcm4, randomBytes as randomBytes7, hkdfSha256 as hkdfSha2563 } from "@frontmcp/utils";
5177
+ import { decryptAesGcm as decryptAesGcm5, encryptAesGcm as encryptAesGcm5, hkdfSha256 as hkdfSha2564, randomBytes as randomBytes8 } from "@frontmcp/utils";
5178
+
5179
+ // libs/auth/src/session/orchestrated-token.crypto.ts
5180
+ import { decryptAesGcm as decryptAesGcm4, encryptAesGcm as encryptAesGcm4, hkdfSha256 as hkdfSha2563, randomBytes as randomBytes7 } from "@frontmcp/utils";
5181
+ function recordDropAt(record) {
5182
+ if (!record.refreshToken) return record.expiresAt;
5183
+ return Math.max(record.expiresAt ?? 0, record.updatedAt + REFRESH_TOKEN_TTL_MS);
5184
+ }
5185
+ function hasLiveAccessToken(record, refreshSkewMs = 0) {
5186
+ if (record.expiresAt === void 0) return true;
5187
+ return record.expiresAt - (record.refreshToken ? refreshSkewMs : 0) > Date.now();
5188
+ }
5189
+ var HKDF_SALT = new TextEncoder().encode("frontmcp-token-store");
5190
+ function deriveKeyForRecord(masterKey, compositeKey, cache) {
5191
+ const cached = cache.get(compositeKey);
5192
+ if (cached) {
5193
+ return cached;
5194
+ }
5195
+ const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
5196
+ const derived = hkdfSha2563(masterKey, HKDF_SALT, info, 32);
5197
+ cache.set(compositeKey, derived);
5198
+ return derived;
5199
+ }
5200
+ function encryptRecord(key, record) {
5201
+ const plaintext = JSON.stringify(record);
5202
+ const iv = randomBytes7(12);
5203
+ const { ciphertext, tag } = encryptAesGcm4(key, new TextEncoder().encode(plaintext), iv);
5204
+ return JSON.stringify({
5205
+ iv: Buffer.from(iv).toString("base64url"),
5206
+ tag: Buffer.from(tag).toString("base64url"),
5207
+ data: Buffer.from(ciphertext).toString("base64url")
5208
+ });
5209
+ }
5210
+ function decryptRecord(key, encrypted) {
5211
+ const { iv, tag, data } = JSON.parse(encrypted);
5212
+ const ivBytes = Buffer.from(iv, "base64url");
5213
+ const tagBytes = Buffer.from(tag, "base64url");
5214
+ const ciphertextBytes = Buffer.from(data, "base64url");
5215
+ const plaintext = decryptAesGcm4(key, ciphertextBytes, ivBytes, tagBytes);
5216
+ return JSON.parse(new TextDecoder().decode(plaintext));
5217
+ }
5218
+
5219
+ // libs/auth/src/session/orchestrated-token.store.ts
4889
5220
  var InMemoryOrchestratedTokenStore = class {
4890
5221
  /** Token storage: Map<compositeKey, ProviderTokenRecord> */
4891
5222
  tokens = /* @__PURE__ */ new Map();
@@ -4897,9 +5228,12 @@ var InMemoryOrchestratedTokenStore = class {
4897
5228
  cleanupTimer;
4898
5229
  /** Default TTL for records */
4899
5230
  defaultTtlMs;
5231
+ /** How long before its expiry a refreshable access token stops being returned */
5232
+ refreshSkewMs;
4900
5233
  constructor(options = {}) {
4901
5234
  this.encryptionKey = options.encryptionKey;
4902
5235
  this.defaultTtlMs = options.defaultTtlMs;
5236
+ this.refreshSkewMs = options.refreshSkewMs ?? 0;
4903
5237
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
4904
5238
  this.cleanupTimer = setInterval(() => {
4905
5239
  void this.cleanup();
@@ -4927,7 +5261,7 @@ var InMemoryOrchestratedTokenStore = class {
4927
5261
  }
4928
5262
  const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
4929
5263
  const salt = new TextEncoder().encode("frontmcp-token-store");
4930
- const derivedKey = hkdfSha2563(this.encryptionKey, salt, info, 32);
5264
+ const derivedKey = hkdfSha2564(this.encryptionKey, salt, info, 32);
4931
5265
  this.derivedKeys.set(compositeKey, derivedKey);
4932
5266
  return derivedKey;
4933
5267
  }
@@ -4937,8 +5271,8 @@ var InMemoryOrchestratedTokenStore = class {
4937
5271
  async encryptRecord(compositeKey, record) {
4938
5272
  const key = await this.deriveKeyForRecord(compositeKey);
4939
5273
  const plaintext = JSON.stringify(record);
4940
- const iv = randomBytes7(12);
4941
- const { ciphertext, tag } = encryptAesGcm4(key, new TextEncoder().encode(plaintext), iv);
5274
+ const iv = randomBytes8(12);
5275
+ const { ciphertext, tag } = encryptAesGcm5(key, new TextEncoder().encode(plaintext), iv);
4942
5276
  return JSON.stringify({
4943
5277
  iv: Buffer.from(iv).toString("base64url"),
4944
5278
  tag: Buffer.from(tag).toString("base64url"),
@@ -4954,7 +5288,7 @@ var InMemoryOrchestratedTokenStore = class {
4954
5288
  const ivBytes = Buffer.from(iv, "base64url");
4955
5289
  const tagBytes = Buffer.from(tag, "base64url");
4956
5290
  const ciphertextBytes = Buffer.from(data, "base64url");
4957
- const plaintext = decryptAesGcm4(key, ciphertextBytes, ivBytes, tagBytes);
5291
+ const plaintext = decryptAesGcm5(key, ciphertextBytes, ivBytes, tagBytes);
4958
5292
  return JSON.parse(new TextDecoder().decode(plaintext));
4959
5293
  }
4960
5294
  /**
@@ -4980,18 +5314,20 @@ var InMemoryOrchestratedTokenStore = class {
4980
5314
  } else {
4981
5315
  record = stored;
4982
5316
  }
4983
- if (record.expiresAt && record.expiresAt < Date.now()) {
5317
+ const dropAt = recordDropAt(record);
5318
+ if (dropAt && dropAt < Date.now()) {
4984
5319
  this.tokens.delete(key);
4985
5320
  return null;
4986
5321
  }
4987
5322
  return record;
4988
5323
  }
4989
5324
  /**
4990
- * Retrieve decrypted access token for a provider
5325
+ * Retrieve decrypted access token for a provider, or null once it has expired (a record with a
5326
+ * refresh token keeps that, to renew it)
4991
5327
  */
4992
5328
  async getAccessToken(authorizationId, providerId) {
4993
5329
  const record = await this.getRecord(authorizationId, providerId);
4994
- return record?.accessToken ?? null;
5330
+ return record && hasLiveAccessToken(record, this.refreshSkewMs) ? record.accessToken : null;
4995
5331
  }
4996
5332
  /**
4997
5333
  * Retrieve decrypted refresh token for a provider
@@ -5084,7 +5420,8 @@ var InMemoryOrchestratedTokenStore = class {
5084
5420
  } else {
5085
5421
  record = stored;
5086
5422
  }
5087
- if (record?.expiresAt && record.expiresAt < now) {
5423
+ const dropAt = record ? recordDropAt(record) : void 0;
5424
+ if (dropAt && dropAt < now) {
5088
5425
  keysToDelete.push(key);
5089
5426
  }
5090
5427
  }
@@ -5124,74 +5461,48 @@ var InMemoryOrchestratedTokenStore = class {
5124
5461
  * @param toAuthId - Target authorization ID (e.g., "def456")
5125
5462
  */
5126
5463
  async migrateTokens(fromAuthId, toAuthId) {
5127
- const prefix = `${fromAuthId}:`;
5128
- const keysToMigrate = [];
5129
- for (const key of this.tokens.keys()) {
5130
- if (key.startsWith(prefix)) {
5131
- keysToMigrate.push(key);
5132
- }
5464
+ for (const oldKey of await this.copyRecords(fromAuthId, toAuthId)) {
5465
+ this.tokens.delete(oldKey);
5466
+ this.derivedKeys.delete(oldKey);
5133
5467
  }
5134
- for (const oldKey of keysToMigrate) {
5135
- const providerId = oldKey.slice(prefix.length);
5136
- const newKey = this.buildKey(toAuthId, providerId);
5468
+ }
5469
+ /**
5470
+ * Copy tokens from one authorization ID to another, keeping them under the source ID too.
5471
+ */
5472
+ async copyTokens(fromAuthId, toAuthId) {
5473
+ await this.copyRecords(fromAuthId, toAuthId);
5474
+ }
5475
+ /** Copy every record of `fromAuthId` to `toAuthId`, skipping a corrupted one; returns the keys copied. */
5476
+ async copyRecords(fromAuthId, toAuthId) {
5477
+ const prefix = `${fromAuthId}:`;
5478
+ const sourceKeys = [...this.tokens.keys()].filter((key) => key.startsWith(prefix));
5479
+ const copiedKeys = [];
5480
+ for (const oldKey of sourceKeys) {
5137
5481
  const stored = this.tokens.get(oldKey);
5138
- if (!stored) {
5139
- continue;
5140
- }
5482
+ if (!stored) continue;
5483
+ const newKey = this.buildKey(toAuthId, oldKey.slice(prefix.length));
5141
5484
  if (this.encryptionKey) {
5142
5485
  try {
5143
5486
  const record = await this.decryptRecord(oldKey, stored);
5144
- const encrypted = await this.encryptRecord(newKey, record);
5145
- this.tokens.set(newKey, encrypted);
5487
+ this.tokens.set(newKey, await this.encryptRecord(newKey, record));
5146
5488
  } catch {
5147
5489
  continue;
5148
5490
  }
5149
5491
  } else {
5150
5492
  this.tokens.set(newKey, stored);
5151
5493
  }
5152
- this.tokens.delete(oldKey);
5153
- this.derivedKeys.delete(oldKey);
5494
+ copiedKeys.push(oldKey);
5154
5495
  }
5496
+ return copiedKeys;
5155
5497
  }
5156
5498
  };
5157
5499
 
5158
- // libs/auth/src/session/orchestrated-token.crypto.ts
5159
- import { decryptAesGcm as decryptAesGcm5, encryptAesGcm as encryptAesGcm5, hkdfSha256 as hkdfSha2564, randomBytes as randomBytes8 } from "@frontmcp/utils";
5160
- var HKDF_SALT = new TextEncoder().encode("frontmcp-token-store");
5161
- function deriveKeyForRecord(masterKey, compositeKey, cache) {
5162
- const cached = cache.get(compositeKey);
5163
- if (cached) {
5164
- return cached;
5165
- }
5166
- const info = new TextEncoder().encode(`orchestrated-token:${compositeKey}`);
5167
- const derived = hkdfSha2564(masterKey, HKDF_SALT, info, 32);
5168
- cache.set(compositeKey, derived);
5169
- return derived;
5170
- }
5171
- function encryptRecord(key, record) {
5172
- const plaintext = JSON.stringify(record);
5173
- const iv = randomBytes8(12);
5174
- const { ciphertext, tag } = encryptAesGcm5(key, new TextEncoder().encode(plaintext), iv);
5175
- return JSON.stringify({
5176
- iv: Buffer.from(iv).toString("base64url"),
5177
- tag: Buffer.from(tag).toString("base64url"),
5178
- data: Buffer.from(ciphertext).toString("base64url")
5179
- });
5180
- }
5181
- function decryptRecord(key, encrypted) {
5182
- const { iv, tag, data } = JSON.parse(encrypted);
5183
- const ivBytes = Buffer.from(iv, "base64url");
5184
- const tagBytes = Buffer.from(tag, "base64url");
5185
- const ciphertextBytes = Buffer.from(data, "base64url");
5186
- const plaintext = decryptAesGcm5(key, ciphertextBytes, ivBytes, tagBytes);
5187
- return JSON.parse(new TextDecoder().decode(plaintext));
5188
- }
5189
-
5190
5500
  // libs/auth/src/session/storage-orchestrated-token.store.ts
5191
5501
  var StorageOrchestratedTokenStore = class {
5192
5502
  storage;
5193
5503
  encryptionKey;
5194
5504
  defaultTtlMs;
5505
+ refreshSkewMs;
5195
5506
  namespace;
5196
5507
  storageIsNamespaced;
5197
5508
  /** HKDF-derived subkey cache, keyed by composite key. */
@@ -5199,6 +5510,7 @@ var StorageOrchestratedTokenStore = class {
5199
5510
  constructor(storage, options = {}) {
5200
5511
  this.encryptionKey = options.encryptionKey;
5201
5512
  this.defaultTtlMs = options.defaultTtlMs;
5513
+ this.refreshSkewMs = options.refreshSkewMs ?? 0;
5202
5514
  this.namespace = options.namespace ?? "otok";
5203
5515
  this.storageIsNamespaced = this.isNamespacedStorage(storage);
5204
5516
  this.storage = this.storageIsNamespaced ? storage.namespace(this.namespace) : storage;
@@ -5208,7 +5520,7 @@ var StorageOrchestratedTokenStore = class {
5208
5520
  // ============================================
5209
5521
  async getAccessToken(authorizationId, providerId) {
5210
5522
  const record = await this.getRecord(authorizationId, providerId);
5211
- return record?.accessToken ?? null;
5523
+ return record && hasLiveAccessToken(record, this.refreshSkewMs) ? record.accessToken : null;
5212
5524
  }
5213
5525
  async getRefreshToken(authorizationId, providerId) {
5214
5526
  const record = await this.getRecord(authorizationId, providerId);
@@ -5225,7 +5537,7 @@ var StorageOrchestratedTokenStore = class {
5225
5537
  updatedAt: now
5226
5538
  };
5227
5539
  const value = this.encryptionKey ? encryptRecord(this.keyFor(compositeKey), record) : JSON.stringify(record);
5228
- await this.storage.set(this.storageKey(compositeKey), value, this.ttlOptions(record.expiresAt));
5540
+ await this.storage.set(this.storageKey(compositeKey), value, this.ttlOptions(recordDropAt(record)));
5229
5541
  }
5230
5542
  async deleteTokens(authorizationId, providerId) {
5231
5543
  const compositeKey = this.compositeKey(authorizationId, providerId);
@@ -5251,12 +5563,37 @@ var StorageOrchestratedTokenStore = class {
5251
5563
  return providerIds;
5252
5564
  }
5253
5565
  async migrateTokens(fromAuthId, toAuthId) {
5566
+ for (const oldComposite of await this.copyRecords(fromAuthId, toAuthId)) {
5567
+ await this.storage.delete(this.storageKey(oldComposite));
5568
+ this.derivedKeys.delete(oldComposite);
5569
+ }
5570
+ }
5571
+ async copyTokens(fromAuthId, toAuthId) {
5572
+ await this.copyRecords(fromAuthId, toAuthId);
5573
+ }
5574
+ /**
5575
+ * Delete all tokens for an authorization id.
5576
+ */
5577
+ async deleteAllForAuthorization(authorizationId) {
5578
+ const prefix = `${authorizationId}:`;
5579
+ const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5580
+ for (const compositeKey of compositeKeys) {
5581
+ if (!compositeKey.startsWith(prefix)) continue;
5582
+ await this.storage.delete(this.storageKey(compositeKey));
5583
+ this.derivedKeys.delete(compositeKey);
5584
+ }
5585
+ }
5586
+ // ============================================
5587
+ // Internals
5588
+ // ============================================
5589
+ /** Copy every record of `fromAuthId` to `toAuthId`, skipping a corrupted one; returns the composite keys copied. */
5590
+ async copyRecords(fromAuthId, toAuthId) {
5254
5591
  const prefix = `${fromAuthId}:`;
5255
5592
  const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5593
+ const copiedKeys = [];
5256
5594
  for (const oldComposite of compositeKeys) {
5257
5595
  if (!oldComposite.startsWith(prefix)) continue;
5258
- const providerId = oldComposite.slice(prefix.length);
5259
- const newComposite = this.compositeKey(toAuthId, providerId);
5596
+ const newComposite = this.compositeKey(toAuthId, oldComposite.slice(prefix.length));
5260
5597
  const stored = await this.storage.get(this.storageKey(oldComposite));
5261
5598
  if (stored === null) continue;
5262
5599
  if (this.encryptionKey) {
@@ -5267,35 +5604,20 @@ var StorageOrchestratedTokenStore = class {
5267
5604
  continue;
5268
5605
  }
5269
5606
  const value = encryptRecord(this.keyFor(newComposite), record);
5270
- await this.storage.set(this.storageKey(newComposite), value, this.ttlOptions(record.expiresAt));
5607
+ await this.storage.set(this.storageKey(newComposite), value, this.ttlOptions(recordDropAt(record)));
5271
5608
  } else {
5272
- let expiresAt;
5609
+ let dropAt;
5273
5610
  try {
5274
- expiresAt = JSON.parse(stored).expiresAt;
5611
+ dropAt = recordDropAt(JSON.parse(stored));
5275
5612
  } catch {
5276
- expiresAt = void 0;
5613
+ dropAt = void 0;
5277
5614
  }
5278
- await this.storage.set(this.storageKey(newComposite), stored, this.ttlOptions(expiresAt));
5615
+ await this.storage.set(this.storageKey(newComposite), stored, this.ttlOptions(dropAt));
5279
5616
  }
5280
- await this.storage.delete(this.storageKey(oldComposite));
5281
- this.derivedKeys.delete(oldComposite);
5282
- }
5283
- }
5284
- /**
5285
- * Delete all tokens for an authorization id.
5286
- */
5287
- async deleteAllForAuthorization(authorizationId) {
5288
- const prefix = `${authorizationId}:`;
5289
- const compositeKeys = await this.listCompositeKeys(`${prefix}*`);
5290
- for (const compositeKey of compositeKeys) {
5291
- if (!compositeKey.startsWith(prefix)) continue;
5292
- await this.storage.delete(this.storageKey(compositeKey));
5293
- this.derivedKeys.delete(compositeKey);
5617
+ copiedKeys.push(oldComposite);
5294
5618
  }
5619
+ return copiedKeys;
5295
5620
  }
5296
- // ============================================
5297
- // Internals
5298
- // ============================================
5299
5621
  async getRecord(authorizationId, providerId) {
5300
5622
  const compositeKey = this.compositeKey(authorizationId, providerId);
5301
5623
  const stored = await this.storage.get(this.storageKey(compositeKey));
@@ -5318,7 +5640,8 @@ var StorageOrchestratedTokenStore = class {
5318
5640
  return null;
5319
5641
  }
5320
5642
  }
5321
- if (record.expiresAt && record.expiresAt < Date.now()) {
5643
+ const dropAt = recordDropAt(record);
5644
+ if (dropAt && dropAt < Date.now()) {
5322
5645
  await this.storage.delete(this.storageKey(compositeKey));
5323
5646
  return null;
5324
5647
  }
@@ -6770,7 +7093,7 @@ var OrchestratedAuthorization = class _OrchestratedAuthorization extends Authori
6770
7093
  const result = await this.#onTokenRefresh(providerId, refreshToken);
6771
7094
  await this.#tokenStore.storeTokens(this.id, providerId, {
6772
7095
  accessToken: result.accessToken,
6773
- refreshToken: result.refreshToken,
7096
+ refreshToken: result.refreshToken ?? refreshToken,
6774
7097
  expiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1e3 : void 0
6775
7098
  });
6776
7099
  const currentState = this.#providerStates.get(providerId);
@@ -7539,7 +7862,7 @@ var CimdService = class {
7539
7862
  cacheConfig;
7540
7863
  securityConfig;
7541
7864
  networkConfig;
7542
- cache;
7865
+ cacheBackend;
7543
7866
  logger;
7544
7867
  /**
7545
7868
  * Whether CIMD is enabled.
@@ -7559,7 +7882,6 @@ var CimdService = class {
7559
7882
  this.cacheConfig = cimdCacheConfigSchema.parse(this.config.cache ?? {});
7560
7883
  this.securityConfig = cimdSecurityConfigSchema.parse(this.config.security ?? {});
7561
7884
  this.networkConfig = cimdNetworkConfigSchema.parse(this.config.network ?? {});
7562
- this.cache = new InMemoryCimdCache(this.cacheConfig);
7563
7885
  this.logger.debug("CimdService initialized", {
7564
7886
  enabled: this.config.enabled,
7565
7887
  cacheDefaultTtlMs: this.cacheConfig.defaultTtlMs,
@@ -7571,6 +7893,34 @@ var CimdService = class {
7571
7893
  );
7572
7894
  }
7573
7895
  }
7896
+ /**
7897
+ * Create the cache now (connecting to Redis with `cache.type: 'redis'`), so a misconfigured or
7898
+ * unreachable cache fails at startup rather than on the first CIMD client.
7899
+ */
7900
+ async initialize() {
7901
+ await this.cache();
7902
+ }
7903
+ /**
7904
+ * The cache `cache.type` selects, created once through the `createCimdCache` factory. A failed
7905
+ * creation is retried on the next use.
7906
+ */
7907
+ cache() {
7908
+ if (!this.cacheBackend) {
7909
+ this.cacheBackend = createCimdCache(this.cacheConfig).catch((error) => {
7910
+ this.cacheBackend = void 0;
7911
+ throw error;
7912
+ });
7913
+ }
7914
+ return this.cacheBackend;
7915
+ }
7916
+ /** Close the cache's connection (Redis with `cache.type: 'redis'`), if one was created. */
7917
+ async dispose() {
7918
+ const backend = this.cacheBackend;
7919
+ this.cacheBackend = void 0;
7920
+ if (!backend) return;
7921
+ const createdBackend = await backend.catch(() => void 0);
7922
+ await createdBackend?.close?.();
7923
+ }
7574
7924
  /**
7575
7925
  * Check if a client_id is a CIMD URL.
7576
7926
  *
@@ -7597,7 +7947,8 @@ var CimdService = class {
7597
7947
  };
7598
7948
  }
7599
7949
  validateClientIdUrl(clientId, this.securityConfig);
7600
- const cached = await this.cache.get(clientId);
7950
+ const cache = await this.cache();
7951
+ const cached = await cache.get(clientId);
7601
7952
  if (cached) {
7602
7953
  this.logger.debug(`Cache hit for CIMD client: ${clientId}`);
7603
7954
  return {
@@ -7615,8 +7966,8 @@ var CimdService = class {
7615
7966
  if (this.securityConfig.warnOnLocalhostRedirects && hasOnlyLocalhostRedirectUris(document.redirect_uris)) {
7616
7967
  this.logger.warn(`CIMD client "${clientId}" has only localhost redirect URIs - this may be a development client`);
7617
7968
  }
7618
- await this.cache.set(clientId, document, headers);
7619
- const entry = await this.cache.get(clientId);
7969
+ await cache.set(clientId, document, headers);
7970
+ const entry = await cache.get(clientId);
7620
7971
  return {
7621
7972
  isCimdClient: true,
7622
7973
  metadata: document,
@@ -7646,11 +7997,12 @@ var CimdService = class {
7646
7997
  * @param clientId - Optional client_id to clear; clears all if not provided
7647
7998
  */
7648
7999
  async clearCache(clientId) {
8000
+ const cache = await this.cache();
7649
8001
  if (clientId) {
7650
- await this.cache.delete(clientId);
8002
+ await cache.delete(clientId);
7651
8003
  this.logger.debug(`Cache cleared for: ${clientId}`);
7652
8004
  } else {
7653
- await this.cache.clear();
8005
+ await cache.clear();
7654
8006
  this.logger.debug("Cache cleared");
7655
8007
  }
7656
8008
  }
@@ -7658,8 +8010,9 @@ var CimdService = class {
7658
8010
  * Get cache statistics.
7659
8011
  */
7660
8012
  async getCacheStats() {
8013
+ const cache = await this.cache();
7661
8014
  return {
7662
- size: await this.cache.size()
8015
+ size: await cache.size()
7663
8016
  };
7664
8017
  }
7665
8018
  /**
@@ -7671,7 +8024,8 @@ var CimdService = class {
7671
8024
  const controller = new AbortController();
7672
8025
  const timer = setTimeout(() => controller.abort(), this.networkConfig.timeoutMs);
7673
8026
  try {
7674
- const conditionalHeaders = await this.cache.getConditionalHeaders(clientId);
8027
+ const cache = await this.cache();
8028
+ const conditionalHeaders = await cache.getConditionalHeaders(clientId);
7675
8029
  const originalOrigin = new URL(clientId).origin;
7676
8030
  const maxRedirects = this.networkConfig.maxRedirects;
7677
8031
  let currentUrl = clientId;
@@ -7694,9 +8048,9 @@ var CimdService = class {
7694
8048
  redirect: "manual"
7695
8049
  });
7696
8050
  if (response.status === 304) {
7697
- const staleEntry = await this.cache.getStale(clientId);
8051
+ const staleEntry = await cache.getStale(clientId);
7698
8052
  if (staleEntry) {
7699
- await this.cache.revalidate(clientId, response.headers);
8053
+ await cache.revalidate(clientId, response.headers);
7700
8054
  this.logger.debug(`CIMD document not modified: ${clientId}`);
7701
8055
  return {
7702
8056
  document: staleEntry.document,
@@ -8204,7 +8558,7 @@ var tokenRefreshConfigSchema = z10.object({
8204
8558
  * Refresh token before expiry by this many seconds
8205
8559
  * @default 60
8206
8560
  */
8207
- skewSeconds: z10.number().default(60)
8561
+ skewSeconds: z10.number().nonnegative().default(60)
8208
8562
  });
8209
8563
  var skippedAppBehaviorSchema = z10.enum(["anonymous", "require-auth"]);
8210
8564
  var consentConfigSchema = z10.object({
@@ -8317,7 +8671,7 @@ var publicAuthOptionsSchema = z11.object({
8317
8671
  * Anonymous session TTL in seconds
8318
8672
  * @default 3600 (1 hour)
8319
8673
  */
8320
- sessionTtl: z11.number().default(3600),
8674
+ sessionTtl: z11.number().int().positive().default(3600),
8321
8675
  /**
8322
8676
  * Scopes granted to anonymous sessions
8323
8677
  * @default ['anonymous']
@@ -11310,8 +11664,16 @@ var AuthoritiesEvaluatorRegistry = class {
11310
11664
  };
11311
11665
 
11312
11666
  // libs/auth/src/authorities/authorities.errors.ts
11667
+ import { brandClass, isBrandedInstance } from "@frontmcp/utils";
11313
11668
  var FORBIDDEN_CODE = -32003;
11314
11669
  var AuthorityDeniedError = class extends Error {
11670
+ /**
11671
+ * `instanceof AuthorityDeniedError` also recognises a denial from another copy of this package in the
11672
+ * process, so every copy of `@frontmcp/sdk` answers it as a FORBIDDEN error (#802).
11673
+ */
11674
+ static [Symbol.hasInstance](value) {
11675
+ return isBrandedInstance(this, value);
11676
+ }
11315
11677
  /** MCP error code for JSON-RPC responses */
11316
11678
  mcpErrorCode = FORBIDDEN_CODE;
11317
11679
  /** HTTP status code equivalent */
@@ -11356,6 +11718,7 @@ var AuthorityDeniedError = class extends Error {
11356
11718
  };
11357
11719
  }
11358
11720
  };
11721
+ brandClass(AuthorityDeniedError, "@frontmcp/auth:AuthorityDeniedError");
11359
11722
 
11360
11723
  // libs/auth/src/fetch/fetch-credential-middleware.ts
11361
11724
  var bearerApplier = {