@ni-c/mcp-hub 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +619 -0
- package/README.md +189 -64
- package/dist/admin.js +232 -8
- package/dist/admin.js.map +1 -1
- package/dist/auth/address.js +89 -0
- package/dist/auth/address.js.map +1 -0
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/api-tokens.js.map +1 -0
- package/dist/auth/cimd.js +367 -0
- package/dist/auth/cimd.js.map +1 -0
- package/dist/auth/consent-page.js +4 -7
- package/dist/auth/consent-page.js.map +1 -1
- package/dist/auth/headers.js +22 -1
- package/dist/auth/headers.js.map +1 -1
- package/dist/auth/login-page.js +4 -7
- package/dist/auth/login-page.js.map +1 -1
- package/dist/auth/oidc/adapter.js +135 -0
- package/dist/auth/oidc/adapter.js.map +1 -0
- package/dist/auth/oidc/interactions.js +187 -0
- package/dist/auth/oidc/interactions.js.map +1 -0
- package/dist/auth/oidc/mount.js +144 -0
- package/dist/auth/oidc/mount.js.map +1 -0
- package/dist/auth/oidc/provider.js +440 -0
- package/dist/auth/oidc/provider.js.map +1 -0
- package/dist/auth/oidc/quirks.js +234 -0
- package/dist/auth/oidc/quirks.js.map +1 -0
- package/dist/auth/oidc/verifier.js +121 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/page.js +22 -0
- package/dist/auth/page.js.map +1 -1
- package/dist/auth/pinned-fetch.js +129 -0
- package/dist/auth/pinned-fetch.js.map +1 -0
- package/dist/auth/protected-resource.js +41 -0
- package/dist/auth/protected-resource.js.map +1 -0
- package/dist/auth/rate-limit.js +156 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/redirect-uri.js +90 -0
- package/dist/auth/redirect-uri.js.map +1 -0
- package/dist/auth/registration.js +146 -0
- package/dist/auth/registration.js.map +1 -0
- package/dist/auth/session.js +43 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/signed-token.js +49 -0
- package/dist/auth/signed-token.js.map +1 -0
- package/dist/auth/store.js +516 -5
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/text.js +51 -0
- package/dist/auth/text.js.map +1 -0
- package/dist/config.js +184 -5
- package/dist/config.js.map +1 -1
- package/dist/docker-proxy/policy.js +1 -0
- package/dist/docker-proxy/policy.js.map +1 -1
- package/dist/docker-proxy/server.js +1 -0
- package/dist/docker-proxy/server.js.map +1 -1
- package/dist/elicitation.js +0 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/forward.js +0 -0
- package/dist/forward.js.map +1 -0
- package/dist/health.js +22 -3
- package/dist/health.js.map +1 -1
- package/dist/hub.js +337 -29
- package/dist/hub.js.map +1 -1
- package/dist/index.js +218 -23
- package/dist/index.js.map +1 -1
- package/dist/limits.js +13 -1
- package/dist/limits.js.map +1 -1
- package/dist/mcp-limits.js +14 -2
- package/dist/mcp-limits.js.map +1 -1
- package/dist/proxy.js +262 -34
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +97 -7
- package/dist/stdio.js.map +1 -1
- package/dist/subscriptions.js +236 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/supervisor.js +472 -28
- package/dist/supervisor.js.map +1 -1
- package/dist/timings.js +61 -0
- package/dist/timings.js.map +1 -0
- package/dist/tool-filter.js +59 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/transports/docker.js.map +1 -1
- package/dist/transports/stream.js +33 -20
- package/dist/transports/stream.js.map +1 -1
- package/dist/upstream/auth.js +435 -0
- package/dist/upstream/auth.js.map +1 -0
- package/dist/upstream/login.js +94 -0
- package/dist/upstream/login.js.map +1 -0
- package/dist/upstream/provider.js +286 -0
- package/dist/upstream/provider.js.map +1 -0
- package/dist/upstream/routes.js +97 -0
- package/dist/upstream/routes.js.map +1 -0
- package/package.json +23 -7
- package/dist/auth/provider.js +0 -380
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/routes.js +0 -223
- package/dist/auth/routes.js.map +0 -1
package/dist/auth/store.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
export const DEFAULT_CLIENT_LIMITS = {
|
|
5
|
+
maxClients: 500,
|
|
6
|
+
pendingTtlSeconds: 24 * 3600,
|
|
7
|
+
inactiveSeconds: 90 * 86_400
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Activity is only written to disk once an hour. The windows it feeds are a day
|
|
11
|
+
* and three months, so recording it per request would buy nothing and cost a
|
|
12
|
+
* lock plus a full rewrite of state.json on every authorization.
|
|
13
|
+
*/
|
|
14
|
+
const ACTIVITY_GRANULARITY_S = 3600;
|
|
15
|
+
/**
|
|
16
|
+
* A revocation marker only has to outlive the longest-lived token it could
|
|
17
|
+
* still reject. Refresh tokens last 30 days, so anything older is answering a
|
|
18
|
+
* question nobody can ask any more — and without this the map is the one
|
|
19
|
+
* structure that only ever grows.
|
|
20
|
+
*/
|
|
21
|
+
const REVOCATION_MARKER_TTL_MS = 31 * 24 * 3600_000;
|
|
22
|
+
/**
|
|
23
|
+
* Every artifact expires and every write prunes, so this cap is not the primary
|
|
24
|
+
* bound — it is the backstop for the one case pruning cannot help with: tokens
|
|
25
|
+
* minted faster than they age out. Eviction is oldest-first, which for tokens
|
|
26
|
+
* costs a client one refresh rather than its session.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_OIDC_ARTIFACTS_PER_MODEL = 5_000;
|
|
4
29
|
/**
|
|
5
30
|
* Registration is open (anyone can POST /register), so unconfirmed clients
|
|
6
31
|
* would otherwise accumulate on disk without bound and every registration
|
|
@@ -8,6 +33,29 @@ import path from 'node:path';
|
|
|
8
33
|
* approved; approved ones are legitimate and never evicted.
|
|
9
34
|
*/
|
|
10
35
|
export const MAX_UNAPPROVED_CLIENTS = 100;
|
|
36
|
+
/**
|
|
37
|
+
* The same limits the hub runs with, so `mcp-hub-admin clients prune` removes
|
|
38
|
+
* exactly what the hub would. The documented invocation is `docker exec` into
|
|
39
|
+
* the hub container, which shares its environment.
|
|
40
|
+
*/
|
|
41
|
+
export function clientLimitsFromEnv(env = process.env) {
|
|
42
|
+
const read = (name, fallback) => {
|
|
43
|
+
const raw = env[name];
|
|
44
|
+
if (raw === undefined)
|
|
45
|
+
return fallback;
|
|
46
|
+
const value = Number(raw);
|
|
47
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
48
|
+
console.warn(`mcp-hub: ignoring ${name}=${raw}; it must be a positive integer`);
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
maxClients: read('DCR_MAX_CLIENTS', 500),
|
|
55
|
+
pendingTtlSeconds: read('DCR_PENDING_TTL_HOURS', 24) * 3600,
|
|
56
|
+
inactiveSeconds: read('DCR_INACTIVE_DAYS', 90) * 86_400
|
|
57
|
+
};
|
|
58
|
+
}
|
|
11
59
|
const LOCK_WAIT_MS = 10_000;
|
|
12
60
|
const STALE_LOCK_MS = 30_000;
|
|
13
61
|
const LOCK_POLL_MS = 10;
|
|
@@ -28,18 +76,23 @@ const lockSleep = new Int32Array(new SharedArrayBuffer(4));
|
|
|
28
76
|
* read-modify-write.
|
|
29
77
|
*/
|
|
30
78
|
export class AuthStore {
|
|
79
|
+
limits;
|
|
31
80
|
privateKey;
|
|
32
81
|
publicKey;
|
|
33
82
|
state;
|
|
34
83
|
statePath;
|
|
35
84
|
lockPath;
|
|
85
|
+
upstreamKeyPath;
|
|
86
|
+
cachedUpstreamKey;
|
|
36
87
|
/** Identity of the file contents this.state was loaded from; see fileSignature(). */
|
|
37
88
|
signature;
|
|
38
89
|
tmpCounter = 0;
|
|
39
|
-
constructor(dataDir) {
|
|
90
|
+
constructor(dataDir, limits = DEFAULT_CLIENT_LIMITS) {
|
|
91
|
+
this.limits = limits;
|
|
40
92
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
41
93
|
this.statePath = path.join(dataDir, 'state.json');
|
|
42
94
|
this.lockPath = path.join(dataDir, '.auth-state.lock');
|
|
95
|
+
this.upstreamKeyPath = path.join(dataDir, 'upstream-key.pem');
|
|
43
96
|
this.acquireLock();
|
|
44
97
|
try {
|
|
45
98
|
const keyPath = path.join(dataDir, 'jwt-key.pem');
|
|
@@ -57,7 +110,11 @@ export class AuthStore {
|
|
|
57
110
|
approvals: {},
|
|
58
111
|
consumedRefreshTokens: {},
|
|
59
112
|
revokedBefore: {},
|
|
60
|
-
apiTokens: {}
|
|
113
|
+
apiTokens: {},
|
|
114
|
+
clientLifecycle: {},
|
|
115
|
+
upstreamCredentials: {},
|
|
116
|
+
upstreamLogins: {},
|
|
117
|
+
oidcArtifacts: {}
|
|
61
118
|
};
|
|
62
119
|
if (restored)
|
|
63
120
|
this.signature = this.fileSignature();
|
|
@@ -248,7 +305,12 @@ export class AuthStore {
|
|
|
248
305
|
approvals: state.approvals ?? {},
|
|
249
306
|
consumedRefreshTokens: state.consumedRefreshTokens ?? {},
|
|
250
307
|
revokedBefore: state.revokedBefore ?? {},
|
|
251
|
-
apiTokens: state.apiTokens ?? {}
|
|
308
|
+
apiTokens: state.apiTokens ?? {},
|
|
309
|
+
clientLifecycle: state.clientLifecycle ?? {},
|
|
310
|
+
upstreamCredentials: state.upstreamCredentials ?? {},
|
|
311
|
+
upstreamLogins: state.upstreamLogins ?? {},
|
|
312
|
+
oidcArtifacts: state.oidcArtifacts ?? {},
|
|
313
|
+
...(typeof state.externalUrl === 'string' ? { externalUrl: state.externalUrl } : {})
|
|
252
314
|
};
|
|
253
315
|
}
|
|
254
316
|
/**
|
|
@@ -306,6 +368,135 @@ export class AuthStore {
|
|
|
306
368
|
if (record.expiresAt < now)
|
|
307
369
|
delete this.state.apiTokens[id];
|
|
308
370
|
}
|
|
371
|
+
// A marker that can no longer reject anything is just a row that never
|
|
372
|
+
// goes away; this is otherwise the one map with no upper bound.
|
|
373
|
+
const markerCutoff = Date.now() - REVOCATION_MARKER_TTL_MS;
|
|
374
|
+
for (const [clientId, revokedAt] of Object.entries(this.state.revokedBefore)) {
|
|
375
|
+
if (revokedAt < markerCutoff)
|
|
376
|
+
delete this.state.revokedBefore[clientId];
|
|
377
|
+
}
|
|
378
|
+
// Bookkeeping for a client that is gone serves nobody.
|
|
379
|
+
for (const clientId of Object.keys(this.state.clientLifecycle)) {
|
|
380
|
+
if (!this.state.clients[clientId])
|
|
381
|
+
delete this.state.clientLifecycle[clientId];
|
|
382
|
+
}
|
|
383
|
+
// A login nobody finished. Note this sweeps only the in-flight record —
|
|
384
|
+
// UpstreamCredentials deliberately has no `expiresAt`, because an expired
|
|
385
|
+
// access token still has a usable refresh token behind it.
|
|
386
|
+
for (const [state, login] of Object.entries(this.state.upstreamLogins)) {
|
|
387
|
+
if (login.expiresAt < now)
|
|
388
|
+
delete this.state.upstreamLogins[state];
|
|
389
|
+
}
|
|
390
|
+
for (const [model, records] of Object.entries(this.state.oidcArtifacts)) {
|
|
391
|
+
for (const [id, record] of Object.entries(records)) {
|
|
392
|
+
if (record.expiresAt !== 0 && record.expiresAt < now)
|
|
393
|
+
delete records[id];
|
|
394
|
+
}
|
|
395
|
+
const ids = Object.keys(records);
|
|
396
|
+
if (ids.length > MAX_OIDC_ARTIFACTS_PER_MODEL) {
|
|
397
|
+
const byAge = ids.sort((a, b) => (records[a].expiresAt || Infinity) - (records[b].expiresAt || Infinity));
|
|
398
|
+
for (const id of byAge.slice(0, ids.length - MAX_OIDC_ARTIFACTS_PER_MODEL))
|
|
399
|
+
delete records[id];
|
|
400
|
+
}
|
|
401
|
+
// An empty model map is a row that never goes away.
|
|
402
|
+
if (Object.keys(records).length === 0)
|
|
403
|
+
delete this.state.oidcArtifacts[model];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// --- Upstream OAuth ------------------------------------------------------
|
|
407
|
+
/** The issuer the hub is running under, recorded so the admin CLI can build a
|
|
408
|
+
* redirect URI without EXTERNAL_URL in its own environment. */
|
|
409
|
+
rememberExternalUrl(externalUrl) {
|
|
410
|
+
this.reloadIfChanged();
|
|
411
|
+
if (this.state.externalUrl === externalUrl)
|
|
412
|
+
return;
|
|
413
|
+
this.mutate(() => {
|
|
414
|
+
this.state.externalUrl = externalUrl;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
getExternalUrl() {
|
|
418
|
+
this.reloadIfChanged();
|
|
419
|
+
return this.state.externalUrl;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* What the hub holds for one upstream, or undefined when there is nothing —
|
|
423
|
+
* including when the configuration moved out from under it, which is what the
|
|
424
|
+
* fingerprint detects. Stale credentials are not returned and not deleted:
|
|
425
|
+
* deleting on a read would race the admin CLI, and the next successful login
|
|
426
|
+
* overwrites them anyway.
|
|
427
|
+
*/
|
|
428
|
+
getUpstreamCredentials(serverName, fingerprint) {
|
|
429
|
+
this.reloadIfChanged();
|
|
430
|
+
const record = this.state.upstreamCredentials[serverName];
|
|
431
|
+
if (!record || record.fingerprint !== fingerprint)
|
|
432
|
+
return undefined;
|
|
433
|
+
return structuredClone(record);
|
|
434
|
+
}
|
|
435
|
+
/** Every stored upstream, whatever its fingerprint — for `upstream list`. */
|
|
436
|
+
listUpstreamCredentials() {
|
|
437
|
+
this.reloadIfChanged();
|
|
438
|
+
return structuredClone(this.state.upstreamCredentials);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Read-modify-write of one upstream's record. The callback runs while the
|
|
442
|
+
* cross-process lock is held, so it must not do I/O — it gets the current
|
|
443
|
+
* record (or undefined) and returns the one to store.
|
|
444
|
+
*/
|
|
445
|
+
updateUpstreamCredentials(serverName, update) {
|
|
446
|
+
this.mutate(() => {
|
|
447
|
+
this.state.upstreamCredentials[serverName] = update(this.state.upstreamCredentials[serverName]);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
forgetUpstreamCredentials(serverName) {
|
|
451
|
+
return this.mutate(() => {
|
|
452
|
+
if (!this.state.upstreamCredentials[serverName])
|
|
453
|
+
return false;
|
|
454
|
+
delete this.state.upstreamCredentials[serverName];
|
|
455
|
+
return true;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
saveUpstreamLogin(state, login) {
|
|
459
|
+
this.mutate(() => {
|
|
460
|
+
this.state.upstreamLogins[state] = login;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
listUpstreamLogins() {
|
|
464
|
+
this.reloadIfChanged();
|
|
465
|
+
return structuredClone(this.state.upstreamLogins);
|
|
466
|
+
}
|
|
467
|
+
/** Single-use: the record is removed as it is read, so a replayed callback
|
|
468
|
+
* finds nothing even if the signed state is still within its lifetime. */
|
|
469
|
+
takeUpstreamLogin(state) {
|
|
470
|
+
return this.mutate(() => {
|
|
471
|
+
const login = this.state.upstreamLogins[state];
|
|
472
|
+
if (!login)
|
|
473
|
+
return undefined;
|
|
474
|
+
delete this.state.upstreamLogins[state];
|
|
475
|
+
return login.expiresAt < Math.floor(Date.now() / 1000) ? undefined : login;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The key the hub signs outbound `private_key_jwt` assertions with.
|
|
480
|
+
*
|
|
481
|
+
* Deliberately *not* `jwt-key.pem`: that one signs access tokens the hub
|
|
482
|
+
* issues to its own clients, and a key that both mints local credentials and
|
|
483
|
+
* proves identity to a third party is one key doing two jobs. Created on
|
|
484
|
+
* first use, because most deployments never need it.
|
|
485
|
+
*/
|
|
486
|
+
get upstreamPrivateKey() {
|
|
487
|
+
if (!this.cachedUpstreamKey) {
|
|
488
|
+
if (!fs.existsSync(this.upstreamKeyPath)) {
|
|
489
|
+
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
490
|
+
fs.writeFileSync(this.upstreamKeyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
|
491
|
+
}
|
|
492
|
+
this.cachedUpstreamKey = crypto.createPrivateKey(fs.readFileSync(this.upstreamKeyPath, 'utf8'));
|
|
493
|
+
}
|
|
494
|
+
return this.cachedUpstreamKey;
|
|
495
|
+
}
|
|
496
|
+
/** The lifecycle limits this store was built with, for callers that need to
|
|
497
|
+
* report them. */
|
|
498
|
+
get clientLimits() {
|
|
499
|
+
return this.limits;
|
|
309
500
|
}
|
|
310
501
|
get cookieSecret() {
|
|
311
502
|
this.reloadIfChanged();
|
|
@@ -325,6 +516,205 @@ export class AuthStore {
|
|
|
325
516
|
this.pruneUnapprovedClients();
|
|
326
517
|
});
|
|
327
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Records a newly registered client, or answers false when the hub is full.
|
|
521
|
+
*
|
|
522
|
+
* Full means the ceiling is reached and every registration under it has been
|
|
523
|
+
* approved. Making room by dropping one of those would take a connector
|
|
524
|
+
* somebody is using offline — and an attacker who can register at will would
|
|
525
|
+
* be able to aim that at a specific victim. Refusing the newcomer is the
|
|
526
|
+
* failure the operator can see and fix.
|
|
527
|
+
*/
|
|
528
|
+
addClient(client, registrationToken, options = {}) {
|
|
529
|
+
return this.mutate(() => {
|
|
530
|
+
this.pruneUnderLock();
|
|
531
|
+
// An operator-created client is not subject to the ceiling either: it was
|
|
532
|
+
// asked for deliberately, and refusing it would be refusing the person
|
|
533
|
+
// who administers the hub.
|
|
534
|
+
if (!options.operatorManaged && !this.makeRoomForOneClient())
|
|
535
|
+
return false;
|
|
536
|
+
this.state.clients[client.client_id] = client;
|
|
537
|
+
this.state.clientLifecycle[client.client_id] = {
|
|
538
|
+
...(registrationToken ? { registrationTokenHash: AuthStore.hash(registrationToken) } : {}),
|
|
539
|
+
...(options.operatorManaged ? { operatorManaged: true } : {}),
|
|
540
|
+
lastActiveAt: Math.floor(Date.now() / 1000)
|
|
541
|
+
};
|
|
542
|
+
this.pruneUnapprovedClients();
|
|
543
|
+
return true;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/** True for a client the operator created by hand. */
|
|
547
|
+
isOperatorManaged(clientId) {
|
|
548
|
+
this.reloadIfChanged();
|
|
549
|
+
return this.state.clientLifecycle[clientId]?.operatorManaged === true;
|
|
550
|
+
}
|
|
551
|
+
/** Evicts never-approved registrations until one more fits, and reports
|
|
552
|
+
* whether it managed to. */
|
|
553
|
+
makeRoomForOneClient() {
|
|
554
|
+
const fits = () => Object.keys(this.state.clients).length < this.limits.maxClients;
|
|
555
|
+
if (fits())
|
|
556
|
+
return true;
|
|
557
|
+
const evictable = Object.values(this.state.clients)
|
|
558
|
+
.filter(candidate => !this.state.approvals[candidate.client_id] && !this.isOperatorManagedUnderLock(candidate.client_id))
|
|
559
|
+
.sort((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
|
|
560
|
+
for (const candidate of evictable) {
|
|
561
|
+
this.forgetClient(candidate.client_id);
|
|
562
|
+
if (fits())
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
return fits();
|
|
566
|
+
}
|
|
567
|
+
/** RFC 7592 update. Changing the redirect URIs withdraws the approval: it was
|
|
568
|
+
* given for a specific destination, and the client does not get to move that
|
|
569
|
+
* destination afterwards without being asked again. */
|
|
570
|
+
updateClient(client, options = {}) {
|
|
571
|
+
this.mutate(() => {
|
|
572
|
+
if (!this.state.clients[client.client_id])
|
|
573
|
+
return;
|
|
574
|
+
this.state.clients[client.client_id] = client;
|
|
575
|
+
if (options.resetApproval)
|
|
576
|
+
delete this.state.approvals[client.client_id];
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Removes a registration outright — the client, its approval, its refresh
|
|
580
|
+
* tokens and, so live access tokens stop working now rather than in fifteen
|
|
581
|
+
* minutes, a revocation marker. */
|
|
582
|
+
deleteClient(clientId) {
|
|
583
|
+
return this.mutate(() => {
|
|
584
|
+
if (!this.state.clients[clientId] && !this.state.approvals[clientId])
|
|
585
|
+
return false;
|
|
586
|
+
this.forgetClient(clientId);
|
|
587
|
+
delete this.state.approvals[clientId];
|
|
588
|
+
for (const [hash, record] of Object.entries(this.state.refreshTokens)) {
|
|
589
|
+
if (record.clientId === clientId)
|
|
590
|
+
delete this.state.refreshTokens[hash];
|
|
591
|
+
}
|
|
592
|
+
this.state.revokedBefore[clientId] = Date.now();
|
|
593
|
+
return true;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
/** The same question as isOperatorManaged(), for a caller that already holds
|
|
597
|
+
* the lock and must not re-read the file underneath itself. */
|
|
598
|
+
isOperatorManagedUnderLock(clientId) {
|
|
599
|
+
return this.state.clientLifecycle[clientId]?.operatorManaged === true;
|
|
600
|
+
}
|
|
601
|
+
forgetClient(clientId) {
|
|
602
|
+
delete this.state.clients[clientId];
|
|
603
|
+
delete this.state.clientLifecycle[clientId];
|
|
604
|
+
}
|
|
605
|
+
/** Notes that a client was used. Cheap to call on every request: it only
|
|
606
|
+
* reaches the disk once an hour, and never fails the caller. */
|
|
607
|
+
touchClient(clientId) {
|
|
608
|
+
try {
|
|
609
|
+
this.reloadIfChanged();
|
|
610
|
+
const entry = this.state.clientLifecycle[clientId];
|
|
611
|
+
if (!entry)
|
|
612
|
+
return; // a metadata-document client has no registration to age
|
|
613
|
+
const now = Math.floor(Date.now() / 1000);
|
|
614
|
+
if (now - entry.lastActiveAt < ACTIVITY_GRANULARITY_S)
|
|
615
|
+
return;
|
|
616
|
+
this.mutate(() => {
|
|
617
|
+
const current = this.state.clientLifecycle[clientId];
|
|
618
|
+
if (current)
|
|
619
|
+
current.lastActiveAt = now;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
// Bookkeeping must never be the reason an authorization fails.
|
|
624
|
+
console.warn(`mcp-hub: could not record activity for client ${clientId}: ${error.message}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Timing-safe check of an RFC 7592 registration access token. */
|
|
628
|
+
/**
|
|
629
|
+
* Attaches the hash of a registration access token to an existing client.
|
|
630
|
+
*
|
|
631
|
+
* The authorization server mints the token and only ever reveals it in the
|
|
632
|
+
* registration response, so the hash has to be recorded from there rather
|
|
633
|
+
* than at the moment the client record is written.
|
|
634
|
+
*/
|
|
635
|
+
rememberRegistrationToken(clientId, token) {
|
|
636
|
+
this.mutate(() => {
|
|
637
|
+
const entry = this.state.clientLifecycle[clientId];
|
|
638
|
+
if (entry)
|
|
639
|
+
entry.registrationTokenHash = AuthStore.hash(token);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
verifyRegistrationToken(clientId, token) {
|
|
643
|
+
this.reloadIfChanged();
|
|
644
|
+
const expected = this.state.clientLifecycle[clientId]?.registrationTokenHash;
|
|
645
|
+
if (!expected)
|
|
646
|
+
return false;
|
|
647
|
+
const given = AuthStore.hash(token);
|
|
648
|
+
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
|
|
649
|
+
}
|
|
650
|
+
/** What a prune would remove right now, without removing it. */
|
|
651
|
+
planClientPrune() {
|
|
652
|
+
this.reloadIfChanged();
|
|
653
|
+
const { pending, inactive } = this.classifyClients();
|
|
654
|
+
return { pending, inactive };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Drops registrations that were never confirmed and ones nobody has used in
|
|
658
|
+
* a long time. Only touches disk when there is something to do.
|
|
659
|
+
*
|
|
660
|
+
* Metadata-document clients are unaffected: they are never stored here, so
|
|
661
|
+
* the approval that is their only trace is left alone.
|
|
662
|
+
*/
|
|
663
|
+
pruneClients() {
|
|
664
|
+
this.reloadIfChanged();
|
|
665
|
+
const preview = this.classifyClients();
|
|
666
|
+
if (preview.pending.length === 0 && preview.inactive.length === 0 && preview.undated.length === 0) {
|
|
667
|
+
return { pending: [], inactive: [] };
|
|
668
|
+
}
|
|
669
|
+
return this.mutate(() => this.pruneUnderLock());
|
|
670
|
+
}
|
|
671
|
+
classifyClients() {
|
|
672
|
+
const now = Math.floor(Date.now() / 1000);
|
|
673
|
+
const pending = [];
|
|
674
|
+
const inactive = [];
|
|
675
|
+
const undated = [];
|
|
676
|
+
for (const clientId of Object.keys(this.state.clients)) {
|
|
677
|
+
const lifecycle = this.state.clientLifecycle[clientId];
|
|
678
|
+
if (!lifecycle) {
|
|
679
|
+
undated.push(clientId);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (lifecycle.operatorManaged)
|
|
683
|
+
continue;
|
|
684
|
+
const idle = now - lifecycle.lastActiveAt;
|
|
685
|
+
if (!this.state.approvals[clientId]) {
|
|
686
|
+
if (idle >= this.limits.pendingTtlSeconds)
|
|
687
|
+
pending.push(clientId);
|
|
688
|
+
}
|
|
689
|
+
else if (idle >= this.limits.inactiveSeconds) {
|
|
690
|
+
inactive.push(clientId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { pending, inactive, undated };
|
|
694
|
+
}
|
|
695
|
+
/** The sweep itself. The only implementation, so a registration and the
|
|
696
|
+
* periodic pass cannot drift apart in what they clean up. */
|
|
697
|
+
pruneUnderLock() {
|
|
698
|
+
const { pending, inactive, undated } = this.classifyClients();
|
|
699
|
+
// A state file written before activity was tracked has no clock for its
|
|
700
|
+
// clients. Starting that clock now, rather than reading it as "idle since
|
|
701
|
+
// registration", is what stops an upgrade from deleting every connector
|
|
702
|
+
// that happens to predate the inactivity window.
|
|
703
|
+
const now = Math.floor(Date.now() / 1000);
|
|
704
|
+
for (const clientId of undated)
|
|
705
|
+
this.state.clientLifecycle[clientId] = { lastActiveAt: now };
|
|
706
|
+
for (const clientId of pending)
|
|
707
|
+
this.forgetClient(clientId);
|
|
708
|
+
for (const clientId of inactive) {
|
|
709
|
+
this.forgetClient(clientId);
|
|
710
|
+
delete this.state.approvals[clientId];
|
|
711
|
+
for (const [hash, record] of Object.entries(this.state.refreshTokens)) {
|
|
712
|
+
if (record.clientId === clientId)
|
|
713
|
+
delete this.state.refreshTokens[hash];
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return { pending, inactive };
|
|
717
|
+
}
|
|
328
718
|
/**
|
|
329
719
|
* Evict the oldest never-approved clients once too many pile up. A client
|
|
330
720
|
* with an approval entry is one the operator confirmed and is kept; the just
|
|
@@ -332,10 +722,10 @@ export class AuthStore {
|
|
|
332
722
|
*/
|
|
333
723
|
pruneUnapprovedClients() {
|
|
334
724
|
const unapproved = Object.values(this.state.clients)
|
|
335
|
-
.filter(c => !this.state.approvals[c.client_id])
|
|
725
|
+
.filter(c => !this.state.approvals[c.client_id] && !this.isOperatorManagedUnderLock(c.client_id))
|
|
336
726
|
.sort((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
|
|
337
727
|
for (let i = 0; i < unapproved.length - MAX_UNAPPROVED_CLIENTS; i++) {
|
|
338
|
-
|
|
728
|
+
this.forgetClient(unapproved[i].client_id);
|
|
339
729
|
}
|
|
340
730
|
}
|
|
341
731
|
getApproval(clientId) {
|
|
@@ -459,5 +849,126 @@ export class AuthStore {
|
|
|
459
849
|
return true;
|
|
460
850
|
});
|
|
461
851
|
}
|
|
852
|
+
// --- oidc-provider artifacts ---------------------------------------------
|
|
853
|
+
//
|
|
854
|
+
// The backing store for the ten adapter models. Deliberately dumb: the
|
|
855
|
+
// library owns the payload shapes, so this layer only adds the two things it
|
|
856
|
+
// cannot do itself — the cross-process lock every other writer here obeys,
|
|
857
|
+
// and the revokedBefore cutoff below.
|
|
858
|
+
/**
|
|
859
|
+
* Artifacts are keyed by the HASH of their id, never by the id itself.
|
|
860
|
+
*
|
|
861
|
+
* With opaque access tokens the id IS the bearer token, and a refresh token
|
|
862
|
+
* or a registration access token is no different — storing them verbatim
|
|
863
|
+
* would turn read access to state.json into working credentials. The file was
|
|
864
|
+
* already sensitive (it holds the cookie secret), but it never used to hold
|
|
865
|
+
* anything an attacker could present as-is, and it should not start now.
|
|
866
|
+
*
|
|
867
|
+
* Lookups all go through here, and the two scans (`findByUid`,
|
|
868
|
+
* `revokeByGrantId`) match on payload fields rather than on the key, so
|
|
869
|
+
* nothing needs the plaintext back.
|
|
870
|
+
*/
|
|
871
|
+
static artifactKey(id) {
|
|
872
|
+
return AuthStore.hash(id);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Models whose id is a bearer credential someone presents back.
|
|
876
|
+
*
|
|
877
|
+
* Hashing the key alone would not be enough: the payload carries `jti`, and
|
|
878
|
+
* for an opaque token the jti IS the token. These are stored without it and
|
|
879
|
+
* get it back from the lookup id on the way out, which `find` always has.
|
|
880
|
+
* The others (Session, Interaction, Grant) are referenced by ids that are not
|
|
881
|
+
* credentials, and their jti has to survive a `findByUid` that never sees one.
|
|
882
|
+
*/
|
|
883
|
+
static CREDENTIAL_MODELS = new Set([
|
|
884
|
+
'AccessToken',
|
|
885
|
+
'RefreshToken',
|
|
886
|
+
'AuthorizationCode',
|
|
887
|
+
'RegistrationAccessToken',
|
|
888
|
+
'ClientCredentials',
|
|
889
|
+
'InitialAccessToken',
|
|
890
|
+
'DeviceCode',
|
|
891
|
+
'BackchannelAuthenticationRequest'
|
|
892
|
+
]);
|
|
893
|
+
oidcUpsert(model, id, payload, expiresInSeconds) {
|
|
894
|
+
this.mutate(() => {
|
|
895
|
+
const records = (this.state.oidcArtifacts[model] ??= {});
|
|
896
|
+
const key = AuthStore.artifactKey(id);
|
|
897
|
+
const stored = AuthStore.CREDENTIAL_MODELS.has(model) ? { ...payload, jti: undefined } : payload;
|
|
898
|
+
if (AuthStore.CREDENTIAL_MODELS.has(model))
|
|
899
|
+
delete stored.jti;
|
|
900
|
+
records[key] = {
|
|
901
|
+
payload: stored,
|
|
902
|
+
expiresAt: expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0,
|
|
903
|
+
...(records[key]?.consumedAt !== undefined ? { consumedAt: records[key].consumedAt } : {})
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Undefined for unknown, expired *and revoked* artifacts — the library treats
|
|
909
|
+
* every falsy return as "not found", so this one comparison is the whole of
|
|
910
|
+
* `mcp-hub-admin clients revoke`. It is why access tokens are opaque: a JWT
|
|
911
|
+
* is never stored, so it could not be reached here at all.
|
|
912
|
+
*/
|
|
913
|
+
oidcFind(model, id) {
|
|
914
|
+
this.reloadIfChanged();
|
|
915
|
+
const record = this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
916
|
+
if (!record)
|
|
917
|
+
return undefined;
|
|
918
|
+
if (record.expiresAt !== 0 && record.expiresAt < Math.floor(Date.now() / 1000))
|
|
919
|
+
return undefined;
|
|
920
|
+
const clientId = record.payload.clientId;
|
|
921
|
+
const issuedAt = record.payload.iat;
|
|
922
|
+
if (typeof clientId === 'string' && typeof issuedAt === 'number') {
|
|
923
|
+
const cutoff = this.state.revokedBefore[clientId];
|
|
924
|
+
if (cutoff !== undefined && issuedAt * 1000 < cutoff)
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
const payload = AuthStore.CREDENTIAL_MODELS.has(model) ? { ...record.payload, jti: id } : record.payload;
|
|
928
|
+
return record.consumedAt === undefined ? payload : { ...payload, consumed: record.consumedAt };
|
|
929
|
+
}
|
|
930
|
+
/** Sessions are looked up by `uid` as well as by id. */
|
|
931
|
+
oidcFindBy(model, field, value) {
|
|
932
|
+
this.reloadIfChanged();
|
|
933
|
+
for (const record of Object.values(this.state.oidcArtifacts[model] ?? {})) {
|
|
934
|
+
if (record.payload[field] !== value)
|
|
935
|
+
continue;
|
|
936
|
+
if (record.expiresAt !== 0 && record.expiresAt < Math.floor(Date.now() / 1000))
|
|
937
|
+
return undefined;
|
|
938
|
+
return record.payload;
|
|
939
|
+
}
|
|
940
|
+
return undefined;
|
|
941
|
+
}
|
|
942
|
+
oidcConsume(model, id) {
|
|
943
|
+
this.mutate(() => {
|
|
944
|
+
const record = this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
945
|
+
if (record)
|
|
946
|
+
record.consumedAt = Math.floor(Date.now() / 1000);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
oidcDestroy(model, id) {
|
|
950
|
+
this.mutate(() => {
|
|
951
|
+
delete this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Scanned rather than indexed. A second index would be a third thing to keep
|
|
956
|
+
* consistent across a crash between two writes, and these maps hold hundreds
|
|
957
|
+
* of rows, not millions.
|
|
958
|
+
*/
|
|
959
|
+
oidcRevokeByGrantId(grantId) {
|
|
960
|
+
return this.mutate(() => {
|
|
961
|
+
let revoked = 0;
|
|
962
|
+
for (const records of Object.values(this.state.oidcArtifacts)) {
|
|
963
|
+
for (const [id, record] of Object.entries(records)) {
|
|
964
|
+
if (record.payload.grantId === grantId) {
|
|
965
|
+
delete records[id];
|
|
966
|
+
revoked++;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return revoked;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
462
973
|
}
|
|
463
974
|
//# sourceMappingURL=store.js.map
|