@ni-c/mcp-hub 0.10.0 → 0.11.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/README.md +79 -12
  3. package/dist/admin.js +1 -1
  4. package/dist/auth/api-tokens.js +27 -0
  5. package/dist/auth/headers.js +22 -1
  6. package/dist/auth/oidc/adapter.js +135 -0
  7. package/dist/auth/oidc/interactions.js +187 -0
  8. package/dist/auth/oidc/mount.js +144 -0
  9. package/dist/auth/oidc/provider.js +440 -0
  10. package/dist/auth/oidc/quirks.js +234 -0
  11. package/dist/auth/oidc/verifier.js +121 -0
  12. package/dist/auth/protected-resource.js +41 -0
  13. package/dist/auth/rate-limit.js +115 -2
  14. package/dist/auth/redirect-uri.js +33 -1
  15. package/dist/auth/registration.js +1 -1
  16. package/dist/auth/session.js +43 -0
  17. package/dist/auth/store.js +160 -1
  18. package/dist/config.js +55 -2
  19. package/dist/docker-proxy/policy.js +1 -0
  20. package/dist/docker-proxy/server.js +1 -0
  21. package/dist/elicitation.js +0 -0
  22. package/dist/forward.js +0 -0
  23. package/dist/hub.js +309 -29
  24. package/dist/index.js +103 -21
  25. package/dist/limits.js +13 -1
  26. package/dist/mcp-limits.js +14 -2
  27. package/dist/proxy.js +246 -40
  28. package/dist/stdio.js +71 -6
  29. package/dist/subscriptions.js +236 -0
  30. package/dist/supervisor.js +322 -22
  31. package/dist/timings.js +61 -0
  32. package/dist/tool-filter.js +1 -0
  33. package/dist/transports/stream.js +33 -20
  34. package/dist/upstream/auth.js +1 -1
  35. package/dist/upstream/routes.js +3 -2
  36. package/package.json +20 -6
  37. package/dist/admin.js.map +0 -1
  38. package/dist/auth/address.js.map +0 -1
  39. package/dist/auth/cimd.js.map +0 -1
  40. package/dist/auth/consent-page.js.map +0 -1
  41. package/dist/auth/headers.js.map +0 -1
  42. package/dist/auth/login-page.js.map +0 -1
  43. package/dist/auth/page.js.map +0 -1
  44. package/dist/auth/pinned-fetch.js.map +0 -1
  45. package/dist/auth/private-key-jwt.js +0 -213
  46. package/dist/auth/private-key-jwt.js.map +0 -1
  47. package/dist/auth/provider.js +0 -437
  48. package/dist/auth/provider.js.map +0 -1
  49. package/dist/auth/rate-limit.js.map +0 -1
  50. package/dist/auth/redirect-uri.js.map +0 -1
  51. package/dist/auth/registration.js.map +0 -1
  52. package/dist/auth/resource.js.map +0 -1
  53. package/dist/auth/routes.js +0 -249
  54. package/dist/auth/routes.js.map +0 -1
  55. package/dist/auth/signed-token.js.map +0 -1
  56. package/dist/auth/store.js.map +0 -1
  57. package/dist/auth/text.js.map +0 -1
  58. package/dist/config.js.map +0 -1
  59. package/dist/docker-proxy/index.js.map +0 -1
  60. package/dist/docker-proxy/policy.js.map +0 -1
  61. package/dist/docker-proxy/secrets-watcher.js.map +0 -1
  62. package/dist/docker-proxy/secrets.js.map +0 -1
  63. package/dist/docker-proxy/server.js.map +0 -1
  64. package/dist/health.js.map +0 -1
  65. package/dist/hub.js.map +0 -1
  66. package/dist/index.js.map +0 -1
  67. package/dist/limits.js.map +0 -1
  68. package/dist/logfile.js.map +0 -1
  69. package/dist/main-module.js.map +0 -1
  70. package/dist/mcp-limits.js.map +0 -1
  71. package/dist/mount-check.js.map +0 -1
  72. package/dist/proxy.js.map +0 -1
  73. package/dist/sandbox/container-spec.js.map +0 -1
  74. package/dist/sandbox/docker-client.js.map +0 -1
  75. package/dist/sandbox/policy-protocol.js.map +0 -1
  76. package/dist/stdio.js.map +0 -1
  77. package/dist/supervisor.js.map +0 -1
  78. package/dist/tool-cache.js.map +0 -1
  79. package/dist/tool-filter.js.map +0 -1
  80. package/dist/transports/docker.js.map +0 -1
  81. package/dist/transports/socket.js.map +0 -1
  82. package/dist/transports/stream.js.map +0 -1
  83. package/dist/upstream/auth.js.map +0 -1
  84. package/dist/upstream/login.js.map +0 -1
  85. package/dist/upstream/provider.js.map +0 -1
  86. package/dist/upstream/routes.js.map +0 -1
  87. package/dist/version.js.map +0 -1
@@ -0,0 +1,234 @@
1
+ import crypto from 'node:crypto';
2
+ import { Readable } from 'node:stream';
3
+ /**
4
+ * The one scope the hub knows. Authorization is per client, not per scope —
5
+ * what a token may reach is decided by the resource it is bound to — but
6
+ * oidc-provider requires a non-empty granted∩requested set, so the hub needs a
7
+ * name for "everything this client was approved for". See `defaultScope`.
8
+ */
9
+ export const HUB_SCOPE = 'mcp';
10
+ /**
11
+ * Four deliberate concessions to two real clients. Every one of them is
12
+ * load-bearing: three are pinned by test/client-compat.test.ts because a real
13
+ * connector broke without them, and the fourth was found by running a real
14
+ * authorization flow against oidc-provider.
15
+ *
16
+ * 1. `client_secret_expires_at: 0` for every client. ChatGPT registers once
17
+ * per connector and never re-registers, so an expiring secret bricks it.
18
+ * Needs no code: registration.js hardcodes 0 and forbids changing it.
19
+ *
20
+ * 2. A throwaway `client_secret` for PUBLIC clients — below.
21
+ * 3. Refresh tokens without `offline_access` — in provider.ts.
22
+ * 4. Authorization with no `scope` parameter — `defaultScope` below.
23
+ */
24
+ /**
25
+ * Quirk 2. ChatGPT refuses its own registration unless the response carries a
26
+ * `client_secret`, even for `token_endpoint_auth_method: 'none'`. Claude is
27
+ * correct and sends none, so the secret must exist in the response and NOT in
28
+ * the stored record — otherwise every well-behaved public client breaks.
29
+ *
30
+ * oidc-provider already gets the storage half right: `Client.needsSecret` is
31
+ * false for 'none', so registration.js deletes the secret before persisting.
32
+ * Only the response needs fixing, and `provider.use()` is the documented place:
33
+ * it installs middleware ahead of the router, so `await next()` returns after
34
+ * ctx.body was set and before Koa serialises it.
35
+ *
36
+ * Not `features.registration.policies` and not `extraClientMetadata`: both run
37
+ * on the properties *before* they are persisted, so both would store the secret
38
+ * and defeat the whole point.
39
+ */
40
+ /**
41
+ * Quirk 2, storage half. Keeps a public client's record free of a secret.
42
+ *
43
+ * The comment above says oidc-provider gets this half right on its own. It does
44
+ * not, and the reason is an ordering the library is entitled to: RFC 7591 says
45
+ * an omitted `token_endpoint_auth_method` means `client_secret_basic`, so
46
+ * `Client.needsSecret` — which runs on the metadata as SENT — mints and
47
+ * persists a real secret for every client that leaves the field out. The hub's
48
+ * `clientDefaults` then rewrites the method to `none`, and the record ends up
49
+ * claiming to be a public client while carrying a credential.
50
+ *
51
+ * Leaving the field out is what Claude and ChatGPT both do, so this is the
52
+ * common case rather than a corner of it. Two things follow, and neither is
53
+ * visible from the outside:
54
+ *
55
+ * - `state.json` gains a value someone could present, which is the one thing
56
+ * `AuthStore` is careful never to store (tokens live there as hashes).
57
+ * - `stripPhantomSecret` below gates on the stored client having NO secret,
58
+ * so it stops firing for exactly the clients it was written for: a
59
+ * connector that echoes the secret from its registration response gets
60
+ * `401 invalid_client`, which is the failure quirk 2 exists to prevent.
61
+ *
62
+ * The response is left alone — it still carries a secret, which is what
63
+ * ChatGPT insists on. Only the record is cleaned.
64
+ */
65
+ export function withoutPhantomSecret(payload) {
66
+ if (payload.token_endpoint_auth_method !== 'none' || payload.client_secret === undefined)
67
+ return payload;
68
+ // An HMAC response algorithm signs with the secret, so a client that declared
69
+ // one needs it kept even though its auth method does not use it.
70
+ const usesHmac = Object.entries(payload).some(([key, value]) => key.endsWith('_signed_response_alg') && typeof value === 'string' && value.startsWith('HS'));
71
+ if (usesHmac)
72
+ return payload;
73
+ const { client_secret: _secret, client_secret_expires_at: _expires, ...rest } = payload;
74
+ return rest;
75
+ }
76
+ export function installThrowawaySecret(provider, store) {
77
+ provider.use(async (ctx, next) => {
78
+ await next();
79
+ const body = ctx.body;
80
+ if (ctx.oidc?.route !== 'registration' || ctx.status !== 201 || !body)
81
+ return;
82
+ if (body.token_endpoint_auth_method === 'none' && body.client_secret === undefined) {
83
+ body.client_secret = crypto.randomBytes(32).toString('base64url');
84
+ body.client_secret_expires_at = 0;
85
+ }
86
+ // The only moment the registration access token is visible. The hub stores
87
+ // just its hash, which is what lets RFC 7592 management stay on mcp-hub's
88
+ // own, stricter implementation.
89
+ if (store && typeof body.client_id === 'string' && typeof body.registration_access_token === 'string') {
90
+ store.rememberRegistrationToken(body.client_id, body.registration_access_token);
91
+ }
92
+ });
93
+ }
94
+ /**
95
+ * RFC 8414 lists `revocation_endpoint_auth_methods_supported`, the hub
96
+ * advertised it, and oidc-provider does not emit it at all. A client that reads
97
+ * the document to decide how to authenticate at /revoke would find nothing
98
+ * where there used to be an answer.
99
+ *
100
+ * Same mechanism as the throwaway secret: middleware installed ahead of the
101
+ * router, so `ctx.body` is still a plain object when it returns.
102
+ */
103
+ export function installDiscoveryFixups(provider) {
104
+ provider.use(async (ctx, next) => {
105
+ await next();
106
+ const body = ctx.body;
107
+ if (ctx.oidc?.route === 'discovery' && ctx.status === 200 && body?.revocation_endpoint) {
108
+ body.revocation_endpoint_auth_methods_supported = ['client_secret_post', 'none', 'private_key_jwt'];
109
+ }
110
+ });
111
+ }
112
+ /**
113
+ * Quirk 4. MCP clients send no `scope` parameter at all.
114
+ *
115
+ * actions/authorization/interactions.js filters the GRANTED scopes by the
116
+ * REQUESTED ones and throws `access_denied` when the intersection is empty —
117
+ * so with no scope requested the flow dead-ends no matter what was granted.
118
+ * The hub issues scopeless, resource-bound tokens today, so defaulting the
119
+ * scope preserves current behaviour; demanding one from the clients would not,
120
+ * because they will never send it.
121
+ *
122
+ * A client that DOES ask for something is left alone.
123
+ */
124
+ export const defaultScope = (req, _res, next) => {
125
+ const url = new URL(req.url, 'http://placeholder.invalid');
126
+ if (!url.searchParams.get('scope')) {
127
+ url.searchParams.set('scope', HUB_SCOPE);
128
+ req.url = `${url.pathname}${url.search}`;
129
+ }
130
+ next();
131
+ };
132
+ /**
133
+ * The most a `/token` body may weigh before it is refused unread.
134
+ *
135
+ * Exactly oidc-provider's own ceiling (shared/selective_body.js), because this
136
+ * middleware reads the stream the library would otherwise read itself: anything
137
+ * above it was never going to be accepted, so capping here changes nothing a
138
+ * legitimate client does — and without the cap the read below is unbounded,
139
+ * which is the one thing a request stream must never be.
140
+ */
141
+ const MAX_TOKEN_BODY_BYTES = 56 * 1024;
142
+ /**
143
+ * Restores the hub's existing tolerance for a client that presents the
144
+ * throwaway secret from quirk 2 back at the token endpoint.
145
+ *
146
+ * The SDK the hub used before gated on the STORED client having a secret
147
+ * (clientAuth.js: `if (client.client_secret)`), and public clients have none,
148
+ * so a presented secret was ignored. oidc-provider gates on the PRESENTED one
149
+ * (client_auth.js: any client_secret selects client_secret_basic/post) and then
150
+ * hard-fails with `401 invalid_client` because the record says 'none'.
151
+ *
152
+ * Whether ChatGPT actually echoes the secret is unknown and unknowable from the
153
+ * outside — which is the reason to be tolerant rather than to find out from a
154
+ * bug report. `ctx.oidc.params` is built inside the router, after every
155
+ * provider.use() middleware, so the raw body before the mount is the only place
156
+ * this can happen.
157
+ *
158
+ * Reading the body here is what makes the cap above load-bearing. `/token` is
159
+ * unauthenticated by nature — the credentials are IN the body — so an
160
+ * unbounded read is a way for anyone to spend the hub's memory, and the
161
+ * per-path rate limit still allows fifty of them per address per window. Every
162
+ * other provider path keeps the library's own bounded reader; only this one
163
+ * takes the stream away from it, so only this one has to bring the ceiling
164
+ * with it.
165
+ */
166
+ export function stripPhantomSecret(store, callback) {
167
+ return (req, res, next) => {
168
+ // Anything that is not a POST still belongs to the provider, which answers
169
+ // 405. Handing it to next() instead would drop it into the hub's catch-all
170
+ // and turn a documented endpoint into a 404.
171
+ if (req.method !== 'POST') {
172
+ callback(req, res);
173
+ return;
174
+ }
175
+ // A declared length above the ceiling is refused without reading a byte;
176
+ // an absent or lying one is caught while reading.
177
+ const declared = Number(req.headers['content-length']);
178
+ if (Number.isFinite(declared) && declared > MAX_TOKEN_BODY_BYTES) {
179
+ refuseOversizedBody(req, res);
180
+ return;
181
+ }
182
+ void (async () => {
183
+ try {
184
+ const chunks = [];
185
+ let size = 0;
186
+ for await (const chunk of req) {
187
+ size += chunk.length;
188
+ if (size > MAX_TOKEN_BODY_BYTES) {
189
+ refuseOversizedBody(req, res);
190
+ return;
191
+ }
192
+ chunks.push(chunk);
193
+ }
194
+ const params = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
195
+ const clientId = params.get('client_id');
196
+ if (params.get('client_secret') && clientId) {
197
+ const client = store.getClient(clientId);
198
+ // A public client never had a secret, so whatever was presented is
199
+ // the throwaway one the hub itself handed out.
200
+ if (client && client.client_secret === undefined)
201
+ params.delete('client_secret');
202
+ }
203
+ const body = Buffer.from(params.toString());
204
+ const shim = Readable.from([body]);
205
+ Object.assign(shim, {
206
+ headers: { ...req.headers, 'content-length': String(body.length) },
207
+ method: req.method,
208
+ url: req.url,
209
+ socket: req.socket,
210
+ httpVersion: req.httpVersion
211
+ });
212
+ callback(shim, res);
213
+ }
214
+ catch (error) {
215
+ next(error);
216
+ }
217
+ })();
218
+ };
219
+ }
220
+ /**
221
+ * The same answer oidc-provider gives a body it will not read, so a client
222
+ * cannot tell whether the ceiling was enforced here or one layer down.
223
+ *
224
+ * The connection is closed rather than drained: the sender is still writing,
225
+ * and reading the rest to keep it alive would defeat the point of refusing.
226
+ */
227
+ function refuseOversizedBody(req, res) {
228
+ const body = JSON.stringify({ error: 'invalid_request', error_description: 'failed to parse the request body' });
229
+ res.status(400);
230
+ res.set({ 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(Buffer.byteLength(body)), Connection: 'close' });
231
+ res.end(body);
232
+ req.destroy();
233
+ }
234
+ //# sourceMappingURL=quirks.js.map
@@ -0,0 +1,121 @@
1
+ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
2
+ import { jwtVerify } from 'jose';
3
+ import { API_TOKEN_SUBJECT } from '../api-tokens.js';
4
+ /**
5
+ * The one error shape `requireBearerAuth` recognises.
6
+ *
7
+ * It classifies whatever the verifier throws: an `OAuthError` carrying
8
+ * `invalid_token` becomes a 401 with a `WWW-Authenticate` challenge pointing at
9
+ * the protected-resource metadata, and anything else becomes a 500. SDK v1 had
10
+ * a class per code; v2 has one class and an enum, so the distinction now lives
11
+ * in the argument rather than in the constructor — which is exactly the kind of
12
+ * thing that fails silently, by answering 500 to a merely expired token and
13
+ * leaving the client with nothing to discover the authorization server from.
14
+ */
15
+ function invalidToken(message) {
16
+ return new OAuthError(OAuthErrorCode.InvalidToken, message);
17
+ }
18
+ /**
19
+ * Verifies the two token shapes the hub hands out, which are deliberately not
20
+ * the same shape.
21
+ *
22
+ * OAuth access tokens are **opaque**. That is what makes revocation immediate:
23
+ * the value is the record's id, so the lookup below is the enforcement point,
24
+ * and `AuthStore.oidcFind` refuses anything past its expiry or minted before a
25
+ * `revokedBefore` cutoff. A JWT could not be revoked at all — oidc-provider
26
+ * never persists one.
27
+ *
28
+ * Admin-minted API tokens stay **JWTs**, signed with the hub's own key. They
29
+ * are not OAuth artifacts: `mcp-hub-admin tokens create` issues them for
30
+ * clients that cannot do OAuth at all, and only their record (jti) is stored,
31
+ * which is what `tokens revoke` deletes.
32
+ *
33
+ * Order matters. Opaque is tried first because it is the common case and needs
34
+ * no cryptography; a value that is not a stored token then gets exactly one
35
+ * signature check. Neither branch may report why it failed — an attacker must
36
+ * not be able to tell "unknown" from "revoked" from "wrong audience".
37
+ */
38
+ export class OidcTokenVerifier {
39
+ store;
40
+ options;
41
+ constructor(store, options) {
42
+ this.store = store;
43
+ this.options = options;
44
+ }
45
+ async verifyAccessToken(token) {
46
+ const opaque = this.store.oidcFind('AccessToken', token);
47
+ if (opaque)
48
+ return this.fromOpaque(token, opaque);
49
+ return this.fromApiToken(token);
50
+ }
51
+ fromOpaque(token, payload) {
52
+ const clientId = payload.clientId;
53
+ if (typeof clientId !== 'string')
54
+ throw invalidToken('Invalid access token claims');
55
+ // An audience equal to the issuer means "not bound to one resource" — the
56
+ // pre-0.5 shape, kept so a deployment that never turned binding on is not
57
+ // logged out by an upgrade. It passes only while binding is not enforced;
58
+ // the per-route check is what narrows it.
59
+ const audience = typeof payload.aud === 'string' ? payload.aud : undefined;
60
+ let resource;
61
+ if (audience !== undefined && audience !== this.options.externalUrl) {
62
+ resource = this.resolve(audience);
63
+ if (!resource)
64
+ throw invalidToken('Invalid token audience');
65
+ }
66
+ else if (this.options.requireResource) {
67
+ throw invalidToken('Invalid token audience');
68
+ }
69
+ const scope = typeof payload.scope === 'string' ? payload.scope : '';
70
+ return {
71
+ token,
72
+ clientId,
73
+ scopes: scope ? scope.split(' ') : [],
74
+ ...(typeof payload.exp === 'number' ? { expiresAt: payload.exp } : {}),
75
+ ...(resource ? { resource } : {})
76
+ };
77
+ }
78
+ async fromApiToken(token) {
79
+ let payload;
80
+ try {
81
+ ({ payload } = await jwtVerify(token, this.store.publicKey, {
82
+ issuer: this.options.externalUrl,
83
+ algorithms: ['EdDSA']
84
+ }));
85
+ }
86
+ catch {
87
+ throw invalidToken('Invalid or expired access token');
88
+ }
89
+ // Only API tokens are JWTs now. An OAuth token in this shape is one the
90
+ // previous authorization server minted; refusing it is what makes clients
91
+ // authorize once against the new one instead of silently keeping a
92
+ // credential nothing can revoke.
93
+ if (payload.sub !== API_TOKEN_SUBJECT)
94
+ throw invalidToken('Invalid access token claims');
95
+ if (typeof payload.jti !== 'string' || !this.store.getApiToken(payload.jti)) {
96
+ throw invalidToken('Access token has been revoked');
97
+ }
98
+ const audience = typeof payload.aud === 'string' ? payload.aud : undefined;
99
+ const resource = audience ? this.resolve(audience) : undefined;
100
+ if (!resource)
101
+ throw invalidToken('Invalid token audience');
102
+ return {
103
+ token,
104
+ clientId: `token:${payload.jti}`,
105
+ scopes: [],
106
+ ...(payload.exp !== undefined ? { expiresAt: payload.exp } : {}),
107
+ resource
108
+ };
109
+ }
110
+ resolve(audience) {
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(audience);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ return this.options.resolveResource(parsed);
119
+ }
120
+ }
121
+ //# sourceMappingURL=verifier.js.map
@@ -0,0 +1,41 @@
1
+ import { Router } from 'express';
2
+ import { authSecurityHeaders } from './headers.js';
3
+ /**
4
+ * RFC 9728 Protected Resource Metadata.
5
+ *
6
+ * This is the RESOURCE SERVER's document, not the authorization server's, which
7
+ * is why it lives outside `createAuthRoutes` and survives whichever
8
+ * authorization server the hub is running. Losing it would take out discovery
9
+ * for every client, and it is the one piece of the auth surface that is not
10
+ * oidc-provider's to serve.
11
+ *
12
+ * Two shapes, both required: the root document the SDK used to publish, and the
13
+ * path-scoped form (§3.1) a client connected to `/<name>/mcp` looks up first.
14
+ */
15
+ export function createProtectedResourceRoutes(options) {
16
+ const router = Router();
17
+ const issuerUrl = new URL(options.externalUrl);
18
+ const resourceName = options.resourceName ?? 'mcp-hub';
19
+ // These used to be served from inside the auth router and inherited its
20
+ // headers; pulled out here, they would have quietly lost them. A discovery
21
+ // document is exactly the thing a shared cache should not keep.
22
+ router.use(authSecurityHeaders);
23
+ router.get('/.well-known/oauth-protected-resource', (_req, res) => {
24
+ res.json({
25
+ resource: issuerUrl.href,
26
+ authorization_servers: [options.externalUrl],
27
+ resource_name: resourceName
28
+ });
29
+ });
30
+ router.get('/.well-known/oauth-protected-resource/{*splat}', (req, res) => {
31
+ const suffix = req.path.replace('/.well-known/oauth-protected-resource', '');
32
+ res.json({
33
+ resource: issuerUrl.origin + suffix,
34
+ authorization_servers: [options.externalUrl],
35
+ bearer_methods_supported: ['header'],
36
+ resource_name: resourceName
37
+ });
38
+ });
39
+ return router;
40
+ }
41
+ //# sourceMappingURL=protected-resource.js.map
@@ -1,5 +1,46 @@
1
+ import net from 'node:net';
1
2
  /**
2
- * A per-IP request budget that rejects before any body is read.
3
+ * The unit a budget is counted against.
4
+ *
5
+ * Not the address. One IPv6 address is not one caller: the smallest block
6
+ * handed to a single subscriber is a /64, and a residential line usually gets a
7
+ * /56 or /48 on top of that. A limiter keyed on the full address therefore
8
+ * counts a single host as billions of distinct callers, and every per-address
9
+ * budget in this file becomes decorative — a /64 walks around all of them
10
+ * without any infrastructure at all.
11
+ *
12
+ * IPv4 keeps its own address, where one address really is roughly one caller,
13
+ * and an IPv4-mapped form (`::ffff:1.2.3.4`, what a dual-stack listener reports
14
+ * for an IPv4 peer) is folded back onto it so the same client is not counted in
15
+ * two places depending on how the socket was opened.
16
+ *
17
+ * Only the KEY is bucketed. Log lines keep the full address — fail2ban bans
18
+ * what it is given, and it should be given the host that actually connected.
19
+ */
20
+ export function rateLimitKey(ip) {
21
+ const bare = ip.replace(/^\[|\]$/g, '').split('%')[0];
22
+ if (net.isIPv4(bare))
23
+ return bare;
24
+ if (!net.isIPv6(bare))
25
+ return ip; // 'unknown', or something we cannot parse
26
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(bare);
27
+ if (mapped)
28
+ return mapped[1];
29
+ return `${expandIPv6(bare).slice(0, 4).join(':')}::/64`;
30
+ }
31
+ /** The eight hextets of an IPv6 address, with `::` filled back in. */
32
+ function expandIPv6(address) {
33
+ const [head, tail] = address.toLowerCase().split('::');
34
+ const left = head ? head.split(':') : [];
35
+ const right = tail ? tail.split(':') : [];
36
+ const missing = 8 - left.length - right.length;
37
+ const groups = [...left, ...Array.from({ length: Math.max(0, missing) }, () => '0'), ...right];
38
+ // Leading zeros are not part of the value; without this `2001:0db8:…` and
39
+ // `2001:db8:…` would be two different keys for one network.
40
+ return groups.map(group => (Number.parseInt(group, 16) || 0).toString(16));
41
+ }
42
+ /**
43
+ * A per-caller request budget that rejects before any body is read.
3
44
  *
4
45
  * The SDK applies its own limits to the OAuth endpoints, but only after body
5
46
  * parsing — which is the expensive part. This runs first, and refuses without
@@ -13,7 +54,7 @@ export function earlyRateLimit(windowMs, maxPerIp, maxTotal) {
13
54
  const now = Date.now();
14
55
  if (total.resetAt <= now)
15
56
  total = { count: 0, resetAt: now + windowMs };
16
- const ip = req.ip ?? 'unknown';
57
+ const ip = rateLimitKey(req.ip ?? 'unknown');
17
58
  let entry = byIp.get(ip);
18
59
  if (entry && entry.resetAt <= now) {
19
60
  byIp.delete(ip);
@@ -40,4 +81,76 @@ export function earlyRateLimit(windowMs, maxPerIp, maxTotal) {
40
81
  next();
41
82
  };
42
83
  }
84
+ const LOGIN_MAX_ATTEMPTS = 10;
85
+ const LOGIN_WINDOW_MS = 15 * 60_000;
86
+ /** A global ceiling as well, so a distributed guess cannot spend the per-address
87
+ * budget many times over. */
88
+ const LOGIN_MAX_ATTEMPTS_TOTAL = 100;
89
+ /**
90
+ * Per-caller lockout for the password form.
91
+ *
92
+ * Separate from `earlyRateLimit` because it counts FAILURES, not requests: a
93
+ * correct login resets the counter, so someone who knows the password is never
94
+ * locked out by someone else guessing from the same address.
95
+ */
96
+ export class LoginRateLimiter {
97
+ attempts = new Map();
98
+ total = { count: 0, resetAt: 0 };
99
+ /**
100
+ * Whether this caller has spent its budget.
101
+ *
102
+ * The global ceiling refuses the callers that are doing the guessing, and
103
+ * only those. It used to refuse everyone, which turned an attacker's cheapest
104
+ * possible traffic into a lockout of the only administrative way in: a
105
+ * hundred wrong passwords — under a second of work, and renewable every
106
+ * fifteen minutes — left the operator holding the correct password and
107
+ * getting 429 from an address that had never been near the form.
108
+ *
109
+ * What the ceiling is for survives the change. A caller that guesses is
110
+ * counted, and once the hub as a whole is under a distributed attempt that
111
+ * caller is refused on its FIRST failure instead of its tenth — so the total
112
+ * number of guesses still collapses, and it collapses hardest for exactly the
113
+ * addresses doing the guessing. What no longer happens is that they can spend
114
+ * somebody else's budget. Repeated failures remain a fail2ban matter; that is
115
+ * what the log line exists for.
116
+ */
117
+ isBlocked(ip) {
118
+ const key = rateLimitKey(ip);
119
+ const entry = this.attempts.get(key);
120
+ const live = entry && entry.resetAt >= Date.now() ? entry : undefined;
121
+ if (this.total.resetAt > Date.now() && this.total.count >= LOGIN_MAX_ATTEMPTS_TOTAL && live !== undefined)
122
+ return true;
123
+ return (live?.count ?? 0) >= LOGIN_MAX_ATTEMPTS;
124
+ }
125
+ recordFailure(ip) {
126
+ this.sweepExpired(); // entries are otherwise only dropped on a successful login from that exact caller
127
+ const now = Date.now();
128
+ // Behind a reverse proxy req.ip is the proxy for every request, and a
129
+ // spoofable X-Forwarded-For makes the per-caller counter meaningless — this
130
+ // caps the total either way.
131
+ if (this.total.resetAt < now)
132
+ this.total = { count: 1, resetAt: now + LOGIN_WINDOW_MS };
133
+ else
134
+ this.total.count++;
135
+ const key = rateLimitKey(ip);
136
+ const entry = this.attempts.get(key);
137
+ if (!entry || entry.resetAt < now) {
138
+ this.attempts.set(key, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
139
+ }
140
+ else {
141
+ entry.count++;
142
+ }
143
+ }
144
+ sweepExpired() {
145
+ const now = Date.now();
146
+ for (const [ip, entry] of this.attempts) {
147
+ if (entry.resetAt < now)
148
+ this.attempts.delete(ip);
149
+ }
150
+ }
151
+ reset(ip) {
152
+ this.attempts.delete(rateLimitKey(ip));
153
+ this.total = { count: 0, resetAt: 0 };
154
+ }
155
+ }
43
156
  //# sourceMappingURL=rate-limit.js.map
@@ -11,7 +11,7 @@ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
11
11
  /** Schemes a browser or the operating system would treat as executable content
12
12
  * or local file access. Never a legitimate redirect target. */
13
13
  const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:', 'blob:']);
14
- export function isLoopbackHostname(hostname) {
14
+ function isLoopbackHostname(hostname) {
15
15
  return LOOPBACK_HOSTNAMES.has(hostname);
16
16
  }
17
17
  /**
@@ -38,6 +38,38 @@ export function isSafeRedirectUri(uri, policy) {
38
38
  return isLoopbackHostname(parsed.hostname);
39
39
  return policy.allowPrivateUseSchemes;
40
40
  }
41
+ /**
42
+ * Whether a requested redirect URI is covered by a registered one.
43
+ *
44
+ * RFC 8252 §7.3: an authorization server MUST allow any port on a loopback
45
+ * redirect URI, because a native client is handed an ephemeral one by the
46
+ * operating system and cannot register it in advance. Everything else is an
47
+ * exact match — a prefix or origin comparison here is the classic open-redirect
48
+ * hole.
49
+ *
50
+ * Lives here rather than being imported because SDK v2 dropped it: the helper
51
+ * belonged to the authorization-server half, which the SDK no longer ships. It
52
+ * is a dozen lines, and keeping the loopback set shared with the checks above
53
+ * is worth more than the import was.
54
+ *
55
+ * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
56
+ */
57
+ export function redirectUriMatches(requested, registered) {
58
+ if (requested === registered)
59
+ return true;
60
+ let req;
61
+ let reg;
62
+ try {
63
+ req = new URL(requested);
64
+ reg = new URL(registered);
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ if (!isLoopbackHostname(req.hostname) || !isLoopbackHostname(reg.hostname))
70
+ return false;
71
+ return (req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search);
72
+ }
41
73
  /**
42
74
  * True when every redirect URI points at this machine — the case the MCP
43
75
  * security considerations single out, because any local program could be the
@@ -1,5 +1,5 @@
1
1
  import express, { Router } from 'express';
2
- import { OAuthClientMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js';
2
+ import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core';
3
3
  import { isSafeRedirectUri } from './redirect-uri.js';
4
4
  import { earlyRateLimit } from './rate-limit.js';
5
5
  import { clampDisplayName, logSafe } from './text.js';
@@ -0,0 +1,43 @@
1
+ import { sign, signatureMatches } from './signed-token.js';
2
+ /** Deliberately short: it only has to outlive a connector's authorization. */
3
+ export const SESSION_TTL_MS = 30 * 60_000;
4
+ export const SESSION_COOKIE = 'mcp_hub_session';
5
+ /**
6
+ * The operator's browser session, carried entirely by the client.
7
+ *
8
+ * `"<expiresMs>.<HMAC>"` and nothing else — there is no session table, which is
9
+ * what lets the hub stay stateless while still recognising someone who has
10
+ * already typed the password. The value doubles as the handle the consent
11
+ * form's CSRF token is bound to.
12
+ *
13
+ * Shared rather than reimplemented: `hasValidSession` is read outside the auth
14
+ * layer (the upstream OAuth callback in `src/upstream/routes.ts`), so two
15
+ * copies of this format drifting apart would break a flow that neither of them
16
+ * looks like it owns.
17
+ */
18
+ export function createSessionCookie(secret) {
19
+ const expires = String(Date.now() + SESSION_TTL_MS);
20
+ return `${expires}.${sign(expires, secret)}`;
21
+ }
22
+ /** The verified cookie value, or undefined when absent, forged or expired. */
23
+ export function readSessionCookie(cookieHeader, secret) {
24
+ const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`));
25
+ if (!match)
26
+ return undefined;
27
+ const value = decodeURIComponent(match[1]);
28
+ const [expires, signature] = value.split('.');
29
+ if (!expires || !signature)
30
+ return undefined;
31
+ if (!signatureMatches(expires, signature, secret))
32
+ return undefined;
33
+ return Number(expires) > Date.now() ? value : undefined;
34
+ }
35
+ export function csrfToken(sessionValue, secret) {
36
+ return sign(`csrf:${sessionValue}`, secret);
37
+ }
38
+ export function verifyCsrfToken(sessionValue, token, secret) {
39
+ if (typeof token !== 'string')
40
+ return false;
41
+ return signatureMatches(`csrf:${sessionValue}`, token, secret);
42
+ }
43
+ //# sourceMappingURL=session.js.map