@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +619 -0
  2. package/README.md +189 -64
  3. package/dist/admin.js +232 -8
  4. package/dist/admin.js.map +1 -1
  5. package/dist/auth/address.js +89 -0
  6. package/dist/auth/address.js.map +1 -0
  7. package/dist/auth/api-tokens.js +27 -0
  8. package/dist/auth/api-tokens.js.map +1 -0
  9. package/dist/auth/cimd.js +367 -0
  10. package/dist/auth/cimd.js.map +1 -0
  11. package/dist/auth/consent-page.js +4 -7
  12. package/dist/auth/consent-page.js.map +1 -1
  13. package/dist/auth/headers.js +22 -1
  14. package/dist/auth/headers.js.map +1 -1
  15. package/dist/auth/login-page.js +4 -7
  16. package/dist/auth/login-page.js.map +1 -1
  17. package/dist/auth/oidc/adapter.js +135 -0
  18. package/dist/auth/oidc/adapter.js.map +1 -0
  19. package/dist/auth/oidc/interactions.js +187 -0
  20. package/dist/auth/oidc/interactions.js.map +1 -0
  21. package/dist/auth/oidc/mount.js +144 -0
  22. package/dist/auth/oidc/mount.js.map +1 -0
  23. package/dist/auth/oidc/provider.js +440 -0
  24. package/dist/auth/oidc/provider.js.map +1 -0
  25. package/dist/auth/oidc/quirks.js +234 -0
  26. package/dist/auth/oidc/quirks.js.map +1 -0
  27. package/dist/auth/oidc/verifier.js +121 -0
  28. package/dist/auth/oidc/verifier.js.map +1 -0
  29. package/dist/auth/page.js +22 -0
  30. package/dist/auth/page.js.map +1 -1
  31. package/dist/auth/pinned-fetch.js +129 -0
  32. package/dist/auth/pinned-fetch.js.map +1 -0
  33. package/dist/auth/protected-resource.js +41 -0
  34. package/dist/auth/protected-resource.js.map +1 -0
  35. package/dist/auth/rate-limit.js +156 -0
  36. package/dist/auth/rate-limit.js.map +1 -0
  37. package/dist/auth/redirect-uri.js +90 -0
  38. package/dist/auth/redirect-uri.js.map +1 -0
  39. package/dist/auth/registration.js +146 -0
  40. package/dist/auth/registration.js.map +1 -0
  41. package/dist/auth/session.js +43 -0
  42. package/dist/auth/session.js.map +1 -0
  43. package/dist/auth/signed-token.js +49 -0
  44. package/dist/auth/signed-token.js.map +1 -0
  45. package/dist/auth/store.js +516 -5
  46. package/dist/auth/store.js.map +1 -1
  47. package/dist/auth/text.js +51 -0
  48. package/dist/auth/text.js.map +1 -0
  49. package/dist/config.js +184 -5
  50. package/dist/config.js.map +1 -1
  51. package/dist/docker-proxy/policy.js +1 -0
  52. package/dist/docker-proxy/policy.js.map +1 -1
  53. package/dist/docker-proxy/server.js +1 -0
  54. package/dist/docker-proxy/server.js.map +1 -1
  55. package/dist/elicitation.js +0 -0
  56. package/dist/elicitation.js.map +1 -0
  57. package/dist/forward.js +0 -0
  58. package/dist/forward.js.map +1 -0
  59. package/dist/health.js +22 -3
  60. package/dist/health.js.map +1 -1
  61. package/dist/hub.js +337 -29
  62. package/dist/hub.js.map +1 -1
  63. package/dist/index.js +218 -23
  64. package/dist/index.js.map +1 -1
  65. package/dist/limits.js +13 -1
  66. package/dist/limits.js.map +1 -1
  67. package/dist/mcp-limits.js +14 -2
  68. package/dist/mcp-limits.js.map +1 -1
  69. package/dist/proxy.js +262 -34
  70. package/dist/proxy.js.map +1 -1
  71. package/dist/stdio.js +97 -7
  72. package/dist/stdio.js.map +1 -1
  73. package/dist/subscriptions.js +236 -0
  74. package/dist/subscriptions.js.map +1 -0
  75. package/dist/supervisor.js +472 -28
  76. package/dist/supervisor.js.map +1 -1
  77. package/dist/timings.js +61 -0
  78. package/dist/timings.js.map +1 -0
  79. package/dist/tool-filter.js +59 -0
  80. package/dist/tool-filter.js.map +1 -0
  81. package/dist/transports/docker.js.map +1 -1
  82. package/dist/transports/stream.js +33 -20
  83. package/dist/transports/stream.js.map +1 -1
  84. package/dist/upstream/auth.js +435 -0
  85. package/dist/upstream/auth.js.map +1 -0
  86. package/dist/upstream/login.js +94 -0
  87. package/dist/upstream/login.js.map +1 -0
  88. package/dist/upstream/provider.js +286 -0
  89. package/dist/upstream/provider.js.map +1 -0
  90. package/dist/upstream/routes.js +97 -0
  91. package/dist/upstream/routes.js.map +1 -0
  92. package/package.json +23 -7
  93. package/dist/auth/provider.js +0 -380
  94. package/dist/auth/provider.js.map +0 -1
  95. package/dist/auth/routes.js +0 -223
  96. package/dist/auth/routes.js.map +0 -1
@@ -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
- delete this.state.clients[unapproved[i].client_id];
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