@frontmcp/auth 0.0.1 → 0.8.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.
- package/README.md +11 -0
- package/authorization/authorization.types.d.ts +236 -0
- package/authorization/authorization.types.d.ts.map +1 -0
- package/authorization/index.d.ts +9 -0
- package/authorization/index.d.ts.map +1 -0
- package/cimd/cimd-redis.cache.d.ts +111 -0
- package/cimd/cimd-redis.cache.d.ts.map +1 -0
- package/cimd/cimd.cache.d.ts +200 -0
- package/cimd/cimd.cache.d.ts.map +1 -0
- package/cimd/cimd.errors.d.ts +124 -0
- package/cimd/cimd.errors.d.ts.map +1 -0
- package/cimd/cimd.logger.d.ts +39 -0
- package/cimd/cimd.logger.d.ts.map +1 -0
- package/cimd/cimd.service.d.ts +88 -0
- package/cimd/cimd.service.d.ts.map +1 -0
- package/cimd/cimd.types.d.ts +178 -0
- package/cimd/cimd.types.d.ts.map +1 -0
- package/cimd/cimd.validator.d.ts +49 -0
- package/cimd/cimd.validator.d.ts.map +1 -0
- package/cimd/index.d.ts +17 -0
- package/cimd/index.d.ts.map +1 -0
- package/esm/index.mjs +4001 -0
- package/esm/package.json +59 -0
- package/index.d.ts +44 -0
- package/index.d.ts.map +1 -0
- package/index.js +4131 -0
- package/jwks/dev-key-persistence.d.ts +70 -0
- package/jwks/dev-key-persistence.d.ts.map +1 -0
- package/jwks/index.d.ts +20 -0
- package/jwks/index.d.ts.map +1 -0
- package/jwks/jwks.service.d.ts +69 -0
- package/jwks/jwks.service.d.ts.map +1 -0
- package/jwks/jwks.types.d.ts +33 -0
- package/jwks/jwks.types.d.ts.map +1 -0
- package/jwks/jwks.utils.d.ts +5 -0
- package/jwks/jwks.utils.d.ts.map +1 -0
- package/package.json +2 -2
- package/session/authorization-vault.d.ts +667 -0
- package/session/authorization-vault.d.ts.map +1 -0
- package/session/authorization.store.d.ts +311 -0
- package/session/authorization.store.d.ts.map +1 -0
- package/session/index.d.ts +19 -0
- package/session/index.d.ts.map +1 -0
- package/session/storage/in-memory-authorization-vault.d.ts +53 -0
- package/session/storage/in-memory-authorization-vault.d.ts.map +1 -0
- package/session/storage/index.d.ts +17 -0
- package/session/storage/index.d.ts.map +1 -0
- package/session/storage/storage-authorization-vault.d.ts +107 -0
- package/session/storage/storage-authorization-vault.d.ts.map +1 -0
- package/session/storage/storage-token-store.d.ts +92 -0
- package/session/storage/storage-token-store.d.ts.map +1 -0
- package/session/token.store.d.ts +39 -0
- package/session/token.store.d.ts.map +1 -0
- package/session/token.vault.d.ts +33 -0
- package/session/token.vault.d.ts.map +1 -0
- package/session/utils/index.d.ts +5 -0
- package/session/utils/index.d.ts.map +1 -0
- package/session/utils/tiny-ttl-cache.d.ts +20 -0
- package/session/utils/tiny-ttl-cache.d.ts.map +1 -0
- package/session/vault-encryption.d.ts +190 -0
- package/session/vault-encryption.d.ts.map +1 -0
- package/ui/base-layout.d.ts +170 -0
- package/ui/base-layout.d.ts.map +1 -0
- package/ui/index.d.ts +10 -0
- package/ui/index.d.ts.map +1 -0
- package/ui/templates.d.ts +134 -0
- package/ui/templates.d.ts.map +1 -0
- package/utils/audience.validator.d.ts +130 -0
- package/utils/audience.validator.d.ts.map +1 -0
- package/utils/index.d.ts +8 -0
- package/utils/index.d.ts.map +1 -0
- package/utils/www-authenticate.utils.d.ts +98 -0
- package/utils/www-authenticate.utils.d.ts.map +1 -0
- package/vault/auth-providers.types.d.ts +262 -0
- package/vault/auth-providers.types.d.ts.map +1 -0
- package/vault/credential-cache.d.ts +98 -0
- package/vault/credential-cache.d.ts.map +1 -0
- package/vault/credential-helpers.d.ts +14 -0
- package/vault/credential-helpers.d.ts.map +1 -0
- package/vault/index.d.ts +10 -0
- package/vault/index.d.ts.map +1 -0
package/esm/index.mjs
ADDED
|
@@ -0,0 +1,4001 @@
|
|
|
1
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// libs/auth/src/cimd/cimd.cache.ts
|
|
13
|
+
function extractCacheHeaders(headers) {
|
|
14
|
+
return {
|
|
15
|
+
"cache-control": headers.get("cache-control") ?? void 0,
|
|
16
|
+
expires: headers.get("expires") ?? void 0,
|
|
17
|
+
etag: headers.get("etag") ?? void 0,
|
|
18
|
+
"last-modified": headers.get("last-modified") ?? void 0,
|
|
19
|
+
age: headers.get("age") ?? void 0
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function parseCacheHeaders(headers, config) {
|
|
23
|
+
let ttlMs = config.defaultTtlMs;
|
|
24
|
+
if (headers["cache-control"]) {
|
|
25
|
+
const cacheControl = parseCacheControlHeader(headers["cache-control"]);
|
|
26
|
+
if (cacheControl["no-store"] || cacheControl["no-cache"]) {
|
|
27
|
+
ttlMs = config.minTtlMs;
|
|
28
|
+
} else if (typeof cacheControl["max-age"] === "number") {
|
|
29
|
+
let maxAgeSecs = cacheControl["max-age"];
|
|
30
|
+
if (headers.age) {
|
|
31
|
+
const ageSeconds = parseInt(headers.age, 10);
|
|
32
|
+
if (!isNaN(ageSeconds)) {
|
|
33
|
+
maxAgeSecs = Math.max(0, maxAgeSecs - ageSeconds);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
ttlMs = maxAgeSecs * 1e3;
|
|
37
|
+
}
|
|
38
|
+
if (typeof cacheControl["s-maxage"] === "number") {
|
|
39
|
+
let sMaxAgeSecs = cacheControl["s-maxage"];
|
|
40
|
+
if (headers.age) {
|
|
41
|
+
const ageSeconds = parseInt(headers.age, 10);
|
|
42
|
+
if (!isNaN(ageSeconds)) {
|
|
43
|
+
sMaxAgeSecs = Math.max(0, sMaxAgeSecs - ageSeconds);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
ttlMs = sMaxAgeSecs * 1e3;
|
|
47
|
+
}
|
|
48
|
+
} else if (headers.expires) {
|
|
49
|
+
const expiresDate = new Date(headers.expires);
|
|
50
|
+
if (!isNaN(expiresDate.getTime())) {
|
|
51
|
+
ttlMs = Math.max(0, expiresDate.getTime() - Date.now());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
ttlMs = Math.max(config.minTtlMs, Math.min(config.maxTtlMs, ttlMs));
|
|
55
|
+
return {
|
|
56
|
+
ttlMs,
|
|
57
|
+
etag: headers.etag,
|
|
58
|
+
lastModified: headers["last-modified"]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseCacheControlHeader(value) {
|
|
62
|
+
const result = {};
|
|
63
|
+
const directives = value.toLowerCase().split(",").map((d) => d.trim());
|
|
64
|
+
for (const directive of directives) {
|
|
65
|
+
const [key, val] = directive.split("=").map((s) => s.trim());
|
|
66
|
+
if (val !== void 0) {
|
|
67
|
+
const numVal = parseInt(val, 10);
|
|
68
|
+
if (!isNaN(numVal)) {
|
|
69
|
+
result[key] = numVal;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
result[key] = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
var InMemoryCimdCache, CimdCache;
|
|
78
|
+
var init_cimd_cache = __esm({
|
|
79
|
+
"libs/auth/src/cimd/cimd.cache.ts"() {
|
|
80
|
+
"use strict";
|
|
81
|
+
InMemoryCimdCache = class {
|
|
82
|
+
cache = /* @__PURE__ */ new Map();
|
|
83
|
+
config;
|
|
84
|
+
constructor(config) {
|
|
85
|
+
this.config = {
|
|
86
|
+
defaultTtlMs: config?.defaultTtlMs ?? 36e5,
|
|
87
|
+
maxTtlMs: config?.maxTtlMs ?? 864e5,
|
|
88
|
+
minTtlMs: config?.minTtlMs ?? 6e4
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get a cached entry by client_id.
|
|
93
|
+
*
|
|
94
|
+
* @param clientId - The client_id URL
|
|
95
|
+
* @returns The cached entry if valid, or undefined
|
|
96
|
+
*/
|
|
97
|
+
async get(clientId) {
|
|
98
|
+
const entry = this.cache.get(clientId);
|
|
99
|
+
if (!entry) {
|
|
100
|
+
return void 0;
|
|
101
|
+
}
|
|
102
|
+
if (entry.expiresAt < Date.now()) {
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get a stale entry for conditional revalidation.
|
|
109
|
+
*
|
|
110
|
+
* @param clientId - The client_id URL
|
|
111
|
+
* @returns The stale entry (even if expired), or undefined if not cached
|
|
112
|
+
*/
|
|
113
|
+
async getStale(clientId) {
|
|
114
|
+
return this.cache.get(clientId);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Store a document in the cache.
|
|
118
|
+
*
|
|
119
|
+
* @param clientId - The client_id URL
|
|
120
|
+
* @param document - The metadata document
|
|
121
|
+
* @param headers - HTTP response headers
|
|
122
|
+
*/
|
|
123
|
+
async set(clientId, document, headers) {
|
|
124
|
+
const cacheHeaders = extractCacheHeaders(headers);
|
|
125
|
+
const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const entry = {
|
|
128
|
+
document,
|
|
129
|
+
expiresAt: now + ttlMs,
|
|
130
|
+
etag,
|
|
131
|
+
lastModified,
|
|
132
|
+
cachedAt: now
|
|
133
|
+
};
|
|
134
|
+
this.cache.set(clientId, entry);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Update an existing cache entry (after 304 Not Modified).
|
|
138
|
+
*
|
|
139
|
+
* @param clientId - The client_id URL
|
|
140
|
+
* @param headers - New HTTP headers with updated cache directives
|
|
141
|
+
*/
|
|
142
|
+
async revalidate(clientId, headers) {
|
|
143
|
+
const existing = this.cache.get(clientId);
|
|
144
|
+
if (!existing) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const cacheHeaders = extractCacheHeaders(headers);
|
|
148
|
+
const { ttlMs, etag, lastModified } = parseCacheHeaders(cacheHeaders, this.config);
|
|
149
|
+
existing.expiresAt = Date.now() + ttlMs;
|
|
150
|
+
if (etag) existing.etag = etag;
|
|
151
|
+
if (lastModified) existing.lastModified = lastModified;
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Delete a cache entry.
|
|
156
|
+
*
|
|
157
|
+
* @param clientId - The client_id URL
|
|
158
|
+
* @returns true if an entry was deleted
|
|
159
|
+
*/
|
|
160
|
+
async delete(clientId) {
|
|
161
|
+
return this.cache.delete(clientId);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get conditional request headers for a cached entry.
|
|
165
|
+
*
|
|
166
|
+
* @param clientId - The client_id URL
|
|
167
|
+
* @returns Headers for conditional request, or undefined if not cached
|
|
168
|
+
*/
|
|
169
|
+
async getConditionalHeaders(clientId) {
|
|
170
|
+
const entry = this.cache.get(clientId);
|
|
171
|
+
if (!entry) {
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
const headers = {};
|
|
175
|
+
if (entry.etag) {
|
|
176
|
+
headers["If-None-Match"] = entry.etag;
|
|
177
|
+
}
|
|
178
|
+
if (entry.lastModified) {
|
|
179
|
+
headers["If-Modified-Since"] = entry.lastModified;
|
|
180
|
+
}
|
|
181
|
+
return Object.keys(headers).length > 0 ? headers : void 0;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Clear all cached entries.
|
|
185
|
+
*/
|
|
186
|
+
async clear() {
|
|
187
|
+
this.cache.clear();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get the number of cached entries.
|
|
191
|
+
*/
|
|
192
|
+
async size() {
|
|
193
|
+
return this.cache.size;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Remove expired entries.
|
|
197
|
+
*
|
|
198
|
+
* @returns Number of entries removed
|
|
199
|
+
*/
|
|
200
|
+
async cleanup() {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
let removed = 0;
|
|
203
|
+
for (const [clientId, entry] of this.cache) {
|
|
204
|
+
if (entry.expiresAt + this.config.maxTtlMs * 2 < now) {
|
|
205
|
+
this.cache.delete(clientId);
|
|
206
|
+
removed++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return removed;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
CimdCache = InMemoryCimdCache;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// libs/auth/src/jwks/jwks.service.ts
|
|
217
|
+
import { jwtVerify, createLocalJWKSet, decodeProtectedHeader } from "jose";
|
|
218
|
+
import { bytesToHex, randomBytes, rsaVerify, createKeyPersistence } from "@frontmcp/utils";
|
|
219
|
+
|
|
220
|
+
// libs/auth/src/jwks/jwks.utils.ts
|
|
221
|
+
function trimSlash(s) {
|
|
222
|
+
return (s ?? "").replace(/\/+$/, "");
|
|
223
|
+
}
|
|
224
|
+
function normalizeIssuer(u) {
|
|
225
|
+
return trimSlash(String(u ?? ""));
|
|
226
|
+
}
|
|
227
|
+
function decodeJwtPayloadSafe(token) {
|
|
228
|
+
if (!token) return void 0;
|
|
229
|
+
const parts = token.split(".");
|
|
230
|
+
if (parts.length < 2) return void 0;
|
|
231
|
+
try {
|
|
232
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
233
|
+
const json = typeof Buffer !== "undefined" ? Buffer.from(b64, "base64").toString("utf8") : (
|
|
234
|
+
// browser fallback
|
|
235
|
+
atob(b64)
|
|
236
|
+
);
|
|
237
|
+
const obj = JSON.parse(json);
|
|
238
|
+
return obj && typeof obj === "object" && !Array.isArray(obj) ? obj : void 0;
|
|
239
|
+
} catch {
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// libs/auth/src/jwks/jwks.service.ts
|
|
245
|
+
var WEAK_KEY_WARNING = `
|
|
246
|
+
\u26A0\uFE0F SECURITY WARNING: OAuth provider is using an RSA key smaller than 2048 bits.
|
|
247
|
+
This is considered insecure and should be updated.
|
|
248
|
+
Please contact your OAuth provider to upgrade their signing keys.
|
|
249
|
+
Verification will proceed but with reduced security guarantees.
|
|
250
|
+
`;
|
|
251
|
+
var JwksService = class {
|
|
252
|
+
opts;
|
|
253
|
+
warnedProviders = /* @__PURE__ */ new Set();
|
|
254
|
+
// Orchestrator signing material
|
|
255
|
+
orchestratorKey;
|
|
256
|
+
// Provider JWKS cache (providerId -> jwks + fetchedAt)
|
|
257
|
+
providerJwks = /* @__PURE__ */ new Map();
|
|
258
|
+
// Track if key has been initialized (for async loading)
|
|
259
|
+
keyInitialized = false;
|
|
260
|
+
// Promise guard to prevent concurrent key generation
|
|
261
|
+
keyInitPromise;
|
|
262
|
+
// KeyPersistence instance for unified key storage
|
|
263
|
+
keyPersistence;
|
|
264
|
+
constructor(opts) {
|
|
265
|
+
this.opts = {
|
|
266
|
+
orchestratorAlg: opts?.orchestratorAlg ?? "RS256",
|
|
267
|
+
rotateDays: opts?.rotateDays ?? 30,
|
|
268
|
+
providerJwksTtlMs: opts?.providerJwksTtlMs ?? 6 * 60 * 60 * 1e3,
|
|
269
|
+
// 6h
|
|
270
|
+
networkTimeoutMs: opts?.networkTimeoutMs ?? 5e3,
|
|
271
|
+
// 5s
|
|
272
|
+
devKeyPersistence: opts?.devKeyPersistence
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// ===========================================================================
|
|
276
|
+
// Key Persistence Helpers
|
|
277
|
+
// ===========================================================================
|
|
278
|
+
/**
|
|
279
|
+
* Check if key persistence should be enabled.
|
|
280
|
+
* Enabled in development by default, disabled in production unless forceEnable.
|
|
281
|
+
*/
|
|
282
|
+
shouldEnablePersistence() {
|
|
283
|
+
const isProd = process.env["NODE_ENV"] === "production";
|
|
284
|
+
if (this.opts.devKeyPersistence?.forceEnable) return true;
|
|
285
|
+
return !isProd;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Get or create the KeyPersistence instance.
|
|
289
|
+
* Returns null if persistence is disabled.
|
|
290
|
+
*/
|
|
291
|
+
async getKeyPersistence() {
|
|
292
|
+
if (!this.shouldEnablePersistence()) return null;
|
|
293
|
+
if (!this.keyPersistence) {
|
|
294
|
+
this.keyPersistence = await createKeyPersistence({
|
|
295
|
+
baseDir: ".frontmcp/keys"
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return this.keyPersistence;
|
|
299
|
+
}
|
|
300
|
+
// ===========================================================================
|
|
301
|
+
// Public JWKS (what /.well-known/jwks.json serves)
|
|
302
|
+
// ===========================================================================
|
|
303
|
+
/** Gateway's public JWKS (publish at /.well-known/jwks.json when orchestrated). */
|
|
304
|
+
async getPublicJwks() {
|
|
305
|
+
return this.getOrchestratorJwks();
|
|
306
|
+
}
|
|
307
|
+
// ===========================================================================
|
|
308
|
+
// Scope-aware verification API
|
|
309
|
+
// ===========================================================================
|
|
310
|
+
/** Verify a token issued by the gateway itself (orchestrated mode). */
|
|
311
|
+
async verifyGatewayToken(token, expectedIssuer) {
|
|
312
|
+
try {
|
|
313
|
+
const payload = decodeJwtPayloadSafe(token);
|
|
314
|
+
if (!payload) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
error: "invalid bearer token"
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
issuer: expectedIssuer,
|
|
323
|
+
sub: payload["sub"],
|
|
324
|
+
payload,
|
|
325
|
+
header: decodeProtectedHeader(token)
|
|
326
|
+
};
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const message = err instanceof Error ? err.message : "verification_failed";
|
|
329
|
+
return { ok: false, error: message };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Verify a token against candidate transparent providers.
|
|
334
|
+
* Ensures JWKS are available (cached/TTL/AS discovery) per provider.
|
|
335
|
+
*/
|
|
336
|
+
async verifyTransparentToken(token, candidates) {
|
|
337
|
+
if (!candidates?.length) return { ok: false, error: "no_providers" };
|
|
338
|
+
let kid;
|
|
339
|
+
try {
|
|
340
|
+
const header = decodeProtectedHeader(token);
|
|
341
|
+
kid = typeof header?.kid === "string" ? header.kid : void 0;
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
for (const p of candidates) {
|
|
345
|
+
let jwks;
|
|
346
|
+
try {
|
|
347
|
+
jwks = await this.getJwksForProvider(p);
|
|
348
|
+
if (!jwks?.keys?.length) continue;
|
|
349
|
+
const draftPayload = decodeJwtPayloadSafe(token);
|
|
350
|
+
const JWKS = createLocalJWKSet(jwks);
|
|
351
|
+
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
|
|
352
|
+
issuer: [normalizeIssuer(p.issuerUrl)].concat(
|
|
353
|
+
draftPayload?.["iss"] ? [draftPayload["iss"]] : []
|
|
354
|
+
)
|
|
355
|
+
// used because current cloud gateway have invalid issuer
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
issuer: payload?.iss,
|
|
360
|
+
sub: payload?.sub,
|
|
361
|
+
providerId: p.id,
|
|
362
|
+
header: protectedHeader,
|
|
363
|
+
payload
|
|
364
|
+
};
|
|
365
|
+
} catch (e) {
|
|
366
|
+
if (this.isWeakKeyError(e)) {
|
|
367
|
+
const fallbackJwks = jwks ?? await this.getJwksForProvider(p);
|
|
368
|
+
if (fallbackJwks?.keys?.length) {
|
|
369
|
+
const fallbackResult = await this.verifyWithWeakKey(token, fallbackJwks, p);
|
|
370
|
+
if (fallbackResult.ok) {
|
|
371
|
+
return fallbackResult;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
console.log("failed to verify token for provider: ", p.id, e);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { ok: false, error: `no_provider_verified${kid ? ` (kid=${kid})` : ""}` };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Check if the error is due to weak RSA key (< 2048 bits)
|
|
382
|
+
*/
|
|
383
|
+
isWeakKeyError(error) {
|
|
384
|
+
const message = typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" ? error.message : String(error);
|
|
385
|
+
return message.includes("modulusLength") && message.includes("2048");
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Fallback verification for providers using RSA keys smaller than 2048 bits.
|
|
389
|
+
* Logs a security warning but allows verification to proceed.
|
|
390
|
+
*/
|
|
391
|
+
async verifyWithWeakKey(token, jwks, provider) {
|
|
392
|
+
try {
|
|
393
|
+
const parts = token.split(".");
|
|
394
|
+
if (parts.length !== 3) {
|
|
395
|
+
return { ok: false, error: "invalid_token_format" };
|
|
396
|
+
}
|
|
397
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
398
|
+
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8"));
|
|
399
|
+
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
|
|
400
|
+
const matchingKey = this.findMatchingKey(jwks, header);
|
|
401
|
+
if (!matchingKey) {
|
|
402
|
+
return { ok: false, error: "no_matching_key" };
|
|
403
|
+
}
|
|
404
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
405
|
+
const signature = Buffer.from(signatureB64, "base64url");
|
|
406
|
+
const jwtAlg = typeof header.alg === "string" ? header.alg : void 0;
|
|
407
|
+
if (!jwtAlg) {
|
|
408
|
+
return { ok: false, error: "missing_alg" };
|
|
409
|
+
}
|
|
410
|
+
const isValid = rsaVerify(jwtAlg, Buffer.from(signatureInput), matchingKey, signature);
|
|
411
|
+
if (!isValid) {
|
|
412
|
+
return { ok: false, error: "signature_invalid" };
|
|
413
|
+
}
|
|
414
|
+
const payloadIssuerRaw = typeof payload.iss === "string" ? payload.iss : void 0;
|
|
415
|
+
if (!payloadIssuerRaw) {
|
|
416
|
+
return { ok: false, error: "issuer_mismatch" };
|
|
417
|
+
}
|
|
418
|
+
const trustedIssuers = /* @__PURE__ */ new Set([normalizeIssuer(provider.issuerUrl)]);
|
|
419
|
+
const payloadIssuer = normalizeIssuer(payloadIssuerRaw);
|
|
420
|
+
if (!trustedIssuers.has(payloadIssuer)) {
|
|
421
|
+
return { ok: false, error: "issuer_mismatch" };
|
|
422
|
+
}
|
|
423
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) {
|
|
424
|
+
return { ok: false, error: "token_expired" };
|
|
425
|
+
}
|
|
426
|
+
if (!this.warnedProviders.has(provider.id)) {
|
|
427
|
+
this.warnedProviders.add(provider.id);
|
|
428
|
+
console.warn(WEAK_KEY_WARNING);
|
|
429
|
+
console.warn(` Provider: ${provider.id} (${provider.issuerUrl})`);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: true,
|
|
433
|
+
issuer: payload.iss,
|
|
434
|
+
sub: payload.sub,
|
|
435
|
+
providerId: provider.id,
|
|
436
|
+
header,
|
|
437
|
+
payload
|
|
438
|
+
};
|
|
439
|
+
} catch (err) {
|
|
440
|
+
const message = err instanceof Error ? err.message : "unknown";
|
|
441
|
+
return { ok: false, error: `weak_key_verification_failed: ${message}` };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Find a matching key from JWKS based on token header
|
|
446
|
+
*/
|
|
447
|
+
findMatchingKey(jwks, header) {
|
|
448
|
+
if (header.kid) {
|
|
449
|
+
const byKid = jwks.keys.find((k) => k.kid === header.kid);
|
|
450
|
+
if (byKid) return byKid;
|
|
451
|
+
}
|
|
452
|
+
if (header.alg) {
|
|
453
|
+
const byAlg = jwks.keys.find((k) => k.alg === header.alg || k.kty === "RSA" && header.alg?.startsWith("RS"));
|
|
454
|
+
if (byAlg) return byAlg;
|
|
455
|
+
}
|
|
456
|
+
return jwks.keys.find((k) => k.kty === "RSA");
|
|
457
|
+
}
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
// Provider JWKS (cache + preload + discovery)
|
|
460
|
+
// ===========================================================================
|
|
461
|
+
/** Directly set provider JWKS (e.g., inline keys from config). */
|
|
462
|
+
setProviderJwks(providerId, jwks) {
|
|
463
|
+
this.providerJwks.set(providerId, { jwks, fetchedAt: Date.now() });
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Ensure JWKS for a provider:
|
|
467
|
+
* 1) inline jwks (if provided) → cache & return
|
|
468
|
+
* 2) cached & fresh (TTL) → return
|
|
469
|
+
* 3) explicit jwksUri → fetch, cache, return
|
|
470
|
+
* 4) discover jwks_uri via AS → fetch AS metadata, then jwks_uri, cache, return
|
|
471
|
+
*/
|
|
472
|
+
async getJwksForProvider(ref) {
|
|
473
|
+
if (ref.jwks?.keys?.length) {
|
|
474
|
+
this.setProviderJwks(ref.id, ref.jwks);
|
|
475
|
+
return ref.jwks;
|
|
476
|
+
}
|
|
477
|
+
const cached = this.providerJwks.get(ref.id);
|
|
478
|
+
if (cached && Date.now() - cached.fetchedAt < this.opts.providerJwksTtlMs) {
|
|
479
|
+
return cached.jwks;
|
|
480
|
+
}
|
|
481
|
+
if (ref.jwksUri) {
|
|
482
|
+
const fromUri = await this.tryFetchJwks(ref.id, ref.jwksUri);
|
|
483
|
+
if (fromUri?.keys?.length) return fromUri;
|
|
484
|
+
}
|
|
485
|
+
const issuer = trimSlash(ref.issuerUrl);
|
|
486
|
+
const meta = await this.tryFetchAsMeta(`${issuer}/.well-known/oauth-authorization-server`);
|
|
487
|
+
const uri = meta && typeof meta === "object" && meta["jwks_uri"] ? String(meta["jwks_uri"]) : void 0;
|
|
488
|
+
if (uri) {
|
|
489
|
+
const fromMeta = await this.tryFetchJwks(ref.id, uri);
|
|
490
|
+
if (fromMeta?.keys?.length) return fromMeta;
|
|
491
|
+
}
|
|
492
|
+
return cached?.jwks;
|
|
493
|
+
}
|
|
494
|
+
// ===========================================================================
|
|
495
|
+
// Orchestrator keys (generation/rotation)
|
|
496
|
+
// ===========================================================================
|
|
497
|
+
/** Return the orchestrator public JWKS (generates/rotates as needed). */
|
|
498
|
+
async getOrchestratorJwks() {
|
|
499
|
+
await this.ensureOrchestratorKey();
|
|
500
|
+
return this.orchestratorKey.publicJwk;
|
|
501
|
+
}
|
|
502
|
+
/** Return private signing key + kid for issuing orchestrator tokens. */
|
|
503
|
+
async getOrchestratorSigningKey() {
|
|
504
|
+
await this.ensureOrchestratorKey();
|
|
505
|
+
return { kid: this.orchestratorKey.kid, key: this.orchestratorKey.privateKey, alg: this.opts.orchestratorAlg };
|
|
506
|
+
}
|
|
507
|
+
// ===========================================================================
|
|
508
|
+
// Internals (fetch, rotation, helpers)
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
async tryFetchJwks(providerId, uri) {
|
|
511
|
+
try {
|
|
512
|
+
const jwks = await this.fetchJson(uri);
|
|
513
|
+
if (jwks?.keys?.length) {
|
|
514
|
+
this.setProviderJwks(providerId, jwks);
|
|
515
|
+
return jwks;
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
return void 0;
|
|
520
|
+
}
|
|
521
|
+
async tryFetchAsMeta(url) {
|
|
522
|
+
try {
|
|
523
|
+
return await this.fetchJson(url);
|
|
524
|
+
} catch {
|
|
525
|
+
return void 0;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async fetchJson(url) {
|
|
529
|
+
const ctl = typeof AbortController !== "undefined" ? new AbortController() : void 0;
|
|
530
|
+
const timer = setTimeout(() => ctl?.abort(), this.opts.networkTimeoutMs);
|
|
531
|
+
try {
|
|
532
|
+
const res = await fetch(url, {
|
|
533
|
+
method: "GET",
|
|
534
|
+
headers: { accept: "application/json" },
|
|
535
|
+
signal: ctl?.signal
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
538
|
+
return await res.json();
|
|
539
|
+
} finally {
|
|
540
|
+
clearTimeout(timer);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async ensureOrchestratorKey() {
|
|
544
|
+
const now = Date.now();
|
|
545
|
+
const maxAge = this.opts.rotateDays * 24 * 60 * 60 * 1e3;
|
|
546
|
+
if (this.orchestratorKey && now - this.orchestratorKey.createdAt <= maxAge) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (this.keyInitPromise) {
|
|
550
|
+
await this.keyInitPromise;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.keyInitPromise = this.initializeOrchestratorKey(now, maxAge);
|
|
554
|
+
try {
|
|
555
|
+
await this.keyInitPromise;
|
|
556
|
+
} finally {
|
|
557
|
+
this.keyInitPromise = void 0;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async initializeOrchestratorKey(now, maxAge) {
|
|
561
|
+
const persistence = await this.getKeyPersistence();
|
|
562
|
+
if (persistence && !this.keyInitialized) {
|
|
563
|
+
this.keyInitialized = true;
|
|
564
|
+
const loaded = await persistence.getAsymmetric("jwks-orchestrator");
|
|
565
|
+
if (loaded && now - loaded.createdAt <= maxAge) {
|
|
566
|
+
if (loaded.alg !== this.opts.orchestratorAlg) {
|
|
567
|
+
console.warn(
|
|
568
|
+
`[JwksService] Persisted key algorithm (${loaded.alg}) doesn't match config (${this.opts.orchestratorAlg}), generating new key`
|
|
569
|
+
);
|
|
570
|
+
} else {
|
|
571
|
+
try {
|
|
572
|
+
const { createPrivateKey } = __require("node:crypto");
|
|
573
|
+
const privateKey = createPrivateKey({
|
|
574
|
+
key: loaded.privateKey,
|
|
575
|
+
format: "jwk"
|
|
576
|
+
});
|
|
577
|
+
this.orchestratorKey = {
|
|
578
|
+
kid: loaded.kid,
|
|
579
|
+
privateKey,
|
|
580
|
+
publicJwk: loaded.publicJwk,
|
|
581
|
+
createdAt: loaded.createdAt
|
|
582
|
+
};
|
|
583
|
+
return;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
console.warn(`[JwksService] Failed to load persisted key: ${error.message}, generating new key`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
this.orchestratorKey = this.generateKey(this.opts.orchestratorAlg);
|
|
591
|
+
this.keyInitialized = true;
|
|
592
|
+
if (persistence) {
|
|
593
|
+
try {
|
|
594
|
+
await persistence.set({
|
|
595
|
+
type: "asymmetric",
|
|
596
|
+
kid: this.orchestratorKey.kid,
|
|
597
|
+
alg: this.opts.orchestratorAlg,
|
|
598
|
+
privateKey: this.orchestratorKey.privateKey.export({ format: "jwk" }),
|
|
599
|
+
publicJwk: this.orchestratorKey.publicJwk,
|
|
600
|
+
createdAt: this.orchestratorKey.createdAt,
|
|
601
|
+
version: 1
|
|
602
|
+
});
|
|
603
|
+
} catch (error) {
|
|
604
|
+
console.warn(`[JwksService] Failed to persist dev key: ${error.message}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
generateKey(alg) {
|
|
609
|
+
const { generateKeyPairSync } = __require("node:crypto");
|
|
610
|
+
if (alg === "RS256") {
|
|
611
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
612
|
+
const kid = bytesToHex(randomBytes(8));
|
|
613
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
614
|
+
Object.assign(publicJwk, { kid, alg: "RS256", use: "sig", kty: "RSA" });
|
|
615
|
+
return { kid, privateKey, publicJwk: { keys: [publicJwk] }, createdAt: Date.now() };
|
|
616
|
+
} else {
|
|
617
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
618
|
+
const kid = bytesToHex(randomBytes(8));
|
|
619
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
620
|
+
Object.assign(publicJwk, { kid, alg: "ES256", use: "sig", kty: "EC" });
|
|
621
|
+
return { kid, privateKey, publicJwk: { keys: [publicJwk] }, createdAt: Date.now() };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// libs/auth/src/jwks/dev-key-persistence.ts
|
|
627
|
+
import * as path from "path";
|
|
628
|
+
import * as crypto from "crypto";
|
|
629
|
+
import { z } from "zod";
|
|
630
|
+
import { readFile, mkdir, writeFile, rename, unlink } from "@frontmcp/utils";
|
|
631
|
+
var DEFAULT_KEY_PATH = ".frontmcp/dev-keys.json";
|
|
632
|
+
var rsaPrivateKeySchema = z.object({
|
|
633
|
+
kty: z.literal("RSA"),
|
|
634
|
+
n: z.string().min(1),
|
|
635
|
+
e: z.string().min(1),
|
|
636
|
+
d: z.string().min(1),
|
|
637
|
+
p: z.string().optional(),
|
|
638
|
+
q: z.string().optional(),
|
|
639
|
+
dp: z.string().optional(),
|
|
640
|
+
dq: z.string().optional(),
|
|
641
|
+
qi: z.string().optional()
|
|
642
|
+
}).passthrough();
|
|
643
|
+
var ecPrivateKeySchema = z.object({
|
|
644
|
+
kty: z.literal("EC"),
|
|
645
|
+
crv: z.string().min(1),
|
|
646
|
+
x: z.string().min(1),
|
|
647
|
+
y: z.string().min(1),
|
|
648
|
+
d: z.string().min(1)
|
|
649
|
+
}).passthrough();
|
|
650
|
+
var publicJwkSchema = z.object({
|
|
651
|
+
kty: z.enum(["RSA", "EC"]),
|
|
652
|
+
kid: z.string().min(1),
|
|
653
|
+
alg: z.enum(["RS256", "ES256"]),
|
|
654
|
+
use: z.literal("sig")
|
|
655
|
+
}).passthrough();
|
|
656
|
+
var jwksSchema = z.object({
|
|
657
|
+
keys: z.array(publicJwkSchema).min(1)
|
|
658
|
+
});
|
|
659
|
+
var devKeyDataSchema = z.object({
|
|
660
|
+
kid: z.string().min(1),
|
|
661
|
+
privateKey: z.union([rsaPrivateKeySchema, ecPrivateKeySchema]),
|
|
662
|
+
publicJwk: jwksSchema,
|
|
663
|
+
createdAt: z.number().positive().int(),
|
|
664
|
+
alg: z.enum(["RS256", "ES256"])
|
|
665
|
+
});
|
|
666
|
+
function validateJwkStructure(data) {
|
|
667
|
+
const result = devKeyDataSchema.safeParse(data);
|
|
668
|
+
if (!result.success) {
|
|
669
|
+
return { valid: false, error: result.error.issues[0]?.message ?? "Invalid JWK structure" };
|
|
670
|
+
}
|
|
671
|
+
const parsed = result.data;
|
|
672
|
+
if (parsed.alg === "RS256" && parsed.privateKey.kty !== "RSA") {
|
|
673
|
+
return { valid: false, error: "Algorithm RS256 requires RSA key type" };
|
|
674
|
+
}
|
|
675
|
+
if (parsed.alg === "ES256" && parsed.privateKey.kty !== "EC") {
|
|
676
|
+
return { valid: false, error: "Algorithm ES256 requires EC key type" };
|
|
677
|
+
}
|
|
678
|
+
const publicKey = parsed.publicJwk.keys[0];
|
|
679
|
+
if (publicKey.kty !== parsed.privateKey.kty) {
|
|
680
|
+
return { valid: false, error: "Public and private key types do not match" };
|
|
681
|
+
}
|
|
682
|
+
if (publicKey.kid !== parsed.kid) {
|
|
683
|
+
return { valid: false, error: "kid mismatch between top-level and publicJwk" };
|
|
684
|
+
}
|
|
685
|
+
const now = Date.now();
|
|
686
|
+
const hundredYearsMs = 100 * 365 * 24 * 60 * 60 * 1e3;
|
|
687
|
+
if (parsed.createdAt > now) {
|
|
688
|
+
return { valid: false, error: "createdAt is in the future" };
|
|
689
|
+
}
|
|
690
|
+
if (parsed.createdAt < now - hundredYearsMs) {
|
|
691
|
+
return { valid: false, error: "createdAt is too old" };
|
|
692
|
+
}
|
|
693
|
+
return { valid: true };
|
|
694
|
+
}
|
|
695
|
+
function isDevKeyPersistenceEnabled(options) {
|
|
696
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
697
|
+
if (isProduction) {
|
|
698
|
+
return options?.forceEnable === true;
|
|
699
|
+
}
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
function resolveKeyPath(options) {
|
|
703
|
+
const keyPath = options?.keyPath ?? DEFAULT_KEY_PATH;
|
|
704
|
+
if (path.isAbsolute(keyPath)) {
|
|
705
|
+
return keyPath;
|
|
706
|
+
}
|
|
707
|
+
return path.resolve(process.cwd(), keyPath);
|
|
708
|
+
}
|
|
709
|
+
async function loadDevKey(options) {
|
|
710
|
+
if (!isDevKeyPersistenceEnabled(options)) {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const keyPath = resolveKeyPath(options);
|
|
714
|
+
try {
|
|
715
|
+
const content = await readFile(keyPath, "utf8");
|
|
716
|
+
const data = JSON.parse(content);
|
|
717
|
+
const validation = validateJwkStructure(data);
|
|
718
|
+
if (!validation.valid) {
|
|
719
|
+
console.warn(`[DevKeyPersistence] Invalid key file format at ${keyPath}: ${validation.error}, will regenerate`);
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
console.log(`[DevKeyPersistence] Loaded key (kid=${data.kid}) from ${keyPath}`);
|
|
723
|
+
return data;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error.code === "ENOENT") {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
console.warn(`[DevKeyPersistence] Failed to load key from ${keyPath}: ${error.message}`);
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function saveDevKey(keyData, options) {
|
|
733
|
+
if (!isDevKeyPersistenceEnabled(options)) {
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
const keyPath = resolveKeyPath(options);
|
|
737
|
+
const dir = path.dirname(keyPath);
|
|
738
|
+
const tempPath = `${keyPath}.tmp.${Date.now()}.${crypto.randomBytes(8).toString("hex")}`;
|
|
739
|
+
try {
|
|
740
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
741
|
+
const content = JSON.stringify(keyData, null, 2);
|
|
742
|
+
await writeFile(tempPath, content, { mode: 384 });
|
|
743
|
+
await rename(tempPath, keyPath);
|
|
744
|
+
console.log(`[DevKeyPersistence] Saved key (kid=${keyData.kid}) to ${keyPath}`);
|
|
745
|
+
return true;
|
|
746
|
+
} catch (error) {
|
|
747
|
+
console.error(`[DevKeyPersistence] Failed to save key to ${keyPath}: ${error.message}`);
|
|
748
|
+
try {
|
|
749
|
+
await unlink(tempPath);
|
|
750
|
+
} catch {
|
|
751
|
+
}
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async function deleteDevKey(options) {
|
|
756
|
+
const keyPath = resolveKeyPath(options);
|
|
757
|
+
try {
|
|
758
|
+
await unlink(keyPath);
|
|
759
|
+
console.log(`[DevKeyPersistence] Deleted key at ${keyPath}`);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (error.code !== "ENOENT") {
|
|
762
|
+
console.warn(`[DevKeyPersistence] Failed to delete key at ${keyPath}: ${error.message}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// libs/auth/src/ui/base-layout.ts
|
|
768
|
+
import { escapeHtml } from "@frontmcp/utils";
|
|
769
|
+
import { escapeHtml as escapeHtml2 } from "@frontmcp/utils";
|
|
770
|
+
var CDN = {
|
|
771
|
+
/** Tailwind CSS v4 Browser CDN - generates styles on-the-fly with @theme support */
|
|
772
|
+
tailwind: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
|
|
773
|
+
/** Google Fonts - Inter for modern UI */
|
|
774
|
+
fonts: {
|
|
775
|
+
preconnect: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
|
776
|
+
stylesheet: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
var DEFAULT_THEME = {
|
|
780
|
+
colors: {
|
|
781
|
+
primary: "#3b82f6",
|
|
782
|
+
// blue-500
|
|
783
|
+
"primary-dark": "#2563eb",
|
|
784
|
+
// blue-600
|
|
785
|
+
secondary: "#8b5cf6",
|
|
786
|
+
// violet-500
|
|
787
|
+
accent: "#06b6d4",
|
|
788
|
+
// cyan-500
|
|
789
|
+
success: "#22c55e",
|
|
790
|
+
// green-500
|
|
791
|
+
warning: "#f59e0b",
|
|
792
|
+
// amber-500
|
|
793
|
+
danger: "#ef4444"
|
|
794
|
+
// red-500
|
|
795
|
+
},
|
|
796
|
+
fonts: {
|
|
797
|
+
sans: 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
function buildThemeCss(theme) {
|
|
801
|
+
const lines = [];
|
|
802
|
+
if (theme.colors) {
|
|
803
|
+
for (const [key, value] of Object.entries(theme.colors)) {
|
|
804
|
+
if (value) {
|
|
805
|
+
lines.push(`--color-${key}: ${value};`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (theme.fonts) {
|
|
810
|
+
for (const [key, value] of Object.entries(theme.fonts)) {
|
|
811
|
+
if (value) {
|
|
812
|
+
lines.push(`--font-${key}: ${value};`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (theme.customVars) {
|
|
817
|
+
lines.push(theme.customVars);
|
|
818
|
+
}
|
|
819
|
+
return lines.join("\n ");
|
|
820
|
+
}
|
|
821
|
+
function baseLayout(content, options) {
|
|
822
|
+
const {
|
|
823
|
+
title,
|
|
824
|
+
description,
|
|
825
|
+
includeTailwind = true,
|
|
826
|
+
includeFonts = true,
|
|
827
|
+
headExtra = "",
|
|
828
|
+
bodyClass = "bg-gray-50 min-h-screen font-sans antialiased",
|
|
829
|
+
theme
|
|
830
|
+
} = options;
|
|
831
|
+
const mergedTheme = {
|
|
832
|
+
colors: { ...DEFAULT_THEME.colors, ...theme?.colors },
|
|
833
|
+
fonts: { ...DEFAULT_THEME.fonts, ...theme?.fonts },
|
|
834
|
+
customVars: theme?.customVars,
|
|
835
|
+
customCss: theme?.customCss
|
|
836
|
+
};
|
|
837
|
+
const fontPreconnect = includeFonts ? CDN.fonts.preconnect.map((url, i) => `<link rel="preconnect" href="${url}"${i > 0 ? " crossorigin" : ""}>`).join("\n ") : "";
|
|
838
|
+
const fontStylesheet = includeFonts ? `<link href="${CDN.fonts.stylesheet}" rel="stylesheet">` : "";
|
|
839
|
+
const themeCss = buildThemeCss(mergedTheme);
|
|
840
|
+
const customCss = mergedTheme.customCss || "";
|
|
841
|
+
const tailwindBlock = includeTailwind ? `<script src="${CDN.tailwind}"></script>
|
|
842
|
+
<style type="text/tailwindcss">
|
|
843
|
+
@theme {
|
|
844
|
+
${themeCss}
|
|
845
|
+
}
|
|
846
|
+
${customCss}
|
|
847
|
+
</style>` : "";
|
|
848
|
+
const metaDescription = description ? `<meta name="description" content="${escapeHtml(description)}">` : "";
|
|
849
|
+
return `<!DOCTYPE html>
|
|
850
|
+
<html lang="en">
|
|
851
|
+
<head>
|
|
852
|
+
<meta charset="UTF-8">
|
|
853
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
854
|
+
<title>${escapeHtml(title)} - FrontMCP</title>
|
|
855
|
+
${metaDescription}
|
|
856
|
+
|
|
857
|
+
<!-- Google Fonts CDN - Inter (modern UI font) -->
|
|
858
|
+
${fontPreconnect}
|
|
859
|
+
${fontStylesheet}
|
|
860
|
+
|
|
861
|
+
<!-- Tailwind CSS v4 Browser CDN with @theme support -->
|
|
862
|
+
${tailwindBlock}
|
|
863
|
+
${headExtra}
|
|
864
|
+
</head>
|
|
865
|
+
<body class="${escapeHtml(bodyClass)}">
|
|
866
|
+
${content}
|
|
867
|
+
</body>
|
|
868
|
+
</html>`;
|
|
869
|
+
}
|
|
870
|
+
function createLayout(defaultOptions) {
|
|
871
|
+
return (content, options) => {
|
|
872
|
+
const mergedTheme = defaultOptions.theme || options.theme ? {
|
|
873
|
+
colors: { ...defaultOptions.theme?.colors, ...options.theme?.colors },
|
|
874
|
+
fonts: { ...defaultOptions.theme?.fonts, ...options.theme?.fonts },
|
|
875
|
+
customVars: options.theme?.customVars ?? defaultOptions.theme?.customVars,
|
|
876
|
+
customCss: options.theme?.customCss ?? defaultOptions.theme?.customCss
|
|
877
|
+
} : void 0;
|
|
878
|
+
return baseLayout(content, {
|
|
879
|
+
...defaultOptions,
|
|
880
|
+
...options,
|
|
881
|
+
theme: mergedTheme
|
|
882
|
+
});
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
var authLayout = createLayout({
|
|
886
|
+
bodyClass: "bg-gray-50 min-h-screen font-sans antialiased"
|
|
887
|
+
});
|
|
888
|
+
function centeredCardLayout(content, options) {
|
|
889
|
+
const wrappedContent = `
|
|
890
|
+
<div class="min-h-screen flex items-center justify-center p-4">
|
|
891
|
+
<div class="w-full max-w-md">
|
|
892
|
+
${content}
|
|
893
|
+
</div>
|
|
894
|
+
</div>`;
|
|
895
|
+
return baseLayout(wrappedContent, {
|
|
896
|
+
...options,
|
|
897
|
+
bodyClass: "bg-gradient-to-br from-primary to-secondary min-h-screen font-sans antialiased"
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
function wideLayout(content, options) {
|
|
901
|
+
const wrappedContent = `
|
|
902
|
+
<div class="min-h-screen py-8 px-4">
|
|
903
|
+
<div class="max-w-2xl mx-auto">
|
|
904
|
+
${content}
|
|
905
|
+
</div>
|
|
906
|
+
</div>`;
|
|
907
|
+
return baseLayout(wrappedContent, options);
|
|
908
|
+
}
|
|
909
|
+
function extraWideLayout(content, options) {
|
|
910
|
+
const wrappedContent = `
|
|
911
|
+
<div class="min-h-screen py-8 px-4">
|
|
912
|
+
<div class="max-w-3xl mx-auto">
|
|
913
|
+
${content}
|
|
914
|
+
</div>
|
|
915
|
+
</div>`;
|
|
916
|
+
return baseLayout(wrappedContent, options);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// libs/auth/src/ui/templates.ts
|
|
920
|
+
var escapeHtml3 = escapeHtml2;
|
|
921
|
+
function buildConsentPage(params) {
|
|
922
|
+
const { apps, clientName, pendingAuthId, csrfToken, callbackPath } = params;
|
|
923
|
+
const appCards = apps.map((app) => buildAppCardHtml(app, pendingAuthId, csrfToken, callbackPath)).join("\n");
|
|
924
|
+
const content = `
|
|
925
|
+
<h1 class="text-3xl font-bold text-gray-900 mb-4">Authorize ${escapeHtml3(clientName)}</h1>
|
|
926
|
+
<p class="text-gray-600 mb-8">
|
|
927
|
+
Select which apps you want to authorize. You can skip apps and authorize them later when needed.
|
|
928
|
+
</p>
|
|
929
|
+
|
|
930
|
+
<div class="space-y-4" id="app-list">
|
|
931
|
+
${appCards}
|
|
932
|
+
</div>
|
|
933
|
+
|
|
934
|
+
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
|
935
|
+
Skipped apps can be authorized later when you try to use their tools (progressive authorization).
|
|
936
|
+
</div>`;
|
|
937
|
+
return wideLayout(content, { title: `Authorize ${clientName}` });
|
|
938
|
+
}
|
|
939
|
+
function buildAppCardHtml(app, pendingAuthId, csrfToken, callbackPath) {
|
|
940
|
+
const icon = app.iconUrl ? `<img src="${escapeHtml3(app.iconUrl)}" alt="${escapeHtml3(
|
|
941
|
+
app.appName
|
|
942
|
+
)}" class="w-12 h-12 rounded-lg object-cover">` : `<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg">${escapeHtml3(
|
|
943
|
+
app.appName.charAt(0).toUpperCase()
|
|
944
|
+
)}</div>`;
|
|
945
|
+
const description = app.description ? `<p class="text-sm text-gray-500">${escapeHtml3(app.description)}</p>` : "";
|
|
946
|
+
const scopes = app.requiredScopes?.length ? `<div class="mb-4">
|
|
947
|
+
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Permissions</p>
|
|
948
|
+
<div class="flex flex-wrap gap-2">
|
|
949
|
+
${app.requiredScopes.map(
|
|
950
|
+
(scope) => `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">${escapeHtml3(
|
|
951
|
+
scope
|
|
952
|
+
)}</span>`
|
|
953
|
+
).join("")}
|
|
954
|
+
</div>
|
|
955
|
+
</div>` : "";
|
|
956
|
+
return `<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow" data-app-id="${escapeHtml3(
|
|
957
|
+
app.appId
|
|
958
|
+
)}">
|
|
959
|
+
<div class="flex items-center gap-4 mb-4">
|
|
960
|
+
${icon}
|
|
961
|
+
<div class="flex-1">
|
|
962
|
+
<h3 class="font-semibold text-gray-900">${escapeHtml3(app.appName)}</h3>
|
|
963
|
+
${description}
|
|
964
|
+
</div>
|
|
965
|
+
</div>
|
|
966
|
+
|
|
967
|
+
${scopes}
|
|
968
|
+
|
|
969
|
+
<form method="POST" action="${escapeHtml3(callbackPath)}" class="flex gap-3 pt-4 border-t border-gray-100">
|
|
970
|
+
<input type="hidden" name="csrf" value="${escapeHtml3(csrfToken)}">
|
|
971
|
+
<input type="hidden" name="pending_auth_id" value="${escapeHtml3(pendingAuthId)}">
|
|
972
|
+
<input type="hidden" name="app" value="${escapeHtml3(app.appId)}">
|
|
973
|
+
<button type="submit" name="action" value="authorize"
|
|
974
|
+
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
|
|
975
|
+
Authorize
|
|
976
|
+
</button>
|
|
977
|
+
<button type="submit" name="action" value="skip"
|
|
978
|
+
class="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 font-medium rounded-lg transition-colors">
|
|
979
|
+
Skip
|
|
980
|
+
</button>
|
|
981
|
+
</form>
|
|
982
|
+
</div>`;
|
|
983
|
+
}
|
|
984
|
+
function buildIncrementalAuthPage(params) {
|
|
985
|
+
const { app, toolId, sessionHint, callbackPath } = params;
|
|
986
|
+
const description = app.description ? `<p class="text-gray-500 mt-2">${escapeHtml3(app.description)}</p>` : "";
|
|
987
|
+
const content = `
|
|
988
|
+
<!-- Warning icon -->
|
|
989
|
+
<div class="flex justify-center mb-6">
|
|
990
|
+
<div class="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center">
|
|
991
|
+
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
992
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
993
|
+
</svg>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
|
|
997
|
+
<h1 class="text-2xl font-bold text-gray-900 text-center mb-2">Authorization Required</h1>
|
|
998
|
+
<p class="text-gray-600 text-center mb-8">
|
|
999
|
+
To use "<span class="font-mono text-sm bg-gray-100 px-1 rounded">${escapeHtml3(
|
|
1000
|
+
toolId
|
|
1001
|
+
)}</span>", you need to authorize ${escapeHtml3(app.appName)}.
|
|
1002
|
+
</p>
|
|
1003
|
+
|
|
1004
|
+
<!-- App card -->
|
|
1005
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
|
1006
|
+
<div class="flex items-center gap-4 mb-4">
|
|
1007
|
+
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
|
|
1008
|
+
${escapeHtml3(app.appName.charAt(0).toUpperCase())}
|
|
1009
|
+
</div>
|
|
1010
|
+
<div class="flex-1">
|
|
1011
|
+
<h3 class="font-semibold text-gray-900">${escapeHtml3(app.appName)}</h3>
|
|
1012
|
+
${description}
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
|
|
1016
|
+
<form method="GET" action="${escapeHtml3(callbackPath)}" class="flex gap-3 pt-4 border-t border-gray-100">
|
|
1017
|
+
<input type="hidden" name="pending_auth_id" value="${escapeHtml3(sessionHint)}">
|
|
1018
|
+
<input type="hidden" name="app_id" value="${escapeHtml3(app.appId)}">
|
|
1019
|
+
<input type="hidden" name="incremental" value="true">
|
|
1020
|
+
<button type="button" onclick="window.close()"
|
|
1021
|
+
class="flex-1 px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 font-medium rounded-lg transition-colors">
|
|
1022
|
+
Cancel
|
|
1023
|
+
</button>
|
|
1024
|
+
<button type="submit"
|
|
1025
|
+
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
|
|
1026
|
+
Authorize
|
|
1027
|
+
</button>
|
|
1028
|
+
</form>
|
|
1029
|
+
</div>
|
|
1030
|
+
|
|
1031
|
+
<div class="p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 text-center">
|
|
1032
|
+
This is an incremental authorization. Your existing session will be expanded to include ${escapeHtml3(
|
|
1033
|
+
app.appName
|
|
1034
|
+
)}.
|
|
1035
|
+
</div>`;
|
|
1036
|
+
return centeredCardLayout(content, { title: `Authorize ${app.appName}` });
|
|
1037
|
+
}
|
|
1038
|
+
function buildFederatedLoginPage(params) {
|
|
1039
|
+
const { providers, clientName, pendingAuthId, callbackPath } = params;
|
|
1040
|
+
const providerCards = providers.map((provider) => {
|
|
1041
|
+
const isPrimaryBadge = provider.isPrimary ? `<span class="px-2 py-0.5 text-xs font-medium bg-blue-600 text-white rounded-full">Primary</span>` : "";
|
|
1042
|
+
const providerUrl = provider.providerUrl ? `<p class="text-xs text-gray-400 font-mono truncate">${escapeHtml3(provider.providerUrl)}</p>` : "";
|
|
1043
|
+
const appIds = provider.appIds.length > 0 ? `<p class="text-xs text-gray-500 mt-1">Apps: ${provider.appIds.map((id) => escapeHtml3(id)).join(", ")}</p>` : "";
|
|
1044
|
+
return `<label class="flex items-start gap-4 p-4 bg-white border-2 border-gray-200 rounded-xl cursor-pointer hover:border-blue-300 transition-colors has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
|
|
1045
|
+
<input type="checkbox" name="providers" value="${escapeHtml3(provider.providerId)}"
|
|
1046
|
+
class="mt-1 w-5 h-5 rounded border-gray-300" ${provider.isPrimary ? "checked" : ""}>
|
|
1047
|
+
<div class="flex-1">
|
|
1048
|
+
<div class="flex items-center gap-2 mb-1">
|
|
1049
|
+
<span class="font-semibold text-gray-900">${escapeHtml3(provider.providerName)}</span>
|
|
1050
|
+
${isPrimaryBadge}
|
|
1051
|
+
</div>
|
|
1052
|
+
<p class="text-sm text-gray-500">Mode: ${escapeHtml3(provider.mode)}</p>
|
|
1053
|
+
${providerUrl}
|
|
1054
|
+
${appIds}
|
|
1055
|
+
</div>
|
|
1056
|
+
</label>`;
|
|
1057
|
+
}).join("\n");
|
|
1058
|
+
const content = `
|
|
1059
|
+
<h1 class="text-3xl font-bold text-gray-900 mb-4">Select Authorization Providers</h1>
|
|
1060
|
+
<p class="text-gray-600 mb-8">
|
|
1061
|
+
${escapeHtml3(clientName)} uses multiple authentication providers. Select which ones you want to authorize.
|
|
1062
|
+
</p>
|
|
1063
|
+
|
|
1064
|
+
<form method="GET" action="${escapeHtml3(callbackPath)}" id="federated-form">
|
|
1065
|
+
<input type="hidden" name="pending_auth_id" value="${escapeHtml3(pendingAuthId)}">
|
|
1066
|
+
<input type="hidden" name="federated" value="true">
|
|
1067
|
+
|
|
1068
|
+
<!-- Select all toggle -->
|
|
1069
|
+
<label class="flex items-center gap-3 mb-6 cursor-pointer">
|
|
1070
|
+
<input type="checkbox" id="select-all" class="w-5 h-5 rounded border-gray-300"
|
|
1071
|
+
onchange="document.querySelectorAll('input[name=providers]').forEach(cb => cb.checked = this.checked)">
|
|
1072
|
+
<span class="text-gray-700">Select all providers</span>
|
|
1073
|
+
</label>
|
|
1074
|
+
|
|
1075
|
+
<!-- Provider cards -->
|
|
1076
|
+
<div class="space-y-4 mb-8">
|
|
1077
|
+
${providerCards}
|
|
1078
|
+
</div>
|
|
1079
|
+
|
|
1080
|
+
<!-- Email input -->
|
|
1081
|
+
<div class="mb-6">
|
|
1082
|
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
|
1083
|
+
<input type="email" id="email" name="email" required placeholder="you@example.com"
|
|
1084
|
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
1085
|
+
</div>
|
|
1086
|
+
|
|
1087
|
+
<!-- Buttons -->
|
|
1088
|
+
<div class="flex gap-4">
|
|
1089
|
+
<button type="button"
|
|
1090
|
+
class="flex-1 px-6 py-3 text-gray-700 bg-gray-100 hover:bg-gray-200 font-medium rounded-lg transition-colors"
|
|
1091
|
+
onclick="document.querySelectorAll('input[name=providers]').forEach(cb => cb.checked = false); document.getElementById('federated-form').submit();">
|
|
1092
|
+
Skip All
|
|
1093
|
+
</button>
|
|
1094
|
+
<button type="submit"
|
|
1095
|
+
class="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
|
|
1096
|
+
Continue
|
|
1097
|
+
</button>
|
|
1098
|
+
</div>
|
|
1099
|
+
</form>
|
|
1100
|
+
|
|
1101
|
+
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
|
1102
|
+
Skipped providers can be authorized later when you try to use their tools (progressive authorization).
|
|
1103
|
+
</div>`;
|
|
1104
|
+
return wideLayout(content, { title: "Select Providers" });
|
|
1105
|
+
}
|
|
1106
|
+
function buildToolConsentPage(params) {
|
|
1107
|
+
const { tools, clientName, pendingAuthId, csrfToken, callbackPath, userName, userEmail } = params;
|
|
1108
|
+
const toolsByApp = {};
|
|
1109
|
+
for (const tool of tools) {
|
|
1110
|
+
if (!toolsByApp[tool.appId]) {
|
|
1111
|
+
toolsByApp[tool.appId] = { appName: tool.appName, tools: [] };
|
|
1112
|
+
}
|
|
1113
|
+
toolsByApp[tool.appId].tools.push(tool);
|
|
1114
|
+
}
|
|
1115
|
+
const userInfo = userName || userEmail ? `<div class="p-3 bg-gray-50 rounded-lg mb-6 text-sm">
|
|
1116
|
+
<span class="text-gray-500">Signed in as: </span>
|
|
1117
|
+
<span class="font-medium text-gray-900">${escapeHtml3(userName || userEmail || "")}</span>
|
|
1118
|
+
</div>` : "";
|
|
1119
|
+
const appGroups = Object.entries(toolsByApp).map(([appId, { appName, tools: appTools }]) => {
|
|
1120
|
+
const toolItems = appTools.map((tool) => {
|
|
1121
|
+
const desc = tool.description ? `<p class="text-sm text-gray-500 mt-0.5">${escapeHtml3(tool.description)}</p>` : "";
|
|
1122
|
+
return `<label class="flex items-start gap-3 p-3 bg-white rounded-lg cursor-pointer hover:bg-gray-50">
|
|
1123
|
+
<input type="checkbox" name="tools" value="${escapeHtml3(
|
|
1124
|
+
tool.toolId
|
|
1125
|
+
)}" class="mt-0.5 w-5 h-5 rounded border-gray-300" checked>
|
|
1126
|
+
<div>
|
|
1127
|
+
<span class="font-medium text-gray-900">${escapeHtml3(tool.toolName)}</span>
|
|
1128
|
+
${desc}
|
|
1129
|
+
</div>
|
|
1130
|
+
</label>`;
|
|
1131
|
+
}).join("\n");
|
|
1132
|
+
return `<div class="bg-gray-50 rounded-xl overflow-hidden">
|
|
1133
|
+
<div class="flex items-center justify-between px-4 py-3 bg-gray-100">
|
|
1134
|
+
<div class="flex items-center gap-3">
|
|
1135
|
+
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
|
1136
|
+
${escapeHtml3(appName.charAt(0).toUpperCase())}
|
|
1137
|
+
</div>
|
|
1138
|
+
<span class="font-semibold text-gray-900">${escapeHtml3(appName)}</span>
|
|
1139
|
+
</div>
|
|
1140
|
+
<button type="button" class="text-sm text-blue-600 hover:text-blue-800"
|
|
1141
|
+
onclick="const container = this.closest('.bg-gray-50').querySelector('[data-app]'); const cbs = container.querySelectorAll('input[name=tools]'); const allChecked = [...cbs].every(cb => cb.checked); cbs.forEach(cb => cb.checked = !allChecked); updateCount();">
|
|
1142
|
+
Toggle All
|
|
1143
|
+
</button>
|
|
1144
|
+
</div>
|
|
1145
|
+
<div class="p-4 space-y-2" data-app="${escapeHtml3(appId)}">
|
|
1146
|
+
${toolItems}
|
|
1147
|
+
</div>
|
|
1148
|
+
</div>`;
|
|
1149
|
+
}).join("\n");
|
|
1150
|
+
const updateCountScript = `
|
|
1151
|
+
<script>
|
|
1152
|
+
function updateCount() {
|
|
1153
|
+
const all = document.querySelectorAll('input[name="tools"]');
|
|
1154
|
+
const checked = document.querySelectorAll('input[name="tools"]:checked');
|
|
1155
|
+
document.getElementById('selection-count').textContent = checked.length + ' of ' + all.length + ' selected';
|
|
1156
|
+
document.getElementById('select-all').checked = all.length > 0 && all.length === checked.length;
|
|
1157
|
+
}
|
|
1158
|
+
document.querySelectorAll('input[name="tools"]').forEach(cb => cb.addEventListener('change', updateCount));
|
|
1159
|
+
</script>`;
|
|
1160
|
+
const content = `
|
|
1161
|
+
<h1 class="text-3xl font-bold text-gray-900 mb-4">Select Tools to Enable</h1>
|
|
1162
|
+
<p class="text-gray-600 mb-6">
|
|
1163
|
+
Choose which tools ${escapeHtml3(clientName)} can access. You can change this later.
|
|
1164
|
+
</p>
|
|
1165
|
+
|
|
1166
|
+
${userInfo}
|
|
1167
|
+
|
|
1168
|
+
<form method="POST" action="${escapeHtml3(callbackPath)}" id="consent-form">
|
|
1169
|
+
<input type="hidden" name="csrf" value="${escapeHtml3(csrfToken)}">
|
|
1170
|
+
<input type="hidden" name="pending_auth_id" value="${escapeHtml3(pendingAuthId)}">
|
|
1171
|
+
|
|
1172
|
+
<!-- Select all toggle -->
|
|
1173
|
+
<div class="flex items-center justify-between mb-6">
|
|
1174
|
+
<label class="flex items-center gap-3 cursor-pointer">
|
|
1175
|
+
<input type="checkbox" id="select-all" class="w-5 h-5 rounded border-gray-300" checked
|
|
1176
|
+
onchange="document.querySelectorAll('input[name=tools]').forEach(cb => cb.checked = this.checked); updateCount();">
|
|
1177
|
+
<span class="text-gray-700">Select all tools</span>
|
|
1178
|
+
</label>
|
|
1179
|
+
<span id="selection-count" class="text-sm text-gray-500">${tools.length} of ${tools.length} selected</span>
|
|
1180
|
+
</div>
|
|
1181
|
+
|
|
1182
|
+
<!-- Tool groups by app -->
|
|
1183
|
+
<div class="space-y-6 mb-8">
|
|
1184
|
+
${appGroups}
|
|
1185
|
+
</div>
|
|
1186
|
+
|
|
1187
|
+
<!-- Buttons -->
|
|
1188
|
+
<div class="flex gap-4">
|
|
1189
|
+
<button type="button" onclick="history.back()"
|
|
1190
|
+
class="flex-1 px-6 py-3 text-gray-700 bg-gray-100 hover:bg-gray-200 font-medium rounded-lg transition-colors">
|
|
1191
|
+
Cancel
|
|
1192
|
+
</button>
|
|
1193
|
+
<button type="submit"
|
|
1194
|
+
class="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
|
|
1195
|
+
Confirm Selection
|
|
1196
|
+
</button>
|
|
1197
|
+
</div>
|
|
1198
|
+
</form>
|
|
1199
|
+
${updateCountScript}`;
|
|
1200
|
+
return extraWideLayout(content, { title: "Select Tools" });
|
|
1201
|
+
}
|
|
1202
|
+
function buildLoginPage(params) {
|
|
1203
|
+
const { clientName, scope, pendingAuthId, callbackPath } = params;
|
|
1204
|
+
const scopesHtml = scope ? `<div class="p-4 bg-gray-50 rounded-lg mb-6">
|
|
1205
|
+
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Requested permissions</p>
|
|
1206
|
+
<p class="text-gray-700">${scope.split(" ").map((s) => escapeHtml3(s)).join(", ") || "Default access"}</p>
|
|
1207
|
+
</div>` : "";
|
|
1208
|
+
const content = `
|
|
1209
|
+
<div class="bg-white rounded-2xl shadow-xl p-8">
|
|
1210
|
+
<h1 class="text-3xl font-bold text-gray-900 mb-2 text-center">Sign In</h1>
|
|
1211
|
+
<p class="text-gray-600 mb-8 text-center">Authorize access to ${escapeHtml3(clientName)}</p>
|
|
1212
|
+
|
|
1213
|
+
${scopesHtml}
|
|
1214
|
+
|
|
1215
|
+
<form method="GET" action="${escapeHtml3(callbackPath)}">
|
|
1216
|
+
<input type="hidden" name="pending_auth_id" value="${escapeHtml3(pendingAuthId)}">
|
|
1217
|
+
|
|
1218
|
+
<!-- Email -->
|
|
1219
|
+
<div class="mb-4">
|
|
1220
|
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
|
1221
|
+
<input type="email" id="email" name="email" required placeholder="you@example.com"
|
|
1222
|
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
1223
|
+
</div>
|
|
1224
|
+
|
|
1225
|
+
<!-- Name (optional) -->
|
|
1226
|
+
<div class="mb-6">
|
|
1227
|
+
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">Name (optional)</label>
|
|
1228
|
+
<input type="text" id="name" name="name" placeholder="Your name"
|
|
1229
|
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
<!-- Submit -->
|
|
1233
|
+
<button type="submit"
|
|
1234
|
+
class="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
|
|
1235
|
+
Authorize
|
|
1236
|
+
</button>
|
|
1237
|
+
</form>
|
|
1238
|
+
|
|
1239
|
+
<p class="text-center text-sm text-gray-500 mt-6">Client: ${escapeHtml3(clientName)}</p>
|
|
1240
|
+
</div>`;
|
|
1241
|
+
return centeredCardLayout(content, { title: "Sign In" });
|
|
1242
|
+
}
|
|
1243
|
+
function buildErrorPage(params) {
|
|
1244
|
+
const { error, description } = params;
|
|
1245
|
+
const content = `
|
|
1246
|
+
<div class="bg-white rounded-2xl shadow-xl p-8 text-center">
|
|
1247
|
+
<!-- Error icon -->
|
|
1248
|
+
<div class="flex justify-center mb-6">
|
|
1249
|
+
<div class="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center">
|
|
1250
|
+
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1251
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
1252
|
+
</svg>
|
|
1253
|
+
</div>
|
|
1254
|
+
</div>
|
|
1255
|
+
|
|
1256
|
+
<h1 class="text-2xl font-bold text-gray-900 mb-4">Authorization Error</h1>
|
|
1257
|
+
<p class="mb-4">
|
|
1258
|
+
<code class="px-2 py-1 bg-gray-100 rounded text-red-600 font-mono text-sm">${escapeHtml3(error)}</code>
|
|
1259
|
+
</p>
|
|
1260
|
+
<p class="text-gray-600">${escapeHtml3(description)}</p>
|
|
1261
|
+
</div>`;
|
|
1262
|
+
return centeredCardLayout(content, { title: "Error" });
|
|
1263
|
+
}
|
|
1264
|
+
function renderToHtml(html, _options) {
|
|
1265
|
+
return html;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// libs/auth/src/session/authorization.store.ts
|
|
1269
|
+
import { randomUUID, sha256Base64url } from "@frontmcp/utils";
|
|
1270
|
+
import { z as z2 } from "zod";
|
|
1271
|
+
var pkceChallengeSchema = z2.object({
|
|
1272
|
+
challenge: z2.string().min(43).max(128),
|
|
1273
|
+
method: z2.literal("S256")
|
|
1274
|
+
});
|
|
1275
|
+
var authorizationCodeRecordSchema = z2.object({
|
|
1276
|
+
code: z2.string().min(1),
|
|
1277
|
+
clientId: z2.string().min(1),
|
|
1278
|
+
redirectUri: z2.string().url(),
|
|
1279
|
+
scopes: z2.array(z2.string()),
|
|
1280
|
+
pkce: pkceChallengeSchema,
|
|
1281
|
+
userSub: z2.string().min(1),
|
|
1282
|
+
userEmail: z2.string().email().optional(),
|
|
1283
|
+
userName: z2.string().optional(),
|
|
1284
|
+
state: z2.string().optional(),
|
|
1285
|
+
createdAt: z2.number(),
|
|
1286
|
+
expiresAt: z2.number(),
|
|
1287
|
+
used: z2.boolean(),
|
|
1288
|
+
resource: z2.string().url().optional(),
|
|
1289
|
+
// Consent and federated login fields
|
|
1290
|
+
selectedToolIds: z2.array(z2.string()).optional(),
|
|
1291
|
+
selectedProviderIds: z2.array(z2.string()).optional(),
|
|
1292
|
+
skippedProviderIds: z2.array(z2.string()).optional(),
|
|
1293
|
+
consentEnabled: z2.boolean().optional(),
|
|
1294
|
+
federatedLoginUsed: z2.boolean().optional(),
|
|
1295
|
+
pendingAuthId: z2.string().optional()
|
|
1296
|
+
});
|
|
1297
|
+
function verifyPkce(codeVerifier, challenge) {
|
|
1298
|
+
if (challenge.method !== "S256") {
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
const hash = sha256Base64url(codeVerifier);
|
|
1302
|
+
return hash === challenge.challenge;
|
|
1303
|
+
}
|
|
1304
|
+
function generatePkceChallenge(codeVerifier) {
|
|
1305
|
+
const challenge = sha256Base64url(codeVerifier);
|
|
1306
|
+
return { challenge, method: "S256" };
|
|
1307
|
+
}
|
|
1308
|
+
var InMemoryAuthorizationStore = class {
|
|
1309
|
+
codes = /* @__PURE__ */ new Map();
|
|
1310
|
+
pending = /* @__PURE__ */ new Map();
|
|
1311
|
+
refreshTokens = /* @__PURE__ */ new Map();
|
|
1312
|
+
/** Default TTL for authorization codes (60 seconds) */
|
|
1313
|
+
codeTtlMs = 60 * 1e3;
|
|
1314
|
+
/** Default TTL for pending authorizations (10 minutes) */
|
|
1315
|
+
pendingTtlMs = 10 * 60 * 1e3;
|
|
1316
|
+
/** Default TTL for refresh tokens (30 days) */
|
|
1317
|
+
refreshTtlMs = 30 * 24 * 60 * 60 * 1e3;
|
|
1318
|
+
generateCode() {
|
|
1319
|
+
return randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, "");
|
|
1320
|
+
}
|
|
1321
|
+
generateRefreshToken() {
|
|
1322
|
+
return randomUUID() + "-" + randomUUID();
|
|
1323
|
+
}
|
|
1324
|
+
async storeAuthorizationCode(record) {
|
|
1325
|
+
this.codes.set(record.code, record);
|
|
1326
|
+
}
|
|
1327
|
+
async getAuthorizationCode(code) {
|
|
1328
|
+
const record = this.codes.get(code);
|
|
1329
|
+
if (!record) return null;
|
|
1330
|
+
if (Date.now() > record.expiresAt) {
|
|
1331
|
+
this.codes.delete(code);
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
return record;
|
|
1335
|
+
}
|
|
1336
|
+
async markCodeUsed(code) {
|
|
1337
|
+
const record = this.codes.get(code);
|
|
1338
|
+
if (record) {
|
|
1339
|
+
record.used = true;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async deleteAuthorizationCode(code) {
|
|
1343
|
+
this.codes.delete(code);
|
|
1344
|
+
}
|
|
1345
|
+
async storePendingAuthorization(record) {
|
|
1346
|
+
this.pending.set(record.id, record);
|
|
1347
|
+
}
|
|
1348
|
+
async getPendingAuthorization(id) {
|
|
1349
|
+
const record = this.pending.get(id);
|
|
1350
|
+
if (!record) return null;
|
|
1351
|
+
if (Date.now() > record.expiresAt) {
|
|
1352
|
+
this.pending.delete(id);
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
return record;
|
|
1356
|
+
}
|
|
1357
|
+
async deletePendingAuthorization(id) {
|
|
1358
|
+
this.pending.delete(id);
|
|
1359
|
+
}
|
|
1360
|
+
async storeRefreshToken(record) {
|
|
1361
|
+
this.refreshTokens.set(record.token, record);
|
|
1362
|
+
}
|
|
1363
|
+
async getRefreshToken(token) {
|
|
1364
|
+
const record = this.refreshTokens.get(token);
|
|
1365
|
+
if (!record) return null;
|
|
1366
|
+
if (Date.now() > record.expiresAt || record.revoked) {
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
return record;
|
|
1370
|
+
}
|
|
1371
|
+
async revokeRefreshToken(token) {
|
|
1372
|
+
const record = this.refreshTokens.get(token);
|
|
1373
|
+
if (record) {
|
|
1374
|
+
record.revoked = true;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async rotateRefreshToken(oldToken, newRecord) {
|
|
1378
|
+
await this.revokeRefreshToken(oldToken);
|
|
1379
|
+
newRecord.previousToken = oldToken;
|
|
1380
|
+
await this.storeRefreshToken(newRecord);
|
|
1381
|
+
}
|
|
1382
|
+
async cleanup() {
|
|
1383
|
+
const now = Date.now();
|
|
1384
|
+
for (const [code, record] of this.codes) {
|
|
1385
|
+
if (now > record.expiresAt) {
|
|
1386
|
+
this.codes.delete(code);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
for (const [id, record] of this.pending) {
|
|
1390
|
+
if (now > record.expiresAt) {
|
|
1391
|
+
this.pending.delete(id);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
for (const [token, record] of this.refreshTokens) {
|
|
1395
|
+
if (now > record.expiresAt || record.revoked) {
|
|
1396
|
+
this.refreshTokens.delete(token);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Create an authorization code record with defaults
|
|
1402
|
+
*/
|
|
1403
|
+
createCodeRecord(params) {
|
|
1404
|
+
const now = Date.now();
|
|
1405
|
+
return {
|
|
1406
|
+
code: this.generateCode(),
|
|
1407
|
+
clientId: params.clientId,
|
|
1408
|
+
redirectUri: params.redirectUri,
|
|
1409
|
+
scopes: params.scopes,
|
|
1410
|
+
pkce: params.pkce,
|
|
1411
|
+
userSub: params.userSub,
|
|
1412
|
+
userEmail: params.userEmail,
|
|
1413
|
+
userName: params.userName,
|
|
1414
|
+
state: params.state,
|
|
1415
|
+
resource: params.resource,
|
|
1416
|
+
createdAt: now,
|
|
1417
|
+
expiresAt: now + this.codeTtlMs,
|
|
1418
|
+
used: false,
|
|
1419
|
+
// Consent and Federated Login Data
|
|
1420
|
+
selectedToolIds: params.selectedToolIds,
|
|
1421
|
+
selectedProviderIds: params.selectedProviderIds,
|
|
1422
|
+
skippedProviderIds: params.skippedProviderIds,
|
|
1423
|
+
consentEnabled: params.consentEnabled,
|
|
1424
|
+
federatedLoginUsed: params.federatedLoginUsed,
|
|
1425
|
+
// Token migration ID (for federated auth)
|
|
1426
|
+
pendingAuthId: params.pendingAuthId
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Create a pending authorization record with defaults
|
|
1431
|
+
*/
|
|
1432
|
+
createPendingRecord(params) {
|
|
1433
|
+
const now = Date.now();
|
|
1434
|
+
return {
|
|
1435
|
+
id: randomUUID(),
|
|
1436
|
+
clientId: params.clientId,
|
|
1437
|
+
redirectUri: params.redirectUri,
|
|
1438
|
+
scopes: params.scopes,
|
|
1439
|
+
pkce: params.pkce,
|
|
1440
|
+
state: params.state,
|
|
1441
|
+
resource: params.resource,
|
|
1442
|
+
createdAt: now,
|
|
1443
|
+
expiresAt: now + this.pendingTtlMs,
|
|
1444
|
+
// Progressive/Incremental Authorization Fields
|
|
1445
|
+
isIncremental: params.isIncremental,
|
|
1446
|
+
targetAppId: params.targetAppId,
|
|
1447
|
+
targetToolId: params.targetToolId,
|
|
1448
|
+
existingSessionId: params.existingSessionId,
|
|
1449
|
+
existingAuthorizationId: params.existingAuthorizationId,
|
|
1450
|
+
// Federated Login State
|
|
1451
|
+
federatedLogin: params.federatedLogin,
|
|
1452
|
+
// Consent State
|
|
1453
|
+
consent: params.consent
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Create a refresh token record with defaults
|
|
1458
|
+
*/
|
|
1459
|
+
createRefreshTokenRecord(params) {
|
|
1460
|
+
const now = Date.now();
|
|
1461
|
+
return {
|
|
1462
|
+
token: this.generateRefreshToken(),
|
|
1463
|
+
clientId: params.clientId,
|
|
1464
|
+
userSub: params.userSub,
|
|
1465
|
+
scopes: params.scopes,
|
|
1466
|
+
resource: params.resource,
|
|
1467
|
+
createdAt: now,
|
|
1468
|
+
expiresAt: now + this.refreshTtlMs,
|
|
1469
|
+
revoked: false
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
var RedisAuthorizationStore = class {
|
|
1474
|
+
constructor(redis, namespace = "oauth:") {
|
|
1475
|
+
this.redis = redis;
|
|
1476
|
+
this.namespace = namespace;
|
|
1477
|
+
}
|
|
1478
|
+
key(type, id) {
|
|
1479
|
+
return `${this.namespace}${type}:${id}`;
|
|
1480
|
+
}
|
|
1481
|
+
generateCode() {
|
|
1482
|
+
return randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, "");
|
|
1483
|
+
}
|
|
1484
|
+
generateRefreshToken() {
|
|
1485
|
+
return randomUUID() + "-" + randomUUID();
|
|
1486
|
+
}
|
|
1487
|
+
async storeAuthorizationCode(record) {
|
|
1488
|
+
const ttl = Math.max(Math.ceil((record.expiresAt - Date.now()) / 1e3), 1);
|
|
1489
|
+
await this.redis.set(this.key("code", record.code), JSON.stringify(record), "EX", Math.max(ttl, 1));
|
|
1490
|
+
}
|
|
1491
|
+
async getAuthorizationCode(code) {
|
|
1492
|
+
const data = await this.redis.get(this.key("code", code));
|
|
1493
|
+
if (!data) return null;
|
|
1494
|
+
return JSON.parse(data);
|
|
1495
|
+
}
|
|
1496
|
+
async markCodeUsed(code) {
|
|
1497
|
+
const record = await this.getAuthorizationCode(code);
|
|
1498
|
+
if (record) {
|
|
1499
|
+
record.used = true;
|
|
1500
|
+
const ttl = Math.ceil((record.expiresAt - Date.now()) / 1e3);
|
|
1501
|
+
await this.redis.set(this.key("code", code), JSON.stringify(record), "EX", Math.max(ttl, 1));
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
async deleteAuthorizationCode(code) {
|
|
1505
|
+
await this.redis.del(this.key("code", code));
|
|
1506
|
+
}
|
|
1507
|
+
async storePendingAuthorization(record) {
|
|
1508
|
+
const ttl = Math.max(Math.ceil((record.expiresAt - Date.now()) / 1e3), 1);
|
|
1509
|
+
await this.redis.set(this.key("pending", record.id), JSON.stringify(record), "EX", ttl);
|
|
1510
|
+
}
|
|
1511
|
+
async getPendingAuthorization(id) {
|
|
1512
|
+
const data = await this.redis.get(this.key("pending", id));
|
|
1513
|
+
if (!data) return null;
|
|
1514
|
+
return JSON.parse(data);
|
|
1515
|
+
}
|
|
1516
|
+
async deletePendingAuthorization(id) {
|
|
1517
|
+
await this.redis.del(this.key("pending", id));
|
|
1518
|
+
}
|
|
1519
|
+
async storeRefreshToken(record) {
|
|
1520
|
+
const ttl = Math.ceil((record.expiresAt - Date.now()) / 1e3);
|
|
1521
|
+
await this.redis.set(this.key("refresh", record.token), JSON.stringify(record), "EX", ttl);
|
|
1522
|
+
}
|
|
1523
|
+
async getRefreshToken(token) {
|
|
1524
|
+
const data = await this.redis.get(this.key("refresh", token));
|
|
1525
|
+
if (!data) return null;
|
|
1526
|
+
const record = JSON.parse(data);
|
|
1527
|
+
if (record.revoked) return null;
|
|
1528
|
+
return record;
|
|
1529
|
+
}
|
|
1530
|
+
async revokeRefreshToken(token) {
|
|
1531
|
+
const record = await this.getRefreshToken(token);
|
|
1532
|
+
if (record) {
|
|
1533
|
+
record.revoked = true;
|
|
1534
|
+
const ttl = Math.ceil((record.expiresAt - Date.now()) / 1e3);
|
|
1535
|
+
await this.redis.set(this.key("refresh", token), JSON.stringify(record), "EX", Math.max(ttl, 1));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
async rotateRefreshToken(oldToken, newRecord) {
|
|
1539
|
+
await this.revokeRefreshToken(oldToken);
|
|
1540
|
+
newRecord.previousToken = oldToken;
|
|
1541
|
+
await this.storeRefreshToken(newRecord);
|
|
1542
|
+
}
|
|
1543
|
+
async cleanup() {
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
// libs/auth/src/session/authorization-vault.ts
|
|
1548
|
+
import { z as z3 } from "zod";
|
|
1549
|
+
var credentialTypeSchema = z3.enum([
|
|
1550
|
+
"oauth",
|
|
1551
|
+
// OAuth 2.0 tokens
|
|
1552
|
+
"api_key",
|
|
1553
|
+
// API key (header or query param)
|
|
1554
|
+
"basic",
|
|
1555
|
+
// Basic auth (username:password)
|
|
1556
|
+
"bearer",
|
|
1557
|
+
// Bearer token (static)
|
|
1558
|
+
"private_key",
|
|
1559
|
+
// Private key for signing (JWT, etc.)
|
|
1560
|
+
"mtls",
|
|
1561
|
+
// Mutual TLS certificate
|
|
1562
|
+
"custom",
|
|
1563
|
+
// Custom credential type
|
|
1564
|
+
"ssh_key",
|
|
1565
|
+
// SSH key for authentication
|
|
1566
|
+
"service_account",
|
|
1567
|
+
// Cloud provider service accounts
|
|
1568
|
+
"oauth_pkce"
|
|
1569
|
+
// OAuth 2.0 with PKCE for public clients
|
|
1570
|
+
]);
|
|
1571
|
+
var oauthCredentialSchema = z3.object({
|
|
1572
|
+
type: z3.literal("oauth"),
|
|
1573
|
+
/** Access token */
|
|
1574
|
+
accessToken: z3.string(),
|
|
1575
|
+
/** Refresh token (optional) */
|
|
1576
|
+
refreshToken: z3.string().optional(),
|
|
1577
|
+
/** Token type (usually 'Bearer') */
|
|
1578
|
+
tokenType: z3.string().default("Bearer"),
|
|
1579
|
+
/** Token expiration timestamp (epoch ms) */
|
|
1580
|
+
expiresAt: z3.number().optional(),
|
|
1581
|
+
/** Granted scopes */
|
|
1582
|
+
scopes: z3.array(z3.string()).default([]),
|
|
1583
|
+
/** ID token for OIDC (optional) */
|
|
1584
|
+
idToken: z3.string().optional()
|
|
1585
|
+
});
|
|
1586
|
+
var apiKeyCredentialSchema = z3.object({
|
|
1587
|
+
type: z3.literal("api_key"),
|
|
1588
|
+
/** The API key value */
|
|
1589
|
+
key: z3.string().min(1),
|
|
1590
|
+
/** Header name to use (e.g., 'X-API-Key', 'Authorization') */
|
|
1591
|
+
headerName: z3.string().default("X-API-Key"),
|
|
1592
|
+
/** Prefix for the header value (e.g., 'Bearer ', 'Api-Key ') */
|
|
1593
|
+
headerPrefix: z3.string().optional(),
|
|
1594
|
+
/** Alternative: send as query parameter */
|
|
1595
|
+
queryParam: z3.string().optional()
|
|
1596
|
+
});
|
|
1597
|
+
var basicAuthCredentialSchema = z3.object({
|
|
1598
|
+
type: z3.literal("basic"),
|
|
1599
|
+
/** Username */
|
|
1600
|
+
username: z3.string().min(1),
|
|
1601
|
+
/** Password */
|
|
1602
|
+
password: z3.string(),
|
|
1603
|
+
/** Pre-computed base64 encoded value (optional, for caching) */
|
|
1604
|
+
encodedValue: z3.string().optional()
|
|
1605
|
+
});
|
|
1606
|
+
var bearerCredentialSchema = z3.object({
|
|
1607
|
+
type: z3.literal("bearer"),
|
|
1608
|
+
/** The bearer token value */
|
|
1609
|
+
token: z3.string().min(1),
|
|
1610
|
+
/** Token expiration (optional, for static tokens that expire) */
|
|
1611
|
+
expiresAt: z3.number().optional()
|
|
1612
|
+
});
|
|
1613
|
+
var privateKeyCredentialSchema = z3.object({
|
|
1614
|
+
type: z3.literal("private_key"),
|
|
1615
|
+
/** Key format */
|
|
1616
|
+
format: z3.enum(["pem", "jwk", "pkcs8", "pkcs12"]),
|
|
1617
|
+
/** The key data (PEM string or JWK JSON) */
|
|
1618
|
+
keyData: z3.string(),
|
|
1619
|
+
/** Key ID (for JWK) */
|
|
1620
|
+
keyId: z3.string().optional(),
|
|
1621
|
+
/** Algorithm to use for signing */
|
|
1622
|
+
algorithm: z3.string().optional(),
|
|
1623
|
+
/** Passphrase if key is encrypted */
|
|
1624
|
+
passphrase: z3.string().optional(),
|
|
1625
|
+
/** Associated certificate (for mTLS) */
|
|
1626
|
+
certificate: z3.string().optional()
|
|
1627
|
+
});
|
|
1628
|
+
var mtlsCredentialSchema = z3.object({
|
|
1629
|
+
type: z3.literal("mtls"),
|
|
1630
|
+
/** Client certificate (PEM format) */
|
|
1631
|
+
certificate: z3.string(),
|
|
1632
|
+
/** Private key (PEM format) */
|
|
1633
|
+
privateKey: z3.string(),
|
|
1634
|
+
/** Passphrase if private key is encrypted */
|
|
1635
|
+
passphrase: z3.string().optional(),
|
|
1636
|
+
/** CA certificate chain (optional) */
|
|
1637
|
+
caCertificate: z3.string().optional()
|
|
1638
|
+
});
|
|
1639
|
+
var customCredentialSchema = z3.object({
|
|
1640
|
+
type: z3.literal("custom"),
|
|
1641
|
+
/** Custom type identifier */
|
|
1642
|
+
customType: z3.string().min(1),
|
|
1643
|
+
/** Arbitrary credential data */
|
|
1644
|
+
data: z3.record(z3.string(), z3.unknown()),
|
|
1645
|
+
/** Headers to include in requests */
|
|
1646
|
+
headers: z3.record(z3.string(), z3.string()).optional()
|
|
1647
|
+
});
|
|
1648
|
+
var sshKeyCredentialSchema = z3.object({
|
|
1649
|
+
type: z3.literal("ssh_key"),
|
|
1650
|
+
/** Private key (PEM format) */
|
|
1651
|
+
privateKey: z3.string().min(1),
|
|
1652
|
+
/** Public key (optional, can be derived from private key) */
|
|
1653
|
+
publicKey: z3.string().optional(),
|
|
1654
|
+
/** Passphrase if private key is encrypted */
|
|
1655
|
+
passphrase: z3.string().optional(),
|
|
1656
|
+
/** Key type */
|
|
1657
|
+
keyType: z3.enum(["rsa", "ed25519", "ecdsa", "dsa"]).default("ed25519"),
|
|
1658
|
+
/** Key fingerprint (SHA256 hash) */
|
|
1659
|
+
fingerprint: z3.string().optional(),
|
|
1660
|
+
/** Username for SSH connections */
|
|
1661
|
+
username: z3.string().optional()
|
|
1662
|
+
});
|
|
1663
|
+
var serviceAccountCredentialSchema = z3.object({
|
|
1664
|
+
type: z3.literal("service_account"),
|
|
1665
|
+
/** Cloud provider */
|
|
1666
|
+
provider: z3.enum(["gcp", "aws", "azure", "custom"]),
|
|
1667
|
+
/** Raw credentials (JSON key file content, access keys, etc.) */
|
|
1668
|
+
credentials: z3.record(z3.string(), z3.unknown()),
|
|
1669
|
+
/** Project/Account ID */
|
|
1670
|
+
projectId: z3.string().optional(),
|
|
1671
|
+
/** Region for regional services */
|
|
1672
|
+
region: z3.string().optional(),
|
|
1673
|
+
/** AWS: Role ARN to assume */
|
|
1674
|
+
assumeRoleArn: z3.string().optional(),
|
|
1675
|
+
/** AWS: External ID for cross-account access */
|
|
1676
|
+
externalId: z3.string().optional(),
|
|
1677
|
+
/** Service account email (GCP) or ARN (AWS) */
|
|
1678
|
+
serviceAccountId: z3.string().optional(),
|
|
1679
|
+
/** Expiration timestamp for temporary credentials */
|
|
1680
|
+
expiresAt: z3.number().optional()
|
|
1681
|
+
});
|
|
1682
|
+
var pkceOAuthCredentialSchema = z3.object({
|
|
1683
|
+
type: z3.literal("oauth_pkce"),
|
|
1684
|
+
/** Access token */
|
|
1685
|
+
accessToken: z3.string(),
|
|
1686
|
+
/** Refresh token (optional) */
|
|
1687
|
+
refreshToken: z3.string().optional(),
|
|
1688
|
+
/** Token type (usually 'Bearer') */
|
|
1689
|
+
tokenType: z3.string().default("Bearer"),
|
|
1690
|
+
/** Token expiration timestamp (epoch ms) */
|
|
1691
|
+
expiresAt: z3.number().optional(),
|
|
1692
|
+
/** Granted scopes */
|
|
1693
|
+
scopes: z3.array(z3.string()).default([]),
|
|
1694
|
+
/** ID token for OIDC (optional) */
|
|
1695
|
+
idToken: z3.string().optional(),
|
|
1696
|
+
/** Authorization server issuer */
|
|
1697
|
+
issuer: z3.string().optional()
|
|
1698
|
+
});
|
|
1699
|
+
var credentialSchema = z3.discriminatedUnion("type", [
|
|
1700
|
+
oauthCredentialSchema,
|
|
1701
|
+
apiKeyCredentialSchema,
|
|
1702
|
+
basicAuthCredentialSchema,
|
|
1703
|
+
bearerCredentialSchema,
|
|
1704
|
+
privateKeyCredentialSchema,
|
|
1705
|
+
mtlsCredentialSchema,
|
|
1706
|
+
customCredentialSchema,
|
|
1707
|
+
sshKeyCredentialSchema,
|
|
1708
|
+
serviceAccountCredentialSchema,
|
|
1709
|
+
pkceOAuthCredentialSchema
|
|
1710
|
+
]);
|
|
1711
|
+
var appCredentialSchema = z3.object({
|
|
1712
|
+
/** App ID this credential belongs to */
|
|
1713
|
+
appId: z3.string().min(1),
|
|
1714
|
+
/** Provider ID within the app (for apps with multiple auth providers) */
|
|
1715
|
+
providerId: z3.string().min(1),
|
|
1716
|
+
/** The credential data */
|
|
1717
|
+
credential: credentialSchema,
|
|
1718
|
+
/** Timestamp when credential was acquired */
|
|
1719
|
+
acquiredAt: z3.number(),
|
|
1720
|
+
/** Timestamp when credential was last used */
|
|
1721
|
+
lastUsedAt: z3.number().optional(),
|
|
1722
|
+
/** Credential expiration (if applicable) */
|
|
1723
|
+
expiresAt: z3.number().optional(),
|
|
1724
|
+
/** Whether this credential is currently valid */
|
|
1725
|
+
isValid: z3.boolean().default(true),
|
|
1726
|
+
/** Error message if credential is invalid */
|
|
1727
|
+
invalidReason: z3.string().optional(),
|
|
1728
|
+
/** User info associated with this credential */
|
|
1729
|
+
userInfo: z3.object({
|
|
1730
|
+
sub: z3.string().optional(),
|
|
1731
|
+
email: z3.string().optional(),
|
|
1732
|
+
name: z3.string().optional()
|
|
1733
|
+
}).optional(),
|
|
1734
|
+
/** Metadata for tracking/debugging */
|
|
1735
|
+
metadata: z3.record(z3.string(), z3.unknown()).optional()
|
|
1736
|
+
});
|
|
1737
|
+
var vaultConsentRecordSchema = z3.object({
|
|
1738
|
+
/** Whether consent was enabled */
|
|
1739
|
+
enabled: z3.boolean(),
|
|
1740
|
+
/** Selected tool IDs (user approved these) */
|
|
1741
|
+
selectedToolIds: z3.array(z3.string()),
|
|
1742
|
+
/** Available tool IDs at time of consent */
|
|
1743
|
+
availableToolIds: z3.array(z3.string()),
|
|
1744
|
+
/** Timestamp when consent was given */
|
|
1745
|
+
consentedAt: z3.number(),
|
|
1746
|
+
/** Consent version for tracking changes */
|
|
1747
|
+
version: z3.string().default("1.0")
|
|
1748
|
+
});
|
|
1749
|
+
var vaultFederatedRecordSchema = z3.object({
|
|
1750
|
+
/** Provider IDs that were selected */
|
|
1751
|
+
selectedProviderIds: z3.array(z3.string()),
|
|
1752
|
+
/** Provider IDs that were skipped (can be authorized later) */
|
|
1753
|
+
skippedProviderIds: z3.array(z3.string()),
|
|
1754
|
+
/** Primary provider ID */
|
|
1755
|
+
primaryProviderId: z3.string().optional(),
|
|
1756
|
+
/** Timestamp when federated login was completed */
|
|
1757
|
+
completedAt: z3.number()
|
|
1758
|
+
});
|
|
1759
|
+
var pendingIncrementalAuthSchema = z3.object({
|
|
1760
|
+
/** Unique ID for this request */
|
|
1761
|
+
id: z3.string(),
|
|
1762
|
+
/** App ID being authorized */
|
|
1763
|
+
appId: z3.string(),
|
|
1764
|
+
/** Tool ID that triggered the auth request */
|
|
1765
|
+
toolId: z3.string().optional(),
|
|
1766
|
+
/** Authorization URL */
|
|
1767
|
+
authUrl: z3.string(),
|
|
1768
|
+
/** Required scopes */
|
|
1769
|
+
requiredScopes: z3.array(z3.string()).optional(),
|
|
1770
|
+
/** Whether elicit is being used */
|
|
1771
|
+
elicitId: z3.string().optional(),
|
|
1772
|
+
/** Timestamp when request was created */
|
|
1773
|
+
createdAt: z3.number(),
|
|
1774
|
+
/** Expiration timestamp */
|
|
1775
|
+
expiresAt: z3.number(),
|
|
1776
|
+
/** Status of the request */
|
|
1777
|
+
status: z3.enum(["pending", "completed", "cancelled", "expired"])
|
|
1778
|
+
});
|
|
1779
|
+
var authorizationVaultEntrySchema = z3.object({
|
|
1780
|
+
/** Vault ID (maps to access token jti claim) */
|
|
1781
|
+
id: z3.string(),
|
|
1782
|
+
/** User subject identifier */
|
|
1783
|
+
userSub: z3.string(),
|
|
1784
|
+
/** User email */
|
|
1785
|
+
userEmail: z3.string().optional(),
|
|
1786
|
+
/** User name */
|
|
1787
|
+
userName: z3.string().optional(),
|
|
1788
|
+
/** Client ID that created this session */
|
|
1789
|
+
clientId: z3.string(),
|
|
1790
|
+
/** Creation timestamp */
|
|
1791
|
+
createdAt: z3.number(),
|
|
1792
|
+
/** Last access timestamp */
|
|
1793
|
+
lastAccessAt: z3.number(),
|
|
1794
|
+
/** App credentials (keyed by `${appId}:${providerId}`) */
|
|
1795
|
+
appCredentials: z3.record(z3.string(), appCredentialSchema).default({}),
|
|
1796
|
+
/** Consent record */
|
|
1797
|
+
consent: vaultConsentRecordSchema.optional(),
|
|
1798
|
+
/** Federated login record */
|
|
1799
|
+
federated: vaultFederatedRecordSchema.optional(),
|
|
1800
|
+
/** Pending incremental authorization requests */
|
|
1801
|
+
pendingAuths: z3.array(pendingIncrementalAuthSchema),
|
|
1802
|
+
/** Apps that are fully authorized */
|
|
1803
|
+
authorizedAppIds: z3.array(z3.string()),
|
|
1804
|
+
/** Apps that were skipped (not yet authorized) */
|
|
1805
|
+
skippedAppIds: z3.array(z3.string())
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
// libs/auth/src/session/vault-encryption.ts
|
|
1809
|
+
import { z as z4 } from "zod";
|
|
1810
|
+
import {
|
|
1811
|
+
hkdfSha256,
|
|
1812
|
+
encryptAesGcm,
|
|
1813
|
+
decryptAesGcm,
|
|
1814
|
+
randomBytes as randomBytes3,
|
|
1815
|
+
base64urlEncode,
|
|
1816
|
+
base64urlDecode
|
|
1817
|
+
} from "@frontmcp/utils";
|
|
1818
|
+
var encryptedDataSchema = z4.object({
|
|
1819
|
+
/** Version for future algorithm changes */
|
|
1820
|
+
v: z4.literal(1),
|
|
1821
|
+
/** Algorithm identifier */
|
|
1822
|
+
alg: z4.literal("aes-256-gcm"),
|
|
1823
|
+
/** Initialization vector (base64) */
|
|
1824
|
+
iv: z4.string(),
|
|
1825
|
+
/** Ciphertext (base64) */
|
|
1826
|
+
ct: z4.string(),
|
|
1827
|
+
/** Authentication tag (base64) */
|
|
1828
|
+
tag: z4.string()
|
|
1829
|
+
});
|
|
1830
|
+
var VaultEncryption = class {
|
|
1831
|
+
pepper;
|
|
1832
|
+
hkdfInfo;
|
|
1833
|
+
constructor(config = {}) {
|
|
1834
|
+
this.pepper = new TextEncoder().encode(config.pepper ?? "");
|
|
1835
|
+
this.hkdfInfo = config.hkdfInfo ?? "frontmcp-vault-v1";
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Derive an encryption key from JWT claims
|
|
1839
|
+
*
|
|
1840
|
+
* The key derivation uses HKDF:
|
|
1841
|
+
* 1. Combine jti + vaultKey + sub + iat + pepper as IKM
|
|
1842
|
+
* 2. Apply HKDF-SHA256 to derive a 256-bit key
|
|
1843
|
+
*
|
|
1844
|
+
* @param claims - JWT claims containing key material
|
|
1845
|
+
* @returns 32-byte encryption key as Uint8Array
|
|
1846
|
+
*/
|
|
1847
|
+
async deriveKey(claims) {
|
|
1848
|
+
const ikmParts = [
|
|
1849
|
+
claims.jti,
|
|
1850
|
+
claims.vaultKey ?? "",
|
|
1851
|
+
claims.sub,
|
|
1852
|
+
claims.iat.toString(),
|
|
1853
|
+
new TextDecoder().decode(this.pepper)
|
|
1854
|
+
];
|
|
1855
|
+
const ikm = new TextEncoder().encode(ikmParts.join(""));
|
|
1856
|
+
const infoBytes = new TextEncoder().encode(this.hkdfInfo);
|
|
1857
|
+
const key = await hkdfSha256(ikm, new Uint8Array(0), infoBytes, 32);
|
|
1858
|
+
return key;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Derive a key directly from the raw JWT token string
|
|
1862
|
+
*
|
|
1863
|
+
* This is useful when you want to derive the key from the token
|
|
1864
|
+
* before or without fully parsing the claims. Uses the token's
|
|
1865
|
+
* signature portion as additional entropy.
|
|
1866
|
+
*
|
|
1867
|
+
* @param token - The raw JWT token string
|
|
1868
|
+
* @param claims - Parsed JWT claims
|
|
1869
|
+
* @returns 32-byte encryption key as Uint8Array
|
|
1870
|
+
*/
|
|
1871
|
+
async deriveKeyFromToken(token, claims) {
|
|
1872
|
+
const parts = token.split(".");
|
|
1873
|
+
const signature = parts[2] ?? "";
|
|
1874
|
+
const ikmParts = [
|
|
1875
|
+
claims.jti,
|
|
1876
|
+
claims.vaultKey ?? "",
|
|
1877
|
+
claims.sub,
|
|
1878
|
+
claims.iat.toString(),
|
|
1879
|
+
signature,
|
|
1880
|
+
new TextDecoder().decode(this.pepper)
|
|
1881
|
+
];
|
|
1882
|
+
const ikm = new TextEncoder().encode(ikmParts.join(""));
|
|
1883
|
+
const infoBytes = new TextEncoder().encode(this.hkdfInfo);
|
|
1884
|
+
const key = await hkdfSha256(ikm, new Uint8Array(0), infoBytes, 32);
|
|
1885
|
+
return key;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Encrypt plaintext data using AES-256-GCM
|
|
1889
|
+
*
|
|
1890
|
+
* @param plaintext - Data to encrypt (typically JSON string)
|
|
1891
|
+
* @param key - 32-byte encryption key from deriveKey()
|
|
1892
|
+
* @returns Encrypted data object (safe to store in Redis)
|
|
1893
|
+
*/
|
|
1894
|
+
async encrypt(plaintext, key) {
|
|
1895
|
+
if (key.length !== 32) {
|
|
1896
|
+
throw new Error("Encryption key must be 32 bytes");
|
|
1897
|
+
}
|
|
1898
|
+
const iv = randomBytes3(12);
|
|
1899
|
+
const { ciphertext, tag } = await encryptAesGcm(key, new TextEncoder().encode(plaintext), iv);
|
|
1900
|
+
return {
|
|
1901
|
+
v: 1,
|
|
1902
|
+
alg: "aes-256-gcm",
|
|
1903
|
+
iv: base64urlEncode(iv),
|
|
1904
|
+
ct: base64urlEncode(ciphertext),
|
|
1905
|
+
tag: base64urlEncode(tag)
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Decrypt encrypted data using AES-256-GCM
|
|
1910
|
+
*
|
|
1911
|
+
* @param encrypted - Encrypted data object from encrypt()
|
|
1912
|
+
* @param key - 32-byte encryption key from deriveKey()
|
|
1913
|
+
* @returns Decrypted plaintext
|
|
1914
|
+
* @throws Error if decryption fails (wrong key, tampered data, etc.)
|
|
1915
|
+
*/
|
|
1916
|
+
async decrypt(encrypted, key) {
|
|
1917
|
+
if (key.length !== 32) {
|
|
1918
|
+
throw new Error("Encryption key must be 32 bytes");
|
|
1919
|
+
}
|
|
1920
|
+
const parsed = encryptedDataSchema.safeParse(encrypted);
|
|
1921
|
+
if (!parsed.success) {
|
|
1922
|
+
throw new Error("Invalid encrypted data format");
|
|
1923
|
+
}
|
|
1924
|
+
const { iv, ct, tag } = parsed.data;
|
|
1925
|
+
const ivBuffer = base64urlDecode(iv);
|
|
1926
|
+
const ciphertext = base64urlDecode(ct);
|
|
1927
|
+
const tagBuffer = base64urlDecode(tag);
|
|
1928
|
+
try {
|
|
1929
|
+
const plaintext = await decryptAesGcm(key, ciphertext, ivBuffer, tagBuffer);
|
|
1930
|
+
return new TextDecoder().decode(plaintext);
|
|
1931
|
+
} catch (_error) {
|
|
1932
|
+
throw new Error("Decryption failed: invalid key or corrupted data");
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Encrypt a JavaScript object (serializes to JSON first)
|
|
1937
|
+
*
|
|
1938
|
+
* @param data - Object to encrypt
|
|
1939
|
+
* @param key - Encryption key
|
|
1940
|
+
* @returns Encrypted data
|
|
1941
|
+
*/
|
|
1942
|
+
async encryptObject(data, key) {
|
|
1943
|
+
return this.encrypt(JSON.stringify(data), key);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Decrypt and parse a JavaScript object
|
|
1947
|
+
*
|
|
1948
|
+
* @param encrypted - Encrypted data
|
|
1949
|
+
* @param key - Encryption key
|
|
1950
|
+
* @returns Decrypted and parsed object
|
|
1951
|
+
*/
|
|
1952
|
+
async decryptObject(encrypted, key) {
|
|
1953
|
+
const plaintext = await this.decrypt(encrypted, key);
|
|
1954
|
+
return JSON.parse(plaintext);
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Check if data is in encrypted format
|
|
1958
|
+
*
|
|
1959
|
+
* @param data - Data to check
|
|
1960
|
+
* @returns True if data appears to be encrypted
|
|
1961
|
+
*/
|
|
1962
|
+
isEncrypted(data) {
|
|
1963
|
+
return encryptedDataSchema.safeParse(data).success;
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
var encryptedVaultEntrySchema = z4.object({
|
|
1967
|
+
/** Vault ID (maps to JWT jti claim) */
|
|
1968
|
+
id: z4.string(),
|
|
1969
|
+
/** User subject identifier */
|
|
1970
|
+
userSub: z4.string(),
|
|
1971
|
+
/** User email (unencrypted for display) */
|
|
1972
|
+
userEmail: z4.string().optional(),
|
|
1973
|
+
/** User name (unencrypted for display) */
|
|
1974
|
+
userName: z4.string().optional(),
|
|
1975
|
+
/** Client ID that created this session */
|
|
1976
|
+
clientId: z4.string(),
|
|
1977
|
+
/** Creation timestamp */
|
|
1978
|
+
createdAt: z4.number(),
|
|
1979
|
+
/** Last access timestamp */
|
|
1980
|
+
lastAccessAt: z4.number(),
|
|
1981
|
+
/** Encrypted sensitive data (provider tokens, credentials, consent) */
|
|
1982
|
+
encryptedData: encryptedDataSchema,
|
|
1983
|
+
/** Apps that are fully authorized (unencrypted for quick lookup) */
|
|
1984
|
+
authorizedAppIds: z4.array(z4.string()),
|
|
1985
|
+
/** Apps that were skipped (unencrypted for quick lookup) */
|
|
1986
|
+
skippedAppIds: z4.array(z4.string()),
|
|
1987
|
+
/** Pending auth IDs (unencrypted for lookup, actual URLs encrypted) */
|
|
1988
|
+
pendingAuthIds: z4.array(z4.string()).default([])
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
// libs/auth/src/session/token.vault.ts
|
|
1992
|
+
import { encryptAesGcm as encryptAesGcm2, decryptAesGcm as decryptAesGcm2, randomBytes as randomBytes4, base64urlEncode as base64urlEncode2, base64urlDecode as base64urlDecode2 } from "@frontmcp/utils";
|
|
1993
|
+
var TokenVault = class {
|
|
1994
|
+
/** Active key used for new encryptions */
|
|
1995
|
+
active;
|
|
1996
|
+
/** All known keys by kid for decryption (includes active) */
|
|
1997
|
+
keys = /* @__PURE__ */ new Map();
|
|
1998
|
+
constructor(keys) {
|
|
1999
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
2000
|
+
throw new Error("TokenVault requires at least one key");
|
|
2001
|
+
}
|
|
2002
|
+
for (const k of keys) {
|
|
2003
|
+
if (!(k.key instanceof Uint8Array) || k.key.length !== 32) {
|
|
2004
|
+
throw new Error(`TokenVault key "${k.kid}" must be a 32-byte Uint8Array for AES-256-GCM`);
|
|
2005
|
+
}
|
|
2006
|
+
if (this.keys.has(k.kid)) {
|
|
2007
|
+
throw new Error(`TokenVault duplicate kid: "${k.kid}"`);
|
|
2008
|
+
}
|
|
2009
|
+
this.keys.set(k.kid, k.key);
|
|
2010
|
+
}
|
|
2011
|
+
this.active = keys[0];
|
|
2012
|
+
}
|
|
2013
|
+
rotateTo(k) {
|
|
2014
|
+
if (!(k.key instanceof Uint8Array) || k.key.length !== 32) {
|
|
2015
|
+
throw new Error(`TokenVault key "${k.kid}" must be a 32-byte Uint8Array for AES-256-GCM`);
|
|
2016
|
+
}
|
|
2017
|
+
this.active = k;
|
|
2018
|
+
this.keys.set(k.kid, k.key);
|
|
2019
|
+
}
|
|
2020
|
+
async encrypt(plaintext, opts) {
|
|
2021
|
+
const iv = randomBytes4(12);
|
|
2022
|
+
const { ciphertext, tag } = await encryptAesGcm2(this.active.key, new TextEncoder().encode(plaintext), iv);
|
|
2023
|
+
return {
|
|
2024
|
+
alg: "A256GCM",
|
|
2025
|
+
kid: this.active.kid,
|
|
2026
|
+
iv: base64urlEncode2(iv),
|
|
2027
|
+
tag: base64urlEncode2(tag),
|
|
2028
|
+
data: base64urlEncode2(ciphertext),
|
|
2029
|
+
exp: opts?.exp,
|
|
2030
|
+
meta: opts?.meta
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
async decrypt(blob) {
|
|
2034
|
+
if (blob.exp !== void 0) {
|
|
2035
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2036
|
+
if (nowSeconds > blob.exp) {
|
|
2037
|
+
throw new Error(`vault_expired:${blob.kid}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
const key = this.keys.get(blob.kid);
|
|
2041
|
+
if (!key) throw new Error(`vault_unknown_kid:${blob.kid}`);
|
|
2042
|
+
const iv = base64urlDecode2(blob.iv);
|
|
2043
|
+
const tag = base64urlDecode2(blob.tag);
|
|
2044
|
+
const data = base64urlDecode2(blob.data);
|
|
2045
|
+
const plaintext = await decryptAesGcm2(key, data, iv, tag);
|
|
2046
|
+
return new TextDecoder().decode(plaintext);
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
|
|
2050
|
+
// libs/auth/src/session/index.ts
|
|
2051
|
+
import {
|
|
2052
|
+
hkdfSha256 as hkdfSha2562,
|
|
2053
|
+
encryptValue,
|
|
2054
|
+
decryptValue,
|
|
2055
|
+
encryptAesGcm as encryptAesGcm3,
|
|
2056
|
+
decryptAesGcm as decryptAesGcm3
|
|
2057
|
+
} from "@frontmcp/utils";
|
|
2058
|
+
|
|
2059
|
+
// libs/auth/src/session/utils/tiny-ttl-cache.ts
|
|
2060
|
+
var TinyTtlCache = class {
|
|
2061
|
+
constructor(ttlMs) {
|
|
2062
|
+
this.ttlMs = ttlMs;
|
|
2063
|
+
}
|
|
2064
|
+
map = /* @__PURE__ */ new Map();
|
|
2065
|
+
get(k) {
|
|
2066
|
+
const hit = this.map.get(k);
|
|
2067
|
+
if (!hit) return void 0;
|
|
2068
|
+
if (hit.exp < Date.now()) {
|
|
2069
|
+
this.map.delete(k);
|
|
2070
|
+
return void 0;
|
|
2071
|
+
}
|
|
2072
|
+
return hit.v;
|
|
2073
|
+
}
|
|
2074
|
+
set(k, v) {
|
|
2075
|
+
this.map.set(k, { v, exp: Date.now() + this.ttlMs });
|
|
2076
|
+
}
|
|
2077
|
+
delete(k) {
|
|
2078
|
+
return this.map.delete(k);
|
|
2079
|
+
}
|
|
2080
|
+
clear() {
|
|
2081
|
+
this.map.clear();
|
|
2082
|
+
}
|
|
2083
|
+
size() {
|
|
2084
|
+
return this.map.size;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Remove all expired entries
|
|
2088
|
+
*/
|
|
2089
|
+
cleanup() {
|
|
2090
|
+
const now = Date.now();
|
|
2091
|
+
let removed = 0;
|
|
2092
|
+
for (const [k, { exp }] of this.map) {
|
|
2093
|
+
if (exp < now) {
|
|
2094
|
+
this.map.delete(k);
|
|
2095
|
+
removed++;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return removed;
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
// libs/auth/src/session/storage/index.ts
|
|
2103
|
+
import { TypedStorage as TypedStorage3 } from "@frontmcp/utils";
|
|
2104
|
+
import { EncryptedTypedStorage, EncryptedStorageError } from "@frontmcp/utils";
|
|
2105
|
+
|
|
2106
|
+
// libs/auth/src/session/storage/storage-token-store.ts
|
|
2107
|
+
import { randomUUID as randomUUID2, TypedStorage } from "@frontmcp/utils";
|
|
2108
|
+
var StorageTokenStore = class {
|
|
2109
|
+
storage;
|
|
2110
|
+
namespace;
|
|
2111
|
+
defaultTtlSeconds;
|
|
2112
|
+
/** Track if original storage was namespaced to avoid double-prefixing */
|
|
2113
|
+
storageIsNamespaced;
|
|
2114
|
+
constructor(storage, options = {}) {
|
|
2115
|
+
this.namespace = options.namespace ?? "tok";
|
|
2116
|
+
this.defaultTtlSeconds = options.defaultTtlSeconds;
|
|
2117
|
+
this.storageIsNamespaced = "namespace" in storage && typeof storage.namespace === "function";
|
|
2118
|
+
const namespacedStorage = this.storageIsNamespaced ? storage.namespace(this.namespace) : storage;
|
|
2119
|
+
this.storage = new TypedStorage(namespacedStorage);
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Allocate a new unique ID for a token record.
|
|
2123
|
+
*/
|
|
2124
|
+
allocId() {
|
|
2125
|
+
return randomUUID2();
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Store an encrypted token blob.
|
|
2129
|
+
*
|
|
2130
|
+
* TTL is calculated from blob.exp (epoch seconds) if present.
|
|
2131
|
+
* Falls back to defaultTtlSeconds if configured.
|
|
2132
|
+
*
|
|
2133
|
+
* @param id - Token record ID
|
|
2134
|
+
* @param blob - Encrypted token blob
|
|
2135
|
+
*/
|
|
2136
|
+
async put(id, blob) {
|
|
2137
|
+
const record = {
|
|
2138
|
+
id,
|
|
2139
|
+
blob,
|
|
2140
|
+
updatedAt: Date.now()
|
|
2141
|
+
};
|
|
2142
|
+
const ttlSeconds = this.calculateTtl(blob.exp);
|
|
2143
|
+
await this.storage.set(this.key(id), record, ttlSeconds ? { ttlSeconds } : void 0);
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Retrieve a token record by ID.
|
|
2147
|
+
*
|
|
2148
|
+
* @param id - Token record ID
|
|
2149
|
+
* @returns The secret record, or undefined if not found
|
|
2150
|
+
*/
|
|
2151
|
+
async get(id) {
|
|
2152
|
+
const record = await this.storage.get(this.key(id));
|
|
2153
|
+
return record ?? void 0;
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Delete a token record.
|
|
2157
|
+
*
|
|
2158
|
+
* @param id - Token record ID
|
|
2159
|
+
*/
|
|
2160
|
+
async del(id) {
|
|
2161
|
+
await this.storage.delete(this.key(id));
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Calculate TTL in seconds from expiration timestamp.
|
|
2165
|
+
*
|
|
2166
|
+
* @param exp - Expiration timestamp in epoch seconds
|
|
2167
|
+
* @returns TTL in seconds, or undefined if no TTL should be applied
|
|
2168
|
+
*/
|
|
2169
|
+
calculateTtl(exp) {
|
|
2170
|
+
if (exp) {
|
|
2171
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2172
|
+
const ttl = exp - nowSeconds;
|
|
2173
|
+
return ttl > 0 ? ttl : 1;
|
|
2174
|
+
}
|
|
2175
|
+
return this.defaultTtlSeconds;
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Build the storage key for a token ID.
|
|
2179
|
+
* For namespaced storage, the namespace is handled by the storage layer.
|
|
2180
|
+
* For non-namespaced storage, includes the namespace prefix in the key.
|
|
2181
|
+
*/
|
|
2182
|
+
key(id) {
|
|
2183
|
+
return this.storageIsNamespaced ? id : `${this.namespace}:${id}`;
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
// libs/auth/src/session/storage/storage-authorization-vault.ts
|
|
2188
|
+
import { randomUUID as randomUUID3, TypedStorage as TypedStorage2 } from "@frontmcp/utils";
|
|
2189
|
+
var StorageAuthorizationVault = class {
|
|
2190
|
+
storage;
|
|
2191
|
+
namespace;
|
|
2192
|
+
pendingAuthTtlMs;
|
|
2193
|
+
constructor(storage, options = {}) {
|
|
2194
|
+
this.namespace = options.namespace ?? "vault";
|
|
2195
|
+
this.pendingAuthTtlMs = options.pendingAuthTtlMs ?? 10 * 60 * 1e3;
|
|
2196
|
+
const namespacedStorage = this.isNamespacedStorage(storage) ? storage.namespace(this.namespace) : storage;
|
|
2197
|
+
this.storage = new TypedStorage2(namespacedStorage, {
|
|
2198
|
+
schema: options.validateOnRead ? authorizationVaultEntrySchema : void 0,
|
|
2199
|
+
throwOnInvalid: false
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
// ============================================
|
|
2203
|
+
// Core CRUD Methods
|
|
2204
|
+
// ============================================
|
|
2205
|
+
async create(params) {
|
|
2206
|
+
const now = Date.now();
|
|
2207
|
+
const entry = {
|
|
2208
|
+
id: randomUUID3(),
|
|
2209
|
+
userSub: params.userSub,
|
|
2210
|
+
userEmail: params.userEmail,
|
|
2211
|
+
userName: params.userName,
|
|
2212
|
+
clientId: params.clientId,
|
|
2213
|
+
createdAt: now,
|
|
2214
|
+
lastAccessAt: now,
|
|
2215
|
+
appCredentials: {},
|
|
2216
|
+
consent: params.consent,
|
|
2217
|
+
federated: params.federated,
|
|
2218
|
+
pendingAuths: [],
|
|
2219
|
+
authorizedAppIds: params.authorizedAppIds ?? [],
|
|
2220
|
+
skippedAppIds: params.skippedAppIds ?? []
|
|
2221
|
+
};
|
|
2222
|
+
await this.storage.set(this.key(entry.id), entry);
|
|
2223
|
+
return entry;
|
|
2224
|
+
}
|
|
2225
|
+
async get(id) {
|
|
2226
|
+
return this.storage.get(this.key(id));
|
|
2227
|
+
}
|
|
2228
|
+
async update(id, updates) {
|
|
2229
|
+
const entry = await this.get(id);
|
|
2230
|
+
if (!entry) {
|
|
2231
|
+
console.warn(`[StorageAuthorizationVault] Update failed: vault entry not found for id ${id}`);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
const updated = {
|
|
2235
|
+
...entry,
|
|
2236
|
+
...updates,
|
|
2237
|
+
lastAccessAt: Date.now()
|
|
2238
|
+
};
|
|
2239
|
+
await this.storage.set(this.key(id), updated);
|
|
2240
|
+
}
|
|
2241
|
+
async delete(id) {
|
|
2242
|
+
await this.storage.delete(this.key(id));
|
|
2243
|
+
}
|
|
2244
|
+
// ============================================
|
|
2245
|
+
// Consent Methods
|
|
2246
|
+
// ============================================
|
|
2247
|
+
async updateConsent(vaultId, consent) {
|
|
2248
|
+
const entry = await this.get(vaultId);
|
|
2249
|
+
if (!entry) return;
|
|
2250
|
+
entry.consent = consent;
|
|
2251
|
+
entry.lastAccessAt = Date.now();
|
|
2252
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2253
|
+
}
|
|
2254
|
+
// ============================================
|
|
2255
|
+
// App Authorization Methods
|
|
2256
|
+
// ============================================
|
|
2257
|
+
async authorizeApp(vaultId, appId) {
|
|
2258
|
+
const entry = await this.get(vaultId);
|
|
2259
|
+
if (!entry) return;
|
|
2260
|
+
entry.skippedAppIds = entry.skippedAppIds.filter((id) => id !== appId);
|
|
2261
|
+
if (!entry.authorizedAppIds.includes(appId)) {
|
|
2262
|
+
entry.authorizedAppIds.push(appId);
|
|
2263
|
+
}
|
|
2264
|
+
entry.lastAccessAt = Date.now();
|
|
2265
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2266
|
+
}
|
|
2267
|
+
async isAppAuthorized(vaultId, appId) {
|
|
2268
|
+
const entry = await this.get(vaultId);
|
|
2269
|
+
if (!entry) return false;
|
|
2270
|
+
return entry.authorizedAppIds.includes(appId);
|
|
2271
|
+
}
|
|
2272
|
+
// ============================================
|
|
2273
|
+
// Pending Auth Methods
|
|
2274
|
+
// ============================================
|
|
2275
|
+
async createPendingAuth(vaultId, params) {
|
|
2276
|
+
const entry = await this.get(vaultId);
|
|
2277
|
+
if (!entry) {
|
|
2278
|
+
throw new Error(`Vault not found: ${vaultId}`);
|
|
2279
|
+
}
|
|
2280
|
+
const now = Date.now();
|
|
2281
|
+
const pendingAuth = {
|
|
2282
|
+
id: randomUUID3(),
|
|
2283
|
+
appId: params.appId,
|
|
2284
|
+
toolId: params.toolId,
|
|
2285
|
+
authUrl: params.authUrl,
|
|
2286
|
+
requiredScopes: params.requiredScopes,
|
|
2287
|
+
elicitId: params.elicitId,
|
|
2288
|
+
createdAt: now,
|
|
2289
|
+
expiresAt: now + (params.ttlMs ?? this.pendingAuthTtlMs),
|
|
2290
|
+
status: "pending"
|
|
2291
|
+
};
|
|
2292
|
+
entry.pendingAuths.push(pendingAuth);
|
|
2293
|
+
entry.lastAccessAt = now;
|
|
2294
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2295
|
+
return pendingAuth;
|
|
2296
|
+
}
|
|
2297
|
+
async getPendingAuth(vaultId, pendingAuthId) {
|
|
2298
|
+
const entry = await this.get(vaultId);
|
|
2299
|
+
if (!entry) return null;
|
|
2300
|
+
const pendingAuth = entry.pendingAuths.find((p) => p.id === pendingAuthId);
|
|
2301
|
+
if (!pendingAuth) return null;
|
|
2302
|
+
if (Date.now() > pendingAuth.expiresAt && pendingAuth.status === "pending") {
|
|
2303
|
+
pendingAuth.status = "expired";
|
|
2304
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2305
|
+
}
|
|
2306
|
+
return pendingAuth;
|
|
2307
|
+
}
|
|
2308
|
+
async completePendingAuth(vaultId, pendingAuthId) {
|
|
2309
|
+
const entry = await this.get(vaultId);
|
|
2310
|
+
if (!entry) return;
|
|
2311
|
+
const pendingAuth = entry.pendingAuths.find((p) => p.id === pendingAuthId);
|
|
2312
|
+
if (pendingAuth) {
|
|
2313
|
+
pendingAuth.status = "completed";
|
|
2314
|
+
entry.skippedAppIds = entry.skippedAppIds.filter((id) => id !== pendingAuth.appId);
|
|
2315
|
+
if (!entry.authorizedAppIds.includes(pendingAuth.appId)) {
|
|
2316
|
+
entry.authorizedAppIds.push(pendingAuth.appId);
|
|
2317
|
+
}
|
|
2318
|
+
entry.lastAccessAt = Date.now();
|
|
2319
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
async cancelPendingAuth(vaultId, pendingAuthId) {
|
|
2323
|
+
const entry = await this.get(vaultId);
|
|
2324
|
+
if (!entry) return;
|
|
2325
|
+
const pendingAuth = entry.pendingAuths.find((p) => p.id === pendingAuthId);
|
|
2326
|
+
if (pendingAuth) {
|
|
2327
|
+
pendingAuth.status = "cancelled";
|
|
2328
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
async getPendingAuths(vaultId) {
|
|
2332
|
+
const entry = await this.get(vaultId);
|
|
2333
|
+
if (!entry) return [];
|
|
2334
|
+
const now = Date.now();
|
|
2335
|
+
let updated = false;
|
|
2336
|
+
const pending = entry.pendingAuths.filter((p) => {
|
|
2337
|
+
if (now > p.expiresAt && p.status === "pending") {
|
|
2338
|
+
p.status = "expired";
|
|
2339
|
+
updated = true;
|
|
2340
|
+
}
|
|
2341
|
+
return p.status === "pending";
|
|
2342
|
+
});
|
|
2343
|
+
if (updated) {
|
|
2344
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2345
|
+
}
|
|
2346
|
+
return pending;
|
|
2347
|
+
}
|
|
2348
|
+
// ============================================
|
|
2349
|
+
// App Credential Methods
|
|
2350
|
+
// ============================================
|
|
2351
|
+
credentialKey(appId, providerId) {
|
|
2352
|
+
return `${appId}:${providerId}`;
|
|
2353
|
+
}
|
|
2354
|
+
async addAppCredential(vaultId, credential) {
|
|
2355
|
+
const entry = await this.get(vaultId);
|
|
2356
|
+
if (!entry) return;
|
|
2357
|
+
const shouldStore = await this.shouldStoreCredential(vaultId, credential.appId);
|
|
2358
|
+
if (!shouldStore) {
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
const key = this.credentialKey(credential.appId, credential.providerId);
|
|
2362
|
+
entry.appCredentials[key] = credential;
|
|
2363
|
+
entry.lastAccessAt = Date.now();
|
|
2364
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2365
|
+
}
|
|
2366
|
+
async removeAppCredential(vaultId, appId, providerId) {
|
|
2367
|
+
const entry = await this.get(vaultId);
|
|
2368
|
+
if (!entry) return;
|
|
2369
|
+
const key = this.credentialKey(appId, providerId);
|
|
2370
|
+
delete entry.appCredentials[key];
|
|
2371
|
+
entry.lastAccessAt = Date.now();
|
|
2372
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2373
|
+
}
|
|
2374
|
+
async getAppCredentials(vaultId, appId) {
|
|
2375
|
+
const entry = await this.get(vaultId);
|
|
2376
|
+
if (!entry) return [];
|
|
2377
|
+
const prefix = `${appId}:`;
|
|
2378
|
+
return Object.entries(entry.appCredentials).filter(([key]) => key.startsWith(prefix)).map(([, cred]) => cred);
|
|
2379
|
+
}
|
|
2380
|
+
async getCredential(vaultId, appId, providerId) {
|
|
2381
|
+
const entry = await this.get(vaultId);
|
|
2382
|
+
if (!entry) return null;
|
|
2383
|
+
const key = this.credentialKey(appId, providerId);
|
|
2384
|
+
return entry.appCredentials[key] ?? null;
|
|
2385
|
+
}
|
|
2386
|
+
async getAllCredentials(vaultId, filterByConsent = false) {
|
|
2387
|
+
const entry = await this.get(vaultId);
|
|
2388
|
+
if (!entry) return [];
|
|
2389
|
+
const allCredentials = Object.values(entry.appCredentials);
|
|
2390
|
+
if (!filterByConsent || !entry.consent?.enabled) {
|
|
2391
|
+
return allCredentials;
|
|
2392
|
+
}
|
|
2393
|
+
const consentedToolIds = new Set(entry.consent.selectedToolIds);
|
|
2394
|
+
return allCredentials.filter((cred) => {
|
|
2395
|
+
return Array.from(consentedToolIds).some((toolId) => toolId.startsWith(`${cred.appId}:`));
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
async updateCredential(vaultId, appId, providerId, updates) {
|
|
2399
|
+
const entry = await this.get(vaultId);
|
|
2400
|
+
if (!entry) return;
|
|
2401
|
+
const key = this.credentialKey(appId, providerId);
|
|
2402
|
+
const credential = entry.appCredentials[key];
|
|
2403
|
+
if (!credential) return;
|
|
2404
|
+
Object.assign(credential, updates);
|
|
2405
|
+
entry.lastAccessAt = Date.now();
|
|
2406
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2407
|
+
}
|
|
2408
|
+
async shouldStoreCredential(vaultId, appId, toolIds) {
|
|
2409
|
+
const entry = await this.get(vaultId);
|
|
2410
|
+
if (!entry) return false;
|
|
2411
|
+
const consent = entry.consent;
|
|
2412
|
+
if (!consent?.enabled) {
|
|
2413
|
+
return true;
|
|
2414
|
+
}
|
|
2415
|
+
if (toolIds && toolIds.length > 0) {
|
|
2416
|
+
return toolIds.some((toolId) => consent.selectedToolIds.includes(toolId));
|
|
2417
|
+
}
|
|
2418
|
+
const consentedToolIds = consent.selectedToolIds;
|
|
2419
|
+
return consentedToolIds.some((toolId) => toolId.startsWith(`${appId}:`));
|
|
2420
|
+
}
|
|
2421
|
+
async invalidateCredential(vaultId, appId, providerId, reason) {
|
|
2422
|
+
await this.updateCredential(vaultId, appId, providerId, {
|
|
2423
|
+
isValid: false,
|
|
2424
|
+
invalidReason: reason
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
async refreshOAuthCredential(vaultId, appId, providerId, tokens) {
|
|
2428
|
+
const entry = await this.get(vaultId);
|
|
2429
|
+
if (!entry) return;
|
|
2430
|
+
const key = this.credentialKey(appId, providerId);
|
|
2431
|
+
const credential = entry.appCredentials[key];
|
|
2432
|
+
if (!credential || credential.credential.type !== "oauth" && credential.credential.type !== "oauth_pkce") return;
|
|
2433
|
+
credential.credential.accessToken = tokens.accessToken;
|
|
2434
|
+
if (tokens.refreshToken !== void 0) {
|
|
2435
|
+
credential.credential.refreshToken = tokens.refreshToken;
|
|
2436
|
+
}
|
|
2437
|
+
if (tokens.expiresAt !== void 0) {
|
|
2438
|
+
credential.credential.expiresAt = tokens.expiresAt;
|
|
2439
|
+
credential.expiresAt = tokens.expiresAt;
|
|
2440
|
+
}
|
|
2441
|
+
credential.isValid = true;
|
|
2442
|
+
credential.invalidReason = void 0;
|
|
2443
|
+
entry.lastAccessAt = Date.now();
|
|
2444
|
+
await this.storage.set(this.key(vaultId), entry);
|
|
2445
|
+
}
|
|
2446
|
+
// ============================================
|
|
2447
|
+
// Cleanup
|
|
2448
|
+
// ============================================
|
|
2449
|
+
async cleanup() {
|
|
2450
|
+
const keys = await this.storage.keys("*");
|
|
2451
|
+
const now = Date.now();
|
|
2452
|
+
for (const key of keys) {
|
|
2453
|
+
const entry = await this.storage.get(key);
|
|
2454
|
+
if (!entry) continue;
|
|
2455
|
+
const originalLength = entry.pendingAuths.length;
|
|
2456
|
+
entry.pendingAuths = entry.pendingAuths.filter((p) => {
|
|
2457
|
+
if (now > p.expiresAt && p.status === "pending") {
|
|
2458
|
+
p.status = "expired";
|
|
2459
|
+
}
|
|
2460
|
+
return p.status === "pending";
|
|
2461
|
+
});
|
|
2462
|
+
if (entry.pendingAuths.length !== originalLength) {
|
|
2463
|
+
await this.storage.set(key, entry);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
// ============================================
|
|
2468
|
+
// Helpers
|
|
2469
|
+
// ============================================
|
|
2470
|
+
/**
|
|
2471
|
+
* Build the storage key for a vault ID.
|
|
2472
|
+
* For non-namespaced storage, includes the namespace prefix.
|
|
2473
|
+
*/
|
|
2474
|
+
key(id) {
|
|
2475
|
+
return this.isNamespacedStorage(this.storage.raw) ? id : `${this.namespace}:${id}`;
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Type guard to check if storage is a NamespacedStorage.
|
|
2479
|
+
*/
|
|
2480
|
+
isNamespacedStorage(storage) {
|
|
2481
|
+
return "namespace" in storage && typeof storage.namespace === "function";
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
|
|
2485
|
+
// libs/auth/src/session/storage/in-memory-authorization-vault.ts
|
|
2486
|
+
import { MemoryStorageAdapter } from "@frontmcp/utils";
|
|
2487
|
+
var InMemoryAuthorizationVault = class extends StorageAuthorizationVault {
|
|
2488
|
+
memoryAdapter;
|
|
2489
|
+
constructor(options = {}) {
|
|
2490
|
+
const memoryAdapter = new MemoryStorageAdapter();
|
|
2491
|
+
super(memoryAdapter, {
|
|
2492
|
+
namespace: options.namespace ?? "vault",
|
|
2493
|
+
pendingAuthTtlMs: options.pendingAuthTtlMs
|
|
2494
|
+
});
|
|
2495
|
+
this.memoryAdapter = memoryAdapter;
|
|
2496
|
+
void this.memoryAdapter.connect();
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Clear all stored data.
|
|
2500
|
+
* Useful for testing.
|
|
2501
|
+
*/
|
|
2502
|
+
async clear() {
|
|
2503
|
+
const keys = await this.memoryAdapter.keys("*");
|
|
2504
|
+
for (const key of keys) {
|
|
2505
|
+
await this.memoryAdapter.delete(key);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
// libs/auth/src/authorization/authorization.types.ts
|
|
2511
|
+
import { z as z5 } from "zod";
|
|
2512
|
+
var authModeSchema = z5.enum(["public", "transparent", "orchestrated"]);
|
|
2513
|
+
var authUserSchema = z5.object({
|
|
2514
|
+
sub: z5.string(),
|
|
2515
|
+
name: z5.string().optional(),
|
|
2516
|
+
email: z5.string().email().optional(),
|
|
2517
|
+
picture: z5.string().url().optional(),
|
|
2518
|
+
anonymous: z5.boolean().optional()
|
|
2519
|
+
});
|
|
2520
|
+
var authorizedToolSchema = z5.object({
|
|
2521
|
+
executionPath: z5.tuple([z5.string(), z5.string()]),
|
|
2522
|
+
scopes: z5.array(z5.string()).optional(),
|
|
2523
|
+
details: z5.record(z5.string(), z5.unknown()).optional()
|
|
2524
|
+
});
|
|
2525
|
+
var authorizedPromptSchema = z5.object({
|
|
2526
|
+
executionPath: z5.tuple([z5.string(), z5.string()]),
|
|
2527
|
+
scopes: z5.array(z5.string()).optional(),
|
|
2528
|
+
details: z5.record(z5.string(), z5.unknown()).optional()
|
|
2529
|
+
});
|
|
2530
|
+
var llmSafeAuthContextSchema = z5.object({
|
|
2531
|
+
authorizationId: z5.string(),
|
|
2532
|
+
sessionId: z5.string(),
|
|
2533
|
+
mode: authModeSchema,
|
|
2534
|
+
isAnonymous: z5.boolean(),
|
|
2535
|
+
user: z5.object({
|
|
2536
|
+
sub: z5.string(),
|
|
2537
|
+
name: z5.string().optional()
|
|
2538
|
+
}),
|
|
2539
|
+
scopes: z5.array(z5.string()),
|
|
2540
|
+
authorizedToolIds: z5.array(z5.string()),
|
|
2541
|
+
authorizedPromptIds: z5.array(z5.string())
|
|
2542
|
+
});
|
|
2543
|
+
var AppAuthState = /* @__PURE__ */ ((AppAuthState2) => {
|
|
2544
|
+
AppAuthState2["AUTHORIZED"] = "authorized";
|
|
2545
|
+
AppAuthState2["SKIPPED"] = "skipped";
|
|
2546
|
+
AppAuthState2["PENDING"] = "pending";
|
|
2547
|
+
return AppAuthState2;
|
|
2548
|
+
})(AppAuthState || {});
|
|
2549
|
+
var appAuthStateSchema = z5.nativeEnum(AppAuthState);
|
|
2550
|
+
var appAuthorizationRecordSchema = z5.object({
|
|
2551
|
+
appId: z5.string(),
|
|
2552
|
+
state: appAuthStateSchema,
|
|
2553
|
+
stateChangedAt: z5.number(),
|
|
2554
|
+
grantedScopes: z5.array(z5.string()).optional(),
|
|
2555
|
+
authProviderId: z5.string().optional(),
|
|
2556
|
+
toolIds: z5.array(z5.string())
|
|
2557
|
+
});
|
|
2558
|
+
var progressiveAuthStateSchema = z5.object({
|
|
2559
|
+
apps: z5.record(z5.string(), appAuthorizationRecordSchema),
|
|
2560
|
+
initiallyAuthorized: z5.array(z5.string()),
|
|
2561
|
+
initiallySkipped: z5.array(z5.string())
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
// libs/auth/src/utils/www-authenticate.utils.ts
|
|
2565
|
+
function buildWwwAuthenticate(options = {}) {
|
|
2566
|
+
const parts = ["Bearer"];
|
|
2567
|
+
const params = [];
|
|
2568
|
+
if (options.resourceMetadataUrl) {
|
|
2569
|
+
params.push(`resource_metadata="${escapeQuotedString(options.resourceMetadataUrl)}"`);
|
|
2570
|
+
}
|
|
2571
|
+
if (options.realm) {
|
|
2572
|
+
params.push(`realm="${escapeQuotedString(options.realm)}"`);
|
|
2573
|
+
}
|
|
2574
|
+
if (options.error) {
|
|
2575
|
+
params.push(`error="${options.error}"`);
|
|
2576
|
+
}
|
|
2577
|
+
if (options.errorDescription) {
|
|
2578
|
+
params.push(`error_description="${escapeQuotedString(options.errorDescription)}"`);
|
|
2579
|
+
}
|
|
2580
|
+
if (options.errorUri) {
|
|
2581
|
+
params.push(`error_uri="${escapeQuotedString(options.errorUri)}"`);
|
|
2582
|
+
}
|
|
2583
|
+
if (options.scope) {
|
|
2584
|
+
const scopeValue = Array.isArray(options.scope) ? options.scope.join(" ") : options.scope;
|
|
2585
|
+
params.push(`scope="${escapeQuotedString(scopeValue)}"`);
|
|
2586
|
+
}
|
|
2587
|
+
if (params.length > 0) {
|
|
2588
|
+
parts.push(params.join(", "));
|
|
2589
|
+
}
|
|
2590
|
+
return parts.join(" ");
|
|
2591
|
+
}
|
|
2592
|
+
function buildPrmUrl(baseUrl, entryPath, routeBase) {
|
|
2593
|
+
const normalizedEntry = normalizePathSegment(entryPath);
|
|
2594
|
+
const normalizedRoute = normalizePathSegment(routeBase);
|
|
2595
|
+
return `${baseUrl}/.well-known/oauth-protected-resource${normalizedEntry}${normalizedRoute}`;
|
|
2596
|
+
}
|
|
2597
|
+
function buildUnauthorizedHeader(prmUrl) {
|
|
2598
|
+
return buildWwwAuthenticate({
|
|
2599
|
+
resourceMetadataUrl: prmUrl
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
function buildInvalidTokenHeader(prmUrl, description) {
|
|
2603
|
+
return buildWwwAuthenticate({
|
|
2604
|
+
resourceMetadataUrl: prmUrl,
|
|
2605
|
+
error: "invalid_token",
|
|
2606
|
+
errorDescription: description ?? "The access token is invalid or expired"
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
function buildInsufficientScopeHeader(prmUrl, requiredScopes, description) {
|
|
2610
|
+
return buildWwwAuthenticate({
|
|
2611
|
+
resourceMetadataUrl: prmUrl,
|
|
2612
|
+
error: "insufficient_scope",
|
|
2613
|
+
scope: requiredScopes,
|
|
2614
|
+
errorDescription: description ?? "The request requires higher privileges"
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
function buildInvalidRequestHeader(prmUrl, description) {
|
|
2618
|
+
return buildWwwAuthenticate({
|
|
2619
|
+
resourceMetadataUrl: prmUrl,
|
|
2620
|
+
error: "invalid_request",
|
|
2621
|
+
errorDescription: description ?? "The request is missing required parameters"
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
function parseWwwAuthenticate(header) {
|
|
2625
|
+
const result = {};
|
|
2626
|
+
if (!header.toLowerCase().startsWith("bearer")) {
|
|
2627
|
+
return result;
|
|
2628
|
+
}
|
|
2629
|
+
const paramString = header.substring(6).trim();
|
|
2630
|
+
const paramRegex = /(\w+)="([^"\\]*(?:\\.[^"\\]*)*)"/g;
|
|
2631
|
+
let match;
|
|
2632
|
+
while ((match = paramRegex.exec(paramString)) !== null) {
|
|
2633
|
+
const [, key, value] = match;
|
|
2634
|
+
const unescapedValue = unescapeQuotedString(value);
|
|
2635
|
+
switch (key.toLowerCase()) {
|
|
2636
|
+
case "resource_metadata":
|
|
2637
|
+
result.resourceMetadataUrl = unescapedValue;
|
|
2638
|
+
break;
|
|
2639
|
+
case "realm":
|
|
2640
|
+
result.realm = unescapedValue;
|
|
2641
|
+
break;
|
|
2642
|
+
case "error":
|
|
2643
|
+
result.error = unescapedValue;
|
|
2644
|
+
break;
|
|
2645
|
+
case "error_description":
|
|
2646
|
+
result.errorDescription = unescapedValue;
|
|
2647
|
+
break;
|
|
2648
|
+
case "error_uri":
|
|
2649
|
+
result.errorUri = unescapedValue;
|
|
2650
|
+
break;
|
|
2651
|
+
case "scope":
|
|
2652
|
+
result.scope = unescapedValue;
|
|
2653
|
+
break;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return result;
|
|
2657
|
+
}
|
|
2658
|
+
function escapeQuotedString(value) {
|
|
2659
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2660
|
+
}
|
|
2661
|
+
function unescapeQuotedString(value) {
|
|
2662
|
+
return value.replace(/\\(.)/g, "$1");
|
|
2663
|
+
}
|
|
2664
|
+
function normalizePathSegment(path2) {
|
|
2665
|
+
if (!path2 || path2 === "/") return "";
|
|
2666
|
+
const normalized = path2.startsWith("/") ? path2 : `/${path2}`;
|
|
2667
|
+
return normalized.replace(/\/+$/, "");
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// libs/auth/src/utils/audience.validator.ts
|
|
2671
|
+
function validateAudience(tokenAudience, options) {
|
|
2672
|
+
const { expectedAudiences, allowNoAudience = false, caseSensitive = true, allowWildcards = false } = options;
|
|
2673
|
+
if (tokenAudience === void 0 || tokenAudience === null) {
|
|
2674
|
+
if (allowNoAudience) {
|
|
2675
|
+
return { valid: true };
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
valid: false,
|
|
2679
|
+
error: "Token is missing audience claim"
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
if (expectedAudiences.length === 0) {
|
|
2683
|
+
return {
|
|
2684
|
+
valid: false,
|
|
2685
|
+
error: "No expected audiences configured - cannot validate token"
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
const tokenAuds = Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience];
|
|
2689
|
+
for (const tokenAud of tokenAuds) {
|
|
2690
|
+
for (const expectedAud of expectedAudiences) {
|
|
2691
|
+
if (matchesAudience(tokenAud, expectedAud, caseSensitive, allowWildcards)) {
|
|
2692
|
+
return { valid: true, matchedAudience: tokenAud };
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
return {
|
|
2697
|
+
valid: false,
|
|
2698
|
+
error: `Token audience does not match expected audiences. Got: ${tokenAuds.join(
|
|
2699
|
+
", "
|
|
2700
|
+
)}. Expected one of: ${expectedAudiences.join(", ")}`
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function matchesAudience(tokenAud, expectedAud, caseSensitive, allowWildcards) {
|
|
2704
|
+
if (caseSensitive) {
|
|
2705
|
+
if (tokenAud === expectedAud) return true;
|
|
2706
|
+
} else {
|
|
2707
|
+
if (tokenAud.toLowerCase() === expectedAud.toLowerCase()) return true;
|
|
2708
|
+
}
|
|
2709
|
+
if (allowWildcards && expectedAud.includes("*")) {
|
|
2710
|
+
const wildcardCount = (expectedAud.match(/\*/g) || []).length;
|
|
2711
|
+
if (wildcardCount > 2) {
|
|
2712
|
+
return false;
|
|
2713
|
+
}
|
|
2714
|
+
const pattern = expectedAud.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.]*");
|
|
2715
|
+
const regex = new RegExp(`^${pattern}$`, caseSensitive ? "" : "i");
|
|
2716
|
+
if (regex.test(tokenAud)) return true;
|
|
2717
|
+
}
|
|
2718
|
+
return false;
|
|
2719
|
+
}
|
|
2720
|
+
function createAudienceValidator(options) {
|
|
2721
|
+
return (audience) => validateAudience(audience, options);
|
|
2722
|
+
}
|
|
2723
|
+
function deriveExpectedAudience(resourceUrl) {
|
|
2724
|
+
const audiences = [];
|
|
2725
|
+
try {
|
|
2726
|
+
const url = new URL(resourceUrl);
|
|
2727
|
+
audiences.push(resourceUrl.replace(/\/$/, ""));
|
|
2728
|
+
if (url.pathname !== "/" && url.pathname !== "") {
|
|
2729
|
+
audiences.push(url.origin);
|
|
2730
|
+
}
|
|
2731
|
+
audiences.push(url.host);
|
|
2732
|
+
} catch {
|
|
2733
|
+
audiences.push(resourceUrl);
|
|
2734
|
+
}
|
|
2735
|
+
return audiences;
|
|
2736
|
+
}
|
|
2737
|
+
var AudienceValidator = class _AudienceValidator {
|
|
2738
|
+
options;
|
|
2739
|
+
constructor(options = {}) {
|
|
2740
|
+
this.options = {
|
|
2741
|
+
expectedAudiences: [...options.expectedAudiences ?? []],
|
|
2742
|
+
allowNoAudience: options.allowNoAudience ?? false,
|
|
2743
|
+
caseSensitive: options.caseSensitive ?? true,
|
|
2744
|
+
allowWildcards: options.allowWildcards ?? false
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Validate an audience claim
|
|
2749
|
+
*/
|
|
2750
|
+
validate(audience) {
|
|
2751
|
+
return validateAudience(audience, this.options);
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Add expected audiences
|
|
2755
|
+
*/
|
|
2756
|
+
addAudiences(...audiences) {
|
|
2757
|
+
this.options.expectedAudiences.push(...audiences);
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Set expected audiences (replace existing)
|
|
2761
|
+
*/
|
|
2762
|
+
setAudiences(audiences) {
|
|
2763
|
+
this.options.expectedAudiences = audiences;
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Create validator from resource URL
|
|
2767
|
+
*/
|
|
2768
|
+
static fromResourceUrl(resourceUrl, options = {}) {
|
|
2769
|
+
return new _AudienceValidator({
|
|
2770
|
+
...options,
|
|
2771
|
+
expectedAudiences: deriveExpectedAudience(resourceUrl)
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
|
|
2776
|
+
// libs/auth/src/vault/auth-providers.types.ts
|
|
2777
|
+
import { z as z6 } from "zod";
|
|
2778
|
+
var credentialScopeSchema = z6.enum(["global", "user", "session"]);
|
|
2779
|
+
var loadingStrategySchema = z6.enum(["eager", "lazy"]);
|
|
2780
|
+
var getCredentialOptionsSchema = z6.object({
|
|
2781
|
+
forceRefresh: z6.boolean().optional(),
|
|
2782
|
+
scopes: z6.array(z6.string()).optional(),
|
|
2783
|
+
timeout: z6.number().positive().optional()
|
|
2784
|
+
}).strict();
|
|
2785
|
+
var credentialProviderConfigSchema = z6.object({
|
|
2786
|
+
name: z6.string().min(1),
|
|
2787
|
+
description: z6.string().optional(),
|
|
2788
|
+
scope: credentialScopeSchema,
|
|
2789
|
+
loading: loadingStrategySchema,
|
|
2790
|
+
cacheTtl: z6.number().nonnegative().optional(),
|
|
2791
|
+
// Functions validated at runtime, not via Zod (Zod 4 compatibility)
|
|
2792
|
+
factory: z6.any(),
|
|
2793
|
+
refresh: z6.any().optional(),
|
|
2794
|
+
toHeaders: z6.any().optional(),
|
|
2795
|
+
metadata: z6.record(z6.string(), z6.unknown()).optional(),
|
|
2796
|
+
required: z6.boolean().optional()
|
|
2797
|
+
}).strict();
|
|
2798
|
+
var authProviderMappingSchema = z6.union([
|
|
2799
|
+
z6.string(),
|
|
2800
|
+
z6.object({
|
|
2801
|
+
name: z6.string().min(1),
|
|
2802
|
+
required: z6.boolean().optional().default(true),
|
|
2803
|
+
scopes: z6.array(z6.string()).optional(),
|
|
2804
|
+
alias: z6.string().optional()
|
|
2805
|
+
}).strict()
|
|
2806
|
+
]);
|
|
2807
|
+
var authProvidersVaultOptionsSchema = z6.object({
|
|
2808
|
+
enabled: z6.boolean().optional(),
|
|
2809
|
+
useSharedStorage: z6.boolean().optional().default(true),
|
|
2810
|
+
namespace: z6.string().optional().default("authproviders:"),
|
|
2811
|
+
defaultCacheTtl: z6.number().nonnegative().optional().default(36e5),
|
|
2812
|
+
maxCredentialsPerSession: z6.number().positive().optional().default(100),
|
|
2813
|
+
providers: z6.array(credentialProviderConfigSchema).optional()
|
|
2814
|
+
}).strict();
|
|
2815
|
+
|
|
2816
|
+
// libs/auth/src/vault/credential-helpers.ts
|
|
2817
|
+
function extractCredentialExpiry(credential) {
|
|
2818
|
+
switch (credential.type) {
|
|
2819
|
+
case "oauth":
|
|
2820
|
+
case "oauth_pkce":
|
|
2821
|
+
case "bearer":
|
|
2822
|
+
case "service_account":
|
|
2823
|
+
return credential.expiresAt;
|
|
2824
|
+
default:
|
|
2825
|
+
return void 0;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// libs/auth/src/vault/credential-cache.ts
|
|
2830
|
+
var CredentialCache = class {
|
|
2831
|
+
cache = /* @__PURE__ */ new Map();
|
|
2832
|
+
maxSize;
|
|
2833
|
+
stats = { hits: 0, misses: 0, evictions: 0, size: 0 };
|
|
2834
|
+
constructor(maxSize = 100) {
|
|
2835
|
+
this.maxSize = maxSize;
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Get a cached credential
|
|
2839
|
+
*
|
|
2840
|
+
* @param providerId - Provider name
|
|
2841
|
+
* @returns Resolved credential or undefined if not cached or expired
|
|
2842
|
+
*/
|
|
2843
|
+
get(providerId) {
|
|
2844
|
+
const entry = this.cache.get(providerId);
|
|
2845
|
+
if (!entry) {
|
|
2846
|
+
this.stats.misses++;
|
|
2847
|
+
return void 0;
|
|
2848
|
+
}
|
|
2849
|
+
if (this.isExpired(entry)) {
|
|
2850
|
+
this.cache.delete(providerId);
|
|
2851
|
+
this.stats.size = this.cache.size;
|
|
2852
|
+
this.stats.misses++;
|
|
2853
|
+
this.stats.evictions++;
|
|
2854
|
+
return void 0;
|
|
2855
|
+
}
|
|
2856
|
+
if (entry.resolved.expiresAt && Date.now() >= entry.resolved.expiresAt) {
|
|
2857
|
+
this.cache.delete(providerId);
|
|
2858
|
+
this.stats.size = this.cache.size;
|
|
2859
|
+
this.stats.misses++;
|
|
2860
|
+
this.stats.evictions++;
|
|
2861
|
+
return void 0;
|
|
2862
|
+
}
|
|
2863
|
+
this.stats.hits++;
|
|
2864
|
+
return entry.resolved;
|
|
2865
|
+
}
|
|
2866
|
+
/**
|
|
2867
|
+
* Cache a resolved credential
|
|
2868
|
+
*
|
|
2869
|
+
* @param providerId - Provider name
|
|
2870
|
+
* @param resolved - Resolved credential to cache
|
|
2871
|
+
* @param ttl - TTL in milliseconds (0 = no TTL, rely on credential expiry)
|
|
2872
|
+
*/
|
|
2873
|
+
set(providerId, resolved, ttl = 0) {
|
|
2874
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(providerId)) {
|
|
2875
|
+
this.evictOldest();
|
|
2876
|
+
}
|
|
2877
|
+
const entry = {
|
|
2878
|
+
resolved,
|
|
2879
|
+
cachedAt: Date.now(),
|
|
2880
|
+
ttl
|
|
2881
|
+
};
|
|
2882
|
+
this.cache.set(providerId, entry);
|
|
2883
|
+
this.stats.size = this.cache.size;
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* Check if a credential is cached and valid
|
|
2887
|
+
*
|
|
2888
|
+
* @param providerId - Provider name
|
|
2889
|
+
* @returns true if cached and not expired
|
|
2890
|
+
*/
|
|
2891
|
+
has(providerId) {
|
|
2892
|
+
const entry = this.cache.get(providerId);
|
|
2893
|
+
if (!entry) return false;
|
|
2894
|
+
if (this.isExpired(entry)) {
|
|
2895
|
+
this.cache.delete(providerId);
|
|
2896
|
+
this.stats.size = this.cache.size;
|
|
2897
|
+
this.stats.evictions++;
|
|
2898
|
+
return false;
|
|
2899
|
+
}
|
|
2900
|
+
return true;
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Invalidate (remove) a cached credential
|
|
2904
|
+
*
|
|
2905
|
+
* @param providerId - Provider name to invalidate
|
|
2906
|
+
* @returns true if credential was removed
|
|
2907
|
+
*/
|
|
2908
|
+
invalidate(providerId) {
|
|
2909
|
+
const deleted = this.cache.delete(providerId);
|
|
2910
|
+
if (deleted) {
|
|
2911
|
+
this.stats.size = this.cache.size;
|
|
2912
|
+
}
|
|
2913
|
+
return deleted;
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Invalidate all cached credentials
|
|
2917
|
+
*/
|
|
2918
|
+
invalidateAll() {
|
|
2919
|
+
this.cache.clear();
|
|
2920
|
+
this.stats.size = 0;
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Invalidate credentials by scope
|
|
2924
|
+
*
|
|
2925
|
+
* @param scope - Credential scope to invalidate
|
|
2926
|
+
*/
|
|
2927
|
+
invalidateByScope(scope) {
|
|
2928
|
+
for (const [key, entry] of this.cache) {
|
|
2929
|
+
if (entry.resolved.scope === scope) {
|
|
2930
|
+
this.cache.delete(key);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
this.stats.size = this.cache.size;
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Get all cached provider IDs
|
|
2937
|
+
*/
|
|
2938
|
+
keys() {
|
|
2939
|
+
this.cleanup();
|
|
2940
|
+
return [...this.cache.keys()];
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Get cache size
|
|
2944
|
+
*/
|
|
2945
|
+
get size() {
|
|
2946
|
+
return this.cache.size;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Get cache statistics
|
|
2950
|
+
*/
|
|
2951
|
+
getStats() {
|
|
2952
|
+
return { ...this.stats };
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Reset cache statistics
|
|
2956
|
+
*/
|
|
2957
|
+
resetStats() {
|
|
2958
|
+
this.stats = { hits: 0, misses: 0, evictions: 0, size: this.cache.size };
|
|
2959
|
+
}
|
|
2960
|
+
/**
|
|
2961
|
+
* Clean up expired entries
|
|
2962
|
+
*/
|
|
2963
|
+
cleanup() {
|
|
2964
|
+
const now = Date.now();
|
|
2965
|
+
for (const [key, entry] of this.cache) {
|
|
2966
|
+
if (this.isExpiredAt(entry, now)) {
|
|
2967
|
+
this.cache.delete(key);
|
|
2968
|
+
this.stats.evictions++;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
this.stats.size = this.cache.size;
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Check if entry is expired based on TTL
|
|
2975
|
+
*/
|
|
2976
|
+
isExpired(entry) {
|
|
2977
|
+
return this.isExpiredAt(entry, Date.now());
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Check if entry is expired at a given timestamp
|
|
2981
|
+
*/
|
|
2982
|
+
isExpiredAt(entry, now) {
|
|
2983
|
+
if (entry.ttl > 0 && now - entry.cachedAt >= entry.ttl) {
|
|
2984
|
+
return true;
|
|
2985
|
+
}
|
|
2986
|
+
if (entry.resolved.expiresAt && now >= entry.resolved.expiresAt) {
|
|
2987
|
+
return true;
|
|
2988
|
+
}
|
|
2989
|
+
if (!entry.resolved.isValid) {
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
return false;
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Evict the oldest entry from cache
|
|
2996
|
+
*/
|
|
2997
|
+
evictOldest() {
|
|
2998
|
+
let oldestKey;
|
|
2999
|
+
let oldestTime = Infinity;
|
|
3000
|
+
for (const [key, entry] of this.cache) {
|
|
3001
|
+
if (entry.cachedAt < oldestTime) {
|
|
3002
|
+
oldestTime = entry.cachedAt;
|
|
3003
|
+
oldestKey = key;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (oldestKey) {
|
|
3007
|
+
this.cache.delete(oldestKey);
|
|
3008
|
+
this.stats.evictions++;
|
|
3009
|
+
this.stats.size = this.cache.size;
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
|
|
3014
|
+
// libs/auth/src/cimd/cimd.logger.ts
|
|
3015
|
+
var noopLogger = {
|
|
3016
|
+
child: () => noopLogger,
|
|
3017
|
+
debug: () => {
|
|
3018
|
+
},
|
|
3019
|
+
info: () => {
|
|
3020
|
+
},
|
|
3021
|
+
warn: () => {
|
|
3022
|
+
},
|
|
3023
|
+
error: () => {
|
|
3024
|
+
}
|
|
3025
|
+
};
|
|
3026
|
+
|
|
3027
|
+
// libs/auth/src/cimd/cimd.types.ts
|
|
3028
|
+
import { z as z7 } from "zod";
|
|
3029
|
+
var clientMetadataDocumentSchema = z7.object({
|
|
3030
|
+
// REQUIRED per CIMD spec
|
|
3031
|
+
/**
|
|
3032
|
+
* Client identifier - MUST match the URL from which this document was fetched.
|
|
3033
|
+
*/
|
|
3034
|
+
client_id: z7.string().url(),
|
|
3035
|
+
/**
|
|
3036
|
+
* Human-readable name of the client.
|
|
3037
|
+
*/
|
|
3038
|
+
client_name: z7.string().min(1),
|
|
3039
|
+
/**
|
|
3040
|
+
* Array of redirect URIs for authorization responses.
|
|
3041
|
+
* At least one is required.
|
|
3042
|
+
*/
|
|
3043
|
+
redirect_uris: z7.array(z7.string().url()).min(1),
|
|
3044
|
+
// OPTIONAL per RFC 7591
|
|
3045
|
+
/**
|
|
3046
|
+
* Token endpoint authentication method.
|
|
3047
|
+
* @default 'none'
|
|
3048
|
+
*/
|
|
3049
|
+
token_endpoint_auth_method: z7.enum(["none", "client_secret_basic", "client_secret_post", "private_key_jwt"]).default("none"),
|
|
3050
|
+
/**
|
|
3051
|
+
* OAuth grant types the client can use.
|
|
3052
|
+
* @default ['authorization_code']
|
|
3053
|
+
*/
|
|
3054
|
+
grant_types: z7.array(z7.string()).default(["authorization_code"]),
|
|
3055
|
+
/**
|
|
3056
|
+
* OAuth response types the client can request.
|
|
3057
|
+
* @default ['code']
|
|
3058
|
+
*/
|
|
3059
|
+
response_types: z7.array(z7.string()).default(["code"]),
|
|
3060
|
+
/**
|
|
3061
|
+
* URL of the client's home page.
|
|
3062
|
+
*/
|
|
3063
|
+
client_uri: z7.string().url().optional(),
|
|
3064
|
+
/**
|
|
3065
|
+
* URL of the client's logo image.
|
|
3066
|
+
*/
|
|
3067
|
+
logo_uri: z7.string().url().optional(),
|
|
3068
|
+
/**
|
|
3069
|
+
* URL of the client's JWKS (for private_key_jwt).
|
|
3070
|
+
*/
|
|
3071
|
+
jwks_uri: z7.string().url().optional(),
|
|
3072
|
+
/**
|
|
3073
|
+
* Inline JWKS (for private_key_jwt).
|
|
3074
|
+
*/
|
|
3075
|
+
jwks: z7.object({
|
|
3076
|
+
keys: z7.array(z7.record(z7.string(), z7.unknown()))
|
|
3077
|
+
}).optional(),
|
|
3078
|
+
/**
|
|
3079
|
+
* URL of the client's terms of service.
|
|
3080
|
+
*/
|
|
3081
|
+
tos_uri: z7.string().url().optional(),
|
|
3082
|
+
/**
|
|
3083
|
+
* URL of the client's privacy policy.
|
|
3084
|
+
*/
|
|
3085
|
+
policy_uri: z7.string().url().optional(),
|
|
3086
|
+
/**
|
|
3087
|
+
* Requested OAuth scopes.
|
|
3088
|
+
*/
|
|
3089
|
+
scope: z7.string().optional(),
|
|
3090
|
+
/**
|
|
3091
|
+
* Array of contact emails for the client.
|
|
3092
|
+
*/
|
|
3093
|
+
contacts: z7.array(z7.string().email()).optional(),
|
|
3094
|
+
/**
|
|
3095
|
+
* Software statement (signed JWT).
|
|
3096
|
+
*/
|
|
3097
|
+
software_statement: z7.string().optional(),
|
|
3098
|
+
/**
|
|
3099
|
+
* Unique identifier for the client software.
|
|
3100
|
+
*/
|
|
3101
|
+
software_id: z7.string().optional(),
|
|
3102
|
+
/**
|
|
3103
|
+
* Version of the client software.
|
|
3104
|
+
*/
|
|
3105
|
+
software_version: z7.string().optional()
|
|
3106
|
+
});
|
|
3107
|
+
var cimdRedisCacheConfigSchema = z7.object({
|
|
3108
|
+
/**
|
|
3109
|
+
* Redis connection URL.
|
|
3110
|
+
* e.g., "redis://user:pass@host:6379/0"
|
|
3111
|
+
*/
|
|
3112
|
+
url: z7.string().optional(),
|
|
3113
|
+
/**
|
|
3114
|
+
* Redis host.
|
|
3115
|
+
*/
|
|
3116
|
+
host: z7.string().optional(),
|
|
3117
|
+
/**
|
|
3118
|
+
* Redis port.
|
|
3119
|
+
* @default 6379
|
|
3120
|
+
*/
|
|
3121
|
+
port: z7.number().optional(),
|
|
3122
|
+
/**
|
|
3123
|
+
* Redis password.
|
|
3124
|
+
*/
|
|
3125
|
+
password: z7.string().optional(),
|
|
3126
|
+
/**
|
|
3127
|
+
* Redis database number.
|
|
3128
|
+
* @default 0
|
|
3129
|
+
*/
|
|
3130
|
+
db: z7.number().optional(),
|
|
3131
|
+
/**
|
|
3132
|
+
* Enable TLS for Redis connection.
|
|
3133
|
+
* @default false
|
|
3134
|
+
*/
|
|
3135
|
+
tls: z7.boolean().optional(),
|
|
3136
|
+
/**
|
|
3137
|
+
* Key prefix for CIMD cache entries.
|
|
3138
|
+
* @default 'cimd:'
|
|
3139
|
+
*/
|
|
3140
|
+
keyPrefix: z7.string().default("cimd:")
|
|
3141
|
+
});
|
|
3142
|
+
var cimdCacheConfigSchema = z7.object({
|
|
3143
|
+
/**
|
|
3144
|
+
* Cache storage type.
|
|
3145
|
+
* - 'memory': In-memory cache (default, suitable for dev/single-instance)
|
|
3146
|
+
* - 'redis': Redis-backed cache (for production/distributed deployments)
|
|
3147
|
+
* @default 'memory'
|
|
3148
|
+
*/
|
|
3149
|
+
type: z7.enum(["memory", "redis"]).default("memory"),
|
|
3150
|
+
/**
|
|
3151
|
+
* Default TTL for cached metadata documents.
|
|
3152
|
+
* @default 3600000 (1 hour)
|
|
3153
|
+
*/
|
|
3154
|
+
defaultTtlMs: z7.number().min(0).default(36e5),
|
|
3155
|
+
/**
|
|
3156
|
+
* Maximum TTL (even if server suggests longer).
|
|
3157
|
+
* @default 86400000 (24 hours)
|
|
3158
|
+
*/
|
|
3159
|
+
maxTtlMs: z7.number().min(0).default(864e5),
|
|
3160
|
+
/**
|
|
3161
|
+
* Minimum TTL (even if server suggests shorter).
|
|
3162
|
+
* @default 60000 (1 minute)
|
|
3163
|
+
*/
|
|
3164
|
+
minTtlMs: z7.number().min(0).default(6e4),
|
|
3165
|
+
/**
|
|
3166
|
+
* Redis configuration (required when type is 'redis').
|
|
3167
|
+
*/
|
|
3168
|
+
redis: cimdRedisCacheConfigSchema.optional()
|
|
3169
|
+
});
|
|
3170
|
+
var cimdSecurityConfigSchema = z7.object({
|
|
3171
|
+
/**
|
|
3172
|
+
* Block fetching from private/internal IP addresses (SSRF protection).
|
|
3173
|
+
* @default true
|
|
3174
|
+
*/
|
|
3175
|
+
blockPrivateIPs: z7.boolean().default(true),
|
|
3176
|
+
/**
|
|
3177
|
+
* Explicit list of allowed domains.
|
|
3178
|
+
* If set, only these domains can host CIMD documents.
|
|
3179
|
+
*/
|
|
3180
|
+
allowedDomains: z7.array(z7.string()).optional(),
|
|
3181
|
+
/**
|
|
3182
|
+
* Explicit list of blocked domains.
|
|
3183
|
+
* These domains cannot host CIMD documents.
|
|
3184
|
+
*/
|
|
3185
|
+
blockedDomains: z7.array(z7.string()).optional(),
|
|
3186
|
+
/**
|
|
3187
|
+
* Warn when a client has only localhost redirect URIs.
|
|
3188
|
+
* @default true
|
|
3189
|
+
*/
|
|
3190
|
+
warnOnLocalhostRedirects: z7.boolean().default(true),
|
|
3191
|
+
/**
|
|
3192
|
+
* Allow HTTP (instead of HTTPS) for localhost CIMD URLs.
|
|
3193
|
+
*
|
|
3194
|
+
* **WARNING: This is for testing purposes only. Never enable in production!**
|
|
3195
|
+
*
|
|
3196
|
+
* When enabled, permits HTTP URLs for localhost CIMD client IDs during testing.
|
|
3197
|
+
* The CIMD spec requires HTTPS, but e2e test servers typically use HTTP.
|
|
3198
|
+
*
|
|
3199
|
+
* @default false
|
|
3200
|
+
*/
|
|
3201
|
+
allowInsecureForTesting: z7.boolean().default(false)
|
|
3202
|
+
});
|
|
3203
|
+
var cimdNetworkConfigSchema = z7.object({
|
|
3204
|
+
/**
|
|
3205
|
+
* Request timeout in milliseconds.
|
|
3206
|
+
* @default 5000 (5 seconds)
|
|
3207
|
+
*/
|
|
3208
|
+
timeoutMs: z7.number().min(100).default(5e3),
|
|
3209
|
+
/**
|
|
3210
|
+
* Maximum response body size in bytes.
|
|
3211
|
+
* @default 65536 (64KB)
|
|
3212
|
+
*/
|
|
3213
|
+
maxResponseSizeBytes: z7.number().min(1024).default(65536),
|
|
3214
|
+
/**
|
|
3215
|
+
* Redirect handling policy for CIMD fetches.
|
|
3216
|
+
* - 'deny': reject redirects (default, safest)
|
|
3217
|
+
* - 'same-origin': allow redirects only to the same origin
|
|
3218
|
+
* - 'allow': allow redirects to any origin
|
|
3219
|
+
* @default 'deny'
|
|
3220
|
+
*/
|
|
3221
|
+
redirectPolicy: z7.enum(["deny", "same-origin", "allow"]).default("deny"),
|
|
3222
|
+
/**
|
|
3223
|
+
* Maximum number of redirects to follow when redirects are allowed.
|
|
3224
|
+
* @default 5
|
|
3225
|
+
*/
|
|
3226
|
+
maxRedirects: z7.number().int().min(0).default(5)
|
|
3227
|
+
});
|
|
3228
|
+
var cimdConfigSchema = z7.object({
|
|
3229
|
+
/**
|
|
3230
|
+
* Enable CIMD support.
|
|
3231
|
+
* @default true
|
|
3232
|
+
*/
|
|
3233
|
+
enabled: z7.boolean().default(true),
|
|
3234
|
+
/**
|
|
3235
|
+
* Cache configuration.
|
|
3236
|
+
*/
|
|
3237
|
+
cache: cimdCacheConfigSchema.optional(),
|
|
3238
|
+
/**
|
|
3239
|
+
* Security configuration.
|
|
3240
|
+
*/
|
|
3241
|
+
security: cimdSecurityConfigSchema.optional(),
|
|
3242
|
+
/**
|
|
3243
|
+
* Network configuration.
|
|
3244
|
+
*/
|
|
3245
|
+
network: cimdNetworkConfigSchema.optional()
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
// libs/auth/src/cimd/cimd.errors.ts
|
|
3249
|
+
var CimdError = class extends Error {
|
|
3250
|
+
/**
|
|
3251
|
+
* Error code for machine-readable identification.
|
|
3252
|
+
*/
|
|
3253
|
+
code;
|
|
3254
|
+
/**
|
|
3255
|
+
* HTTP status code to return when this error occurs.
|
|
3256
|
+
*/
|
|
3257
|
+
statusCode;
|
|
3258
|
+
/**
|
|
3259
|
+
* The client_id URL that caused the error.
|
|
3260
|
+
*/
|
|
3261
|
+
clientIdUrl;
|
|
3262
|
+
constructor(message, code, statusCode, clientIdUrl) {
|
|
3263
|
+
super(message);
|
|
3264
|
+
this.name = this.constructor.name;
|
|
3265
|
+
this.code = code;
|
|
3266
|
+
this.statusCode = statusCode;
|
|
3267
|
+
this.clientIdUrl = clientIdUrl;
|
|
3268
|
+
if (Error.captureStackTrace) {
|
|
3269
|
+
Error.captureStackTrace(this, this.constructor);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Get a message safe to return to clients.
|
|
3274
|
+
* Override in subclasses to provide user-friendly messages.
|
|
3275
|
+
*/
|
|
3276
|
+
getPublicMessage() {
|
|
3277
|
+
return this.message;
|
|
3278
|
+
}
|
|
3279
|
+
};
|
|
3280
|
+
var InvalidClientIdUrlError = class extends CimdError {
|
|
3281
|
+
reason;
|
|
3282
|
+
constructor(clientIdUrl, reason) {
|
|
3283
|
+
super(`Invalid CIMD client_id URL: ${reason}`, "INVALID_CLIENT_ID_URL", 400, clientIdUrl);
|
|
3284
|
+
this.reason = reason;
|
|
3285
|
+
}
|
|
3286
|
+
getPublicMessage() {
|
|
3287
|
+
return `Invalid client_id URL: ${this.reason}`;
|
|
3288
|
+
}
|
|
3289
|
+
};
|
|
3290
|
+
var CimdFetchError = class extends CimdError {
|
|
3291
|
+
httpStatus;
|
|
3292
|
+
originalError;
|
|
3293
|
+
constructor(clientIdUrl, message, options) {
|
|
3294
|
+
super(`Failed to fetch CIMD document from ${clientIdUrl}: ${message}`, "CIMD_FETCH_ERROR", 502, clientIdUrl);
|
|
3295
|
+
this.httpStatus = options?.httpStatus;
|
|
3296
|
+
this.originalError = options?.originalError;
|
|
3297
|
+
}
|
|
3298
|
+
getPublicMessage() {
|
|
3299
|
+
if (this.httpStatus) {
|
|
3300
|
+
return `Failed to fetch client metadata: HTTP ${this.httpStatus}`;
|
|
3301
|
+
}
|
|
3302
|
+
return "Failed to fetch client metadata document";
|
|
3303
|
+
}
|
|
3304
|
+
};
|
|
3305
|
+
var CimdValidationError = class extends CimdError {
|
|
3306
|
+
validationErrors;
|
|
3307
|
+
constructor(clientIdUrl, errors) {
|
|
3308
|
+
super(`CIMD document validation failed: ${errors.join("; ")}`, "CIMD_VALIDATION_ERROR", 400, clientIdUrl);
|
|
3309
|
+
this.validationErrors = errors;
|
|
3310
|
+
}
|
|
3311
|
+
getPublicMessage() {
|
|
3312
|
+
return `Client metadata document validation failed: ${this.validationErrors.join("; ")}`;
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
var CimdClientIdMismatchError = class extends CimdError {
|
|
3316
|
+
documentClientId;
|
|
3317
|
+
constructor(urlClientId, documentClientId) {
|
|
3318
|
+
super(
|
|
3319
|
+
`CIMD client_id mismatch: URL is "${urlClientId}" but document contains "${documentClientId}"`,
|
|
3320
|
+
"CIMD_CLIENT_ID_MISMATCH",
|
|
3321
|
+
400,
|
|
3322
|
+
urlClientId
|
|
3323
|
+
);
|
|
3324
|
+
this.documentClientId = documentClientId;
|
|
3325
|
+
}
|
|
3326
|
+
getPublicMessage() {
|
|
3327
|
+
return "Client ID in metadata document does not match the request";
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
var CimdSecurityError = class extends CimdError {
|
|
3331
|
+
securityReason;
|
|
3332
|
+
constructor(clientIdUrl, reason) {
|
|
3333
|
+
super(`CIMD security check failed for ${clientIdUrl}: ${reason}`, "CIMD_SECURITY_ERROR", 403, clientIdUrl);
|
|
3334
|
+
this.securityReason = reason;
|
|
3335
|
+
}
|
|
3336
|
+
getPublicMessage() {
|
|
3337
|
+
return "Client ID URL is not allowed by security policy";
|
|
3338
|
+
}
|
|
3339
|
+
};
|
|
3340
|
+
var RedirectUriMismatchError = class extends CimdError {
|
|
3341
|
+
requestedRedirectUri;
|
|
3342
|
+
allowedRedirectUris;
|
|
3343
|
+
constructor(clientIdUrl, requestedRedirectUri, allowedRedirectUris) {
|
|
3344
|
+
super(
|
|
3345
|
+
`Redirect URI "${requestedRedirectUri}" is not registered for client "${clientIdUrl}"`,
|
|
3346
|
+
"REDIRECT_URI_MISMATCH",
|
|
3347
|
+
400,
|
|
3348
|
+
clientIdUrl
|
|
3349
|
+
);
|
|
3350
|
+
this.requestedRedirectUri = requestedRedirectUri;
|
|
3351
|
+
this.allowedRedirectUris = allowedRedirectUris;
|
|
3352
|
+
}
|
|
3353
|
+
getPublicMessage() {
|
|
3354
|
+
return "The redirect_uri is not registered for this client";
|
|
3355
|
+
}
|
|
3356
|
+
};
|
|
3357
|
+
var CimdResponseTooLargeError = class extends CimdError {
|
|
3358
|
+
maxBytes;
|
|
3359
|
+
actualBytes;
|
|
3360
|
+
constructor(clientIdUrl, maxBytes, actualBytes) {
|
|
3361
|
+
super(
|
|
3362
|
+
`CIMD response from ${clientIdUrl} exceeds maximum size of ${maxBytes} bytes${actualBytes ? ` (received ${actualBytes} bytes)` : ""}`,
|
|
3363
|
+
"CIMD_RESPONSE_TOO_LARGE",
|
|
3364
|
+
502,
|
|
3365
|
+
clientIdUrl
|
|
3366
|
+
);
|
|
3367
|
+
this.maxBytes = maxBytes;
|
|
3368
|
+
this.actualBytes = actualBytes;
|
|
3369
|
+
}
|
|
3370
|
+
getPublicMessage() {
|
|
3371
|
+
return "Client metadata document is too large";
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
var CimdDisabledError = class extends CimdError {
|
|
3375
|
+
constructor() {
|
|
3376
|
+
super("CIMD (Client ID Metadata Documents) is disabled on this server", "CIMD_DISABLED", 400);
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
|
|
3380
|
+
// libs/auth/src/cimd/cimd.validator.ts
|
|
3381
|
+
function isCimdClientId(clientId, allowInsecure = false) {
|
|
3382
|
+
if (!clientId || typeof clientId !== "string") {
|
|
3383
|
+
return false;
|
|
3384
|
+
}
|
|
3385
|
+
try {
|
|
3386
|
+
const url = new URL(clientId);
|
|
3387
|
+
if (url.protocol === "https:") {
|
|
3388
|
+
} else if (url.protocol === "http:" && allowInsecure && isLocalhostHost(url.hostname)) {
|
|
3389
|
+
} else {
|
|
3390
|
+
return false;
|
|
3391
|
+
}
|
|
3392
|
+
if (!url.pathname || url.pathname === "/") {
|
|
3393
|
+
return false;
|
|
3394
|
+
}
|
|
3395
|
+
return true;
|
|
3396
|
+
} catch {
|
|
3397
|
+
return false;
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
function isLocalhostHost(hostname) {
|
|
3401
|
+
const lower = hostname.toLowerCase();
|
|
3402
|
+
return lower === "localhost" || lower === "127.0.0.1" || lower === "[::1]" || lower.endsWith(".localhost");
|
|
3403
|
+
}
|
|
3404
|
+
function validateClientIdUrl(clientId, securityConfig) {
|
|
3405
|
+
if (!clientId || typeof clientId !== "string") {
|
|
3406
|
+
throw new InvalidClientIdUrlError(clientId || "", "client_id must be a non-empty string");
|
|
3407
|
+
}
|
|
3408
|
+
let url;
|
|
3409
|
+
try {
|
|
3410
|
+
url = new URL(clientId);
|
|
3411
|
+
} catch {
|
|
3412
|
+
throw new InvalidClientIdUrlError(clientId, "Invalid URL format");
|
|
3413
|
+
}
|
|
3414
|
+
const allowInsecure = securityConfig?.allowInsecureForTesting ?? false;
|
|
3415
|
+
if (url.protocol === "https:") {
|
|
3416
|
+
} else if (url.protocol === "http:" && allowInsecure && isLocalhostHost(url.hostname)) {
|
|
3417
|
+
} else {
|
|
3418
|
+
throw new InvalidClientIdUrlError(clientId, `CIMD requires HTTPS, got ${url.protocol.replace(":", "")}`);
|
|
3419
|
+
}
|
|
3420
|
+
if (!url.pathname || url.pathname === "/") {
|
|
3421
|
+
throw new InvalidClientIdUrlError(
|
|
3422
|
+
clientId,
|
|
3423
|
+
"CIMD client_id URL must have a path component (e.g., /oauth/client-metadata.json)"
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
const config = {
|
|
3427
|
+
blockPrivateIPs: securityConfig?.blockPrivateIPs ?? true,
|
|
3428
|
+
allowedDomains: securityConfig?.allowedDomains,
|
|
3429
|
+
blockedDomains: securityConfig?.blockedDomains
|
|
3430
|
+
};
|
|
3431
|
+
if (config.allowedDomains?.length) {
|
|
3432
|
+
if (!isDomainInList(url.hostname, config.allowedDomains)) {
|
|
3433
|
+
throw new CimdSecurityError(clientId, `Domain "${url.hostname}" is not in the allowed domains list`);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
if (config.blockedDomains?.length) {
|
|
3437
|
+
if (isDomainInList(url.hostname, config.blockedDomains)) {
|
|
3438
|
+
throw new CimdSecurityError(clientId, `Domain "${url.hostname}" is blocked`);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
if (config.blockPrivateIPs && !allowInsecure) {
|
|
3442
|
+
const ssrfCheck = checkSsrfProtection(url.hostname);
|
|
3443
|
+
if (!ssrfCheck.allowed) {
|
|
3444
|
+
throw new CimdSecurityError(clientId, ssrfCheck.reason);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return url;
|
|
3448
|
+
}
|
|
3449
|
+
function checkSsrfProtection(hostname) {
|
|
3450
|
+
const lowercaseHostname = hostname.toLowerCase();
|
|
3451
|
+
if (lowercaseHostname === "localhost" || lowercaseHostname === "localhost.localdomain" || lowercaseHostname.endsWith(".localhost")) {
|
|
3452
|
+
return { allowed: false, reason: "Localhost addresses are not allowed" };
|
|
3453
|
+
}
|
|
3454
|
+
if (isIpAddress(hostname)) {
|
|
3455
|
+
const ipCheck = checkIpAddress(hostname);
|
|
3456
|
+
if (!ipCheck.allowed) {
|
|
3457
|
+
return ipCheck;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
return { allowed: true, reason: "" };
|
|
3461
|
+
}
|
|
3462
|
+
function isIpAddress(hostname) {
|
|
3463
|
+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
3464
|
+
if (ipv4Pattern.test(hostname)) {
|
|
3465
|
+
return true;
|
|
3466
|
+
}
|
|
3467
|
+
const cleanHostname = hostname.replace(/^\[|\]$/g, "");
|
|
3468
|
+
if (cleanHostname.includes(":")) {
|
|
3469
|
+
return true;
|
|
3470
|
+
}
|
|
3471
|
+
return false;
|
|
3472
|
+
}
|
|
3473
|
+
function checkIpAddress(ip) {
|
|
3474
|
+
const cleanIp = ip.replace(/^\[|\]$/g, "");
|
|
3475
|
+
if (cleanIp.includes(".") && !cleanIp.includes(":")) {
|
|
3476
|
+
return checkIpv4(cleanIp);
|
|
3477
|
+
}
|
|
3478
|
+
return checkIpv6(cleanIp);
|
|
3479
|
+
}
|
|
3480
|
+
function checkIpv4(ip) {
|
|
3481
|
+
const parts = ip.split(".").map(Number);
|
|
3482
|
+
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) {
|
|
3483
|
+
return { allowed: false, reason: "Invalid IPv4 address" };
|
|
3484
|
+
}
|
|
3485
|
+
const [a, b, c, d] = parts;
|
|
3486
|
+
if (a === 127) {
|
|
3487
|
+
return { allowed: false, reason: "Loopback addresses (127.x.x.x) are not allowed" };
|
|
3488
|
+
}
|
|
3489
|
+
if (a === 10) {
|
|
3490
|
+
return { allowed: false, reason: "Private IP addresses (10.x.x.x) are not allowed" };
|
|
3491
|
+
}
|
|
3492
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
3493
|
+
return { allowed: false, reason: "Private IP addresses (172.16-31.x.x) are not allowed" };
|
|
3494
|
+
}
|
|
3495
|
+
if (a === 192 && b === 168) {
|
|
3496
|
+
return { allowed: false, reason: "Private IP addresses (192.168.x.x) are not allowed" };
|
|
3497
|
+
}
|
|
3498
|
+
if (a === 169 && b === 254) {
|
|
3499
|
+
return { allowed: false, reason: "Link-local addresses (169.254.x.x) are not allowed" };
|
|
3500
|
+
}
|
|
3501
|
+
if (a === 0) {
|
|
3502
|
+
return { allowed: false, reason: "Current network addresses (0.x.x.x) are not allowed" };
|
|
3503
|
+
}
|
|
3504
|
+
if (a === 255 && b === 255 && c === 255 && d === 255) {
|
|
3505
|
+
return { allowed: false, reason: "Broadcast address is not allowed" };
|
|
3506
|
+
}
|
|
3507
|
+
if (a >= 224 && a <= 239) {
|
|
3508
|
+
return { allowed: false, reason: "Multicast addresses are not allowed" };
|
|
3509
|
+
}
|
|
3510
|
+
return { allowed: true, reason: "" };
|
|
3511
|
+
}
|
|
3512
|
+
function checkIpv6(ip) {
|
|
3513
|
+
const normalizedIp = ip.toLowerCase();
|
|
3514
|
+
if (normalizedIp === "::1") {
|
|
3515
|
+
return { allowed: false, reason: "IPv6 loopback address (::1) is not allowed" };
|
|
3516
|
+
}
|
|
3517
|
+
if (normalizedIp === "::" || normalizedIp === "0:0:0:0:0:0:0:0") {
|
|
3518
|
+
return { allowed: false, reason: "IPv6 unspecified address (::) is not allowed" };
|
|
3519
|
+
}
|
|
3520
|
+
if (normalizedIp.startsWith("fe8") || normalizedIp.startsWith("fe9") || normalizedIp.startsWith("fea") || normalizedIp.startsWith("feb")) {
|
|
3521
|
+
return { allowed: false, reason: "IPv6 link-local addresses (fe80::/10) are not allowed" };
|
|
3522
|
+
}
|
|
3523
|
+
if (normalizedIp.startsWith("fc") || normalizedIp.startsWith("fd")) {
|
|
3524
|
+
return { allowed: false, reason: "IPv6 unique local addresses (fc00::/7) are not allowed" };
|
|
3525
|
+
}
|
|
3526
|
+
const ipv4MappedMatch = normalizedIp.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
3527
|
+
if (ipv4MappedMatch) {
|
|
3528
|
+
return checkIpv4(ipv4MappedMatch[1]);
|
|
3529
|
+
}
|
|
3530
|
+
return { allowed: true, reason: "" };
|
|
3531
|
+
}
|
|
3532
|
+
function isDomainInList(hostname, domainList) {
|
|
3533
|
+
const lowerHostname = hostname.toLowerCase();
|
|
3534
|
+
for (const domain of domainList) {
|
|
3535
|
+
const lowerDomain = domain.toLowerCase();
|
|
3536
|
+
if (lowerHostname === lowerDomain) {
|
|
3537
|
+
return true;
|
|
3538
|
+
}
|
|
3539
|
+
if (lowerHostname.endsWith("." + lowerDomain)) {
|
|
3540
|
+
return true;
|
|
3541
|
+
}
|
|
3542
|
+
if (lowerDomain.startsWith("*.")) {
|
|
3543
|
+
const baseDomain = lowerDomain.slice(2);
|
|
3544
|
+
if (lowerHostname === baseDomain || lowerHostname.endsWith("." + baseDomain)) {
|
|
3545
|
+
return true;
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
return false;
|
|
3550
|
+
}
|
|
3551
|
+
function hasOnlyLocalhostRedirectUris(redirectUris) {
|
|
3552
|
+
if (!redirectUris.length) {
|
|
3553
|
+
return false;
|
|
3554
|
+
}
|
|
3555
|
+
return redirectUris.every((uri) => {
|
|
3556
|
+
try {
|
|
3557
|
+
const url = new URL(uri);
|
|
3558
|
+
const hostname = url.hostname.toLowerCase();
|
|
3559
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
|
|
3560
|
+
} catch {
|
|
3561
|
+
return false;
|
|
3562
|
+
}
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
// libs/auth/src/cimd/index.ts
|
|
3567
|
+
init_cimd_cache();
|
|
3568
|
+
|
|
3569
|
+
// libs/auth/src/cimd/cimd.service.ts
|
|
3570
|
+
init_cimd_cache();
|
|
3571
|
+
var CimdService = class {
|
|
3572
|
+
config;
|
|
3573
|
+
cacheConfig;
|
|
3574
|
+
securityConfig;
|
|
3575
|
+
networkConfig;
|
|
3576
|
+
cache;
|
|
3577
|
+
logger;
|
|
3578
|
+
/**
|
|
3579
|
+
* Whether CIMD is enabled.
|
|
3580
|
+
*/
|
|
3581
|
+
get enabled() {
|
|
3582
|
+
return this.config.enabled;
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* Create a new CIMD service.
|
|
3586
|
+
*
|
|
3587
|
+
* @param logger - Optional logger. If not provided, logging is disabled.
|
|
3588
|
+
* @param config - Optional configuration.
|
|
3589
|
+
*/
|
|
3590
|
+
constructor(logger, config) {
|
|
3591
|
+
this.logger = (logger ?? noopLogger).child("CimdService");
|
|
3592
|
+
this.config = cimdConfigSchema.parse(config ?? {});
|
|
3593
|
+
this.cacheConfig = cimdCacheConfigSchema.parse(this.config.cache ?? {});
|
|
3594
|
+
this.securityConfig = cimdSecurityConfigSchema.parse(this.config.security ?? {});
|
|
3595
|
+
this.networkConfig = cimdNetworkConfigSchema.parse(this.config.network ?? {});
|
|
3596
|
+
this.cache = new CimdCache(this.cacheConfig);
|
|
3597
|
+
this.logger.debug("CimdService initialized", {
|
|
3598
|
+
enabled: this.config.enabled,
|
|
3599
|
+
cacheDefaultTtlMs: this.cacheConfig.defaultTtlMs,
|
|
3600
|
+
networkTimeoutMs: this.networkConfig.timeoutMs
|
|
3601
|
+
});
|
|
3602
|
+
if (this.securityConfig.allowInsecureForTesting) {
|
|
3603
|
+
this.logger.warn(
|
|
3604
|
+
"CIMD allowInsecureForTesting is enabled. HTTP is allowed for localhost CIMD URLs. This should NEVER be enabled in production!"
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
/**
|
|
3609
|
+
* Check if a client_id is a CIMD URL.
|
|
3610
|
+
*
|
|
3611
|
+
* @param clientId - The client_id to check
|
|
3612
|
+
* @returns true if this is a CIMD client_id (HTTPS URL with path, or HTTP for localhost when testing)
|
|
3613
|
+
*/
|
|
3614
|
+
isCimdClientId(clientId) {
|
|
3615
|
+
return isCimdClientId(clientId, this.securityConfig.allowInsecureForTesting);
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Resolve a client_id to its metadata document.
|
|
3619
|
+
*
|
|
3620
|
+
* If the client_id is a CIMD URL, this fetches and validates the metadata document.
|
|
3621
|
+
* Non-CIMD client IDs return a result with isCimdClient: false.
|
|
3622
|
+
*
|
|
3623
|
+
* @param clientId - The client_id to resolve
|
|
3624
|
+
* @returns Resolution result with metadata if available
|
|
3625
|
+
*/
|
|
3626
|
+
async resolveClientMetadata(clientId) {
|
|
3627
|
+
if (!this.isCimdClientId(clientId)) {
|
|
3628
|
+
return {
|
|
3629
|
+
isCimdClient: false,
|
|
3630
|
+
fromCache: false
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
validateClientIdUrl(clientId, this.securityConfig);
|
|
3634
|
+
const cached = await this.cache.get(clientId);
|
|
3635
|
+
if (cached) {
|
|
3636
|
+
this.logger.debug(`Cache hit for CIMD client: ${clientId}`);
|
|
3637
|
+
return {
|
|
3638
|
+
isCimdClient: true,
|
|
3639
|
+
metadata: cached.document,
|
|
3640
|
+
fromCache: true,
|
|
3641
|
+
expiresAt: cached.expiresAt,
|
|
3642
|
+
etag: cached.etag,
|
|
3643
|
+
lastModified: cached.lastModified
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
this.logger.info(`Fetching CIMD document: ${clientId}`);
|
|
3647
|
+
const { document, headers } = await this.fetchMetadataDocument(clientId);
|
|
3648
|
+
this.validateDocument(clientId, document);
|
|
3649
|
+
if (this.securityConfig.warnOnLocalhostRedirects && hasOnlyLocalhostRedirectUris(document.redirect_uris)) {
|
|
3650
|
+
this.logger.warn(`CIMD client "${clientId}" has only localhost redirect URIs - this may be a development client`);
|
|
3651
|
+
}
|
|
3652
|
+
await this.cache.set(clientId, document, headers);
|
|
3653
|
+
const entry = await this.cache.get(clientId);
|
|
3654
|
+
return {
|
|
3655
|
+
isCimdClient: true,
|
|
3656
|
+
metadata: document,
|
|
3657
|
+
fromCache: false,
|
|
3658
|
+
expiresAt: entry?.expiresAt,
|
|
3659
|
+
etag: entry?.etag,
|
|
3660
|
+
lastModified: entry?.lastModified
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* Validate that a redirect_uri is registered for the client.
|
|
3665
|
+
*
|
|
3666
|
+
* @param redirectUri - The redirect_uri from the authorization request
|
|
3667
|
+
* @param metadata - The client's metadata document
|
|
3668
|
+
* @throws RedirectUriMismatchError if the redirect_uri is not registered
|
|
3669
|
+
*/
|
|
3670
|
+
validateRedirectUri(redirectUri, metadata) {
|
|
3671
|
+
const normalizedRequestUri = normalizeRedirectUri(redirectUri);
|
|
3672
|
+
const normalizedAllowed = metadata.redirect_uris.map(normalizeRedirectUri);
|
|
3673
|
+
if (!normalizedAllowed.includes(normalizedRequestUri)) {
|
|
3674
|
+
throw new RedirectUriMismatchError(metadata.client_id, redirectUri, metadata.redirect_uris);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
/**
|
|
3678
|
+
* Clear the cache for a specific client or all clients.
|
|
3679
|
+
*
|
|
3680
|
+
* @param clientId - Optional client_id to clear; clears all if not provided
|
|
3681
|
+
*/
|
|
3682
|
+
async clearCache(clientId) {
|
|
3683
|
+
if (clientId) {
|
|
3684
|
+
await this.cache.delete(clientId);
|
|
3685
|
+
this.logger.debug(`Cache cleared for: ${clientId}`);
|
|
3686
|
+
} else {
|
|
3687
|
+
await this.cache.clear();
|
|
3688
|
+
this.logger.debug("Cache cleared");
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
/**
|
|
3692
|
+
* Get cache statistics.
|
|
3693
|
+
*/
|
|
3694
|
+
async getCacheStats() {
|
|
3695
|
+
return {
|
|
3696
|
+
size: await this.cache.size()
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Fetch a metadata document from a CIMD URL.
|
|
3701
|
+
*
|
|
3702
|
+
* Following the JwksService.fetchJson() pattern.
|
|
3703
|
+
*/
|
|
3704
|
+
async fetchMetadataDocument(clientId) {
|
|
3705
|
+
const controller = new AbortController();
|
|
3706
|
+
const timer = setTimeout(() => controller.abort(), this.networkConfig.timeoutMs);
|
|
3707
|
+
try {
|
|
3708
|
+
const conditionalHeaders = await this.cache.getConditionalHeaders(clientId);
|
|
3709
|
+
const originalOrigin = new URL(clientId).origin;
|
|
3710
|
+
const maxRedirects = this.networkConfig.maxRedirects;
|
|
3711
|
+
let currentUrl = clientId;
|
|
3712
|
+
let redirectCount = 0;
|
|
3713
|
+
let isFirstRequest = true;
|
|
3714
|
+
while (true) {
|
|
3715
|
+
const headers = {
|
|
3716
|
+
Accept: "application/json"
|
|
3717
|
+
};
|
|
3718
|
+
if (isFirstRequest && conditionalHeaders) {
|
|
3719
|
+
Object.assign(headers, conditionalHeaders);
|
|
3720
|
+
}
|
|
3721
|
+
const response = await fetch(currentUrl, {
|
|
3722
|
+
method: "GET",
|
|
3723
|
+
headers,
|
|
3724
|
+
signal: controller.signal,
|
|
3725
|
+
redirect: "manual"
|
|
3726
|
+
});
|
|
3727
|
+
if (response.status === 304) {
|
|
3728
|
+
const staleEntry = await this.cache.getStale(clientId);
|
|
3729
|
+
if (staleEntry) {
|
|
3730
|
+
await this.cache.revalidate(clientId, response.headers);
|
|
3731
|
+
this.logger.debug(`CIMD document not modified: ${clientId}`);
|
|
3732
|
+
return {
|
|
3733
|
+
document: staleEntry.document,
|
|
3734
|
+
headers: response.headers
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3737
|
+
throw new CimdFetchError(clientId, "304 Not Modified but no cached entry", {
|
|
3738
|
+
httpStatus: 304
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
if (response.status >= 300 && response.status < 400) {
|
|
3742
|
+
const location = response.headers.get("location");
|
|
3743
|
+
if (!location) {
|
|
3744
|
+
throw new CimdFetchError(clientId, "Redirect response missing Location header", {
|
|
3745
|
+
httpStatus: response.status
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
3748
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
3749
|
+
const redirectPolicy = this.networkConfig.redirectPolicy;
|
|
3750
|
+
if (redirectPolicy === "deny") {
|
|
3751
|
+
throw new CimdFetchError(
|
|
3752
|
+
clientId,
|
|
3753
|
+
`CIMD fetch redirected to "${nextUrl}" but redirects are disabled. Host the metadata at the client_id URL or set auth.cimd.network.redirectPolicy to "same-origin" or "allow".`,
|
|
3754
|
+
{ httpStatus: response.status }
|
|
3755
|
+
);
|
|
3756
|
+
}
|
|
3757
|
+
if (redirectPolicy === "same-origin") {
|
|
3758
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
3759
|
+
if (nextOrigin !== originalOrigin) {
|
|
3760
|
+
throw new CimdFetchError(
|
|
3761
|
+
clientId,
|
|
3762
|
+
`CIMD fetch redirected to "${nextUrl}" which is not the same origin as "${originalOrigin}". Host the metadata at the client_id URL or set auth.cimd.network.redirectPolicy to "allow".`,
|
|
3763
|
+
{ httpStatus: response.status }
|
|
3764
|
+
);
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
validateClientIdUrl(nextUrl, this.securityConfig);
|
|
3768
|
+
redirectCount += 1;
|
|
3769
|
+
if (redirectCount > maxRedirects) {
|
|
3770
|
+
throw new CimdFetchError(
|
|
3771
|
+
clientId,
|
|
3772
|
+
`CIMD fetch exceeded max redirects (${maxRedirects}). Host the metadata at the client_id URL or increase auth.cimd.network.maxRedirects.`
|
|
3773
|
+
);
|
|
3774
|
+
}
|
|
3775
|
+
this.logger.warn(`CIMD redirect ${redirectCount}/${maxRedirects}: ${currentUrl} -> ${nextUrl}`);
|
|
3776
|
+
currentUrl = nextUrl;
|
|
3777
|
+
isFirstRequest = false;
|
|
3778
|
+
continue;
|
|
3779
|
+
}
|
|
3780
|
+
if (!response.ok) {
|
|
3781
|
+
throw new CimdFetchError(clientId, `HTTP ${response.status} ${response.statusText}`, {
|
|
3782
|
+
httpStatus: response.status
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
const contentLength = response.headers.get("content-length");
|
|
3786
|
+
if (contentLength) {
|
|
3787
|
+
const length = parseInt(contentLength, 10);
|
|
3788
|
+
if (!isNaN(length) && length > this.networkConfig.maxResponseSizeBytes) {
|
|
3789
|
+
throw new CimdResponseTooLargeError(clientId, this.networkConfig.maxResponseSizeBytes, length);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
const text = await this.readResponseWithLimit(response, this.networkConfig.maxResponseSizeBytes, clientId);
|
|
3793
|
+
let document;
|
|
3794
|
+
try {
|
|
3795
|
+
document = JSON.parse(text);
|
|
3796
|
+
} catch (e) {
|
|
3797
|
+
throw new CimdFetchError(clientId, "Invalid JSON response", {
|
|
3798
|
+
originalError: e instanceof Error ? e : new Error(String(e))
|
|
3799
|
+
});
|
|
3800
|
+
}
|
|
3801
|
+
return { document, headers: response.headers };
|
|
3802
|
+
}
|
|
3803
|
+
} catch (error) {
|
|
3804
|
+
if (error instanceof CimdFetchError || error instanceof CimdResponseTooLargeError) {
|
|
3805
|
+
throw error;
|
|
3806
|
+
}
|
|
3807
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
3808
|
+
throw new CimdFetchError(clientId, "Request timeout", {
|
|
3809
|
+
originalError: error
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
throw new CimdFetchError(clientId, error instanceof Error ? error.message : "Unknown error", {
|
|
3813
|
+
originalError: error instanceof Error ? error : void 0
|
|
3814
|
+
});
|
|
3815
|
+
} finally {
|
|
3816
|
+
clearTimeout(timer);
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* Read response body with size limit.
|
|
3821
|
+
*/
|
|
3822
|
+
async readResponseWithLimit(response, maxBytes, clientId) {
|
|
3823
|
+
const reader = response.body?.getReader();
|
|
3824
|
+
if (!reader) {
|
|
3825
|
+
const buffer = await response.arrayBuffer();
|
|
3826
|
+
if (buffer.byteLength > maxBytes) {
|
|
3827
|
+
throw new CimdResponseTooLargeError(clientId, maxBytes, buffer.byteLength);
|
|
3828
|
+
}
|
|
3829
|
+
return new TextDecoder().decode(buffer);
|
|
3830
|
+
}
|
|
3831
|
+
const chunks = [];
|
|
3832
|
+
let totalBytes = 0;
|
|
3833
|
+
try {
|
|
3834
|
+
while (true) {
|
|
3835
|
+
const { done, value } = await reader.read();
|
|
3836
|
+
if (done) break;
|
|
3837
|
+
totalBytes += value.length;
|
|
3838
|
+
if (totalBytes > maxBytes) {
|
|
3839
|
+
throw new CimdResponseTooLargeError(clientId, maxBytes, totalBytes);
|
|
3840
|
+
}
|
|
3841
|
+
chunks.push(value);
|
|
3842
|
+
}
|
|
3843
|
+
} finally {
|
|
3844
|
+
reader.releaseLock();
|
|
3845
|
+
}
|
|
3846
|
+
const combined = new Uint8Array(totalBytes);
|
|
3847
|
+
let offset = 0;
|
|
3848
|
+
for (const chunk of chunks) {
|
|
3849
|
+
combined.set(chunk, offset);
|
|
3850
|
+
offset += chunk.length;
|
|
3851
|
+
}
|
|
3852
|
+
return new TextDecoder().decode(combined);
|
|
3853
|
+
}
|
|
3854
|
+
/**
|
|
3855
|
+
* Validate a fetched document against the schema.
|
|
3856
|
+
*
|
|
3857
|
+
* @param clientId - The URL from which the document was fetched
|
|
3858
|
+
* @param document - The document to validate
|
|
3859
|
+
*/
|
|
3860
|
+
validateDocument(clientId, document) {
|
|
3861
|
+
const result = clientMetadataDocumentSchema.safeParse(document);
|
|
3862
|
+
if (!result.success) {
|
|
3863
|
+
const errors = result.error.issues.map((issue) => {
|
|
3864
|
+
const path2 = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
3865
|
+
return `${path2}${issue.message}`;
|
|
3866
|
+
});
|
|
3867
|
+
throw new CimdValidationError(clientId, errors);
|
|
3868
|
+
}
|
|
3869
|
+
if (result.data.client_id !== clientId) {
|
|
3870
|
+
throw new CimdClientIdMismatchError(clientId, result.data.client_id);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
};
|
|
3874
|
+
function normalizeRedirectUri(uri) {
|
|
3875
|
+
try {
|
|
3876
|
+
const url = new URL(uri);
|
|
3877
|
+
let normalized = `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}`;
|
|
3878
|
+
normalized += url.pathname.replace(/\/+$/, "") || "/";
|
|
3879
|
+
if (url.search) normalized += url.search;
|
|
3880
|
+
return normalized;
|
|
3881
|
+
} catch {
|
|
3882
|
+
return uri;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
export {
|
|
3886
|
+
AppAuthState,
|
|
3887
|
+
AudienceValidator,
|
|
3888
|
+
CDN,
|
|
3889
|
+
CimdCache,
|
|
3890
|
+
CimdClientIdMismatchError,
|
|
3891
|
+
CimdDisabledError,
|
|
3892
|
+
CimdError,
|
|
3893
|
+
CimdFetchError,
|
|
3894
|
+
CimdResponseTooLargeError,
|
|
3895
|
+
CimdSecurityError,
|
|
3896
|
+
CimdService,
|
|
3897
|
+
CimdValidationError,
|
|
3898
|
+
CredentialCache,
|
|
3899
|
+
DEFAULT_THEME,
|
|
3900
|
+
EncryptedStorageError,
|
|
3901
|
+
EncryptedTypedStorage,
|
|
3902
|
+
InMemoryAuthorizationStore,
|
|
3903
|
+
InMemoryAuthorizationVault,
|
|
3904
|
+
InvalidClientIdUrlError,
|
|
3905
|
+
JwksService,
|
|
3906
|
+
RedirectUriMismatchError,
|
|
3907
|
+
RedisAuthorizationStore,
|
|
3908
|
+
StorageAuthorizationVault,
|
|
3909
|
+
StorageTokenStore,
|
|
3910
|
+
TinyTtlCache,
|
|
3911
|
+
TokenVault,
|
|
3912
|
+
TypedStorage3 as TypedStorage,
|
|
3913
|
+
VaultEncryption,
|
|
3914
|
+
apiKeyCredentialSchema,
|
|
3915
|
+
appAuthStateSchema,
|
|
3916
|
+
appAuthorizationRecordSchema,
|
|
3917
|
+
appCredentialSchema,
|
|
3918
|
+
authLayout,
|
|
3919
|
+
authModeSchema,
|
|
3920
|
+
authProviderMappingSchema,
|
|
3921
|
+
authProvidersVaultOptionsSchema,
|
|
3922
|
+
authUserSchema,
|
|
3923
|
+
authorizationCodeRecordSchema,
|
|
3924
|
+
authorizationVaultEntrySchema,
|
|
3925
|
+
authorizedPromptSchema,
|
|
3926
|
+
authorizedToolSchema,
|
|
3927
|
+
baseLayout,
|
|
3928
|
+
basicAuthCredentialSchema,
|
|
3929
|
+
bearerCredentialSchema,
|
|
3930
|
+
buildConsentPage,
|
|
3931
|
+
buildErrorPage,
|
|
3932
|
+
buildFederatedLoginPage,
|
|
3933
|
+
buildIncrementalAuthPage,
|
|
3934
|
+
buildInsufficientScopeHeader,
|
|
3935
|
+
buildInvalidRequestHeader,
|
|
3936
|
+
buildInvalidTokenHeader,
|
|
3937
|
+
buildLoginPage,
|
|
3938
|
+
buildPrmUrl,
|
|
3939
|
+
buildToolConsentPage,
|
|
3940
|
+
buildUnauthorizedHeader,
|
|
3941
|
+
buildWwwAuthenticate,
|
|
3942
|
+
centeredCardLayout,
|
|
3943
|
+
checkSsrfProtection,
|
|
3944
|
+
cimdCacheConfigSchema,
|
|
3945
|
+
cimdConfigSchema,
|
|
3946
|
+
cimdNetworkConfigSchema,
|
|
3947
|
+
cimdSecurityConfigSchema,
|
|
3948
|
+
clientMetadataDocumentSchema,
|
|
3949
|
+
createAudienceValidator,
|
|
3950
|
+
createLayout,
|
|
3951
|
+
credentialProviderConfigSchema,
|
|
3952
|
+
credentialSchema,
|
|
3953
|
+
credentialScopeSchema,
|
|
3954
|
+
credentialTypeSchema,
|
|
3955
|
+
customCredentialSchema,
|
|
3956
|
+
decodeJwtPayloadSafe,
|
|
3957
|
+
decryptAesGcm3 as decryptAesGcm,
|
|
3958
|
+
decryptValue,
|
|
3959
|
+
deleteDevKey,
|
|
3960
|
+
deriveExpectedAudience,
|
|
3961
|
+
encryptAesGcm3 as encryptAesGcm,
|
|
3962
|
+
encryptValue,
|
|
3963
|
+
encryptedDataSchema,
|
|
3964
|
+
encryptedVaultEntrySchema,
|
|
3965
|
+
escapeHtml2 as escapeHtml,
|
|
3966
|
+
extraWideLayout,
|
|
3967
|
+
extractCacheHeaders,
|
|
3968
|
+
extractCredentialExpiry,
|
|
3969
|
+
generatePkceChallenge,
|
|
3970
|
+
getCredentialOptionsSchema,
|
|
3971
|
+
hasOnlyLocalhostRedirectUris,
|
|
3972
|
+
hkdfSha2562 as hkdfSha256,
|
|
3973
|
+
isCimdClientId,
|
|
3974
|
+
isDevKeyPersistenceEnabled,
|
|
3975
|
+
llmSafeAuthContextSchema,
|
|
3976
|
+
loadDevKey,
|
|
3977
|
+
loadingStrategySchema,
|
|
3978
|
+
mtlsCredentialSchema,
|
|
3979
|
+
noopLogger,
|
|
3980
|
+
normalizeIssuer,
|
|
3981
|
+
oauthCredentialSchema,
|
|
3982
|
+
parseCacheHeaders,
|
|
3983
|
+
parseWwwAuthenticate,
|
|
3984
|
+
pendingIncrementalAuthSchema,
|
|
3985
|
+
pkceChallengeSchema,
|
|
3986
|
+
pkceOAuthCredentialSchema,
|
|
3987
|
+
privateKeyCredentialSchema,
|
|
3988
|
+
progressiveAuthStateSchema,
|
|
3989
|
+
renderToHtml,
|
|
3990
|
+
resolveKeyPath,
|
|
3991
|
+
saveDevKey,
|
|
3992
|
+
serviceAccountCredentialSchema,
|
|
3993
|
+
sshKeyCredentialSchema,
|
|
3994
|
+
trimSlash,
|
|
3995
|
+
validateAudience,
|
|
3996
|
+
validateClientIdUrl,
|
|
3997
|
+
vaultConsentRecordSchema,
|
|
3998
|
+
vaultFederatedRecordSchema,
|
|
3999
|
+
verifyPkce,
|
|
4000
|
+
wideLayout
|
|
4001
|
+
};
|