@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
@@ -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 @@
1
+ {"version":3,"file":"quirks.js","sourceRoot":"","sources":["../../../src/auth/oidc/quirks.ts"],"names":[],"mappings":"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"}
@@ -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 @@
1
+ {"version":3,"file":"verifier.js","sourceRoot":"","sources":["../../../src/auth/oidc/verifier.ts"],"names":[],"mappings":"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"}
package/dist/auth/page.js CHANGED
@@ -1,6 +1,28 @@
1
1
  export function escapeHtml(value) {
2
2
  return value.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
3
3
  }
4
+ /**
5
+ * The block both pages share: what access is requested, where codes would go
6
+ * and — for a Client ID Metadata Document client — the URL that vouches for
7
+ * the name above it. The document URL is the one part of a CIMD client's
8
+ * identity that cannot be invented, which is why the specification asks for it
9
+ * to be shown; a client whose redirect URIs are all local cannot be attributed
10
+ * to anything at all, so that gets said outright.
11
+ */
12
+ export function renderIdentity(redirectUri, identity) {
13
+ const lines = [
14
+ ' <p class="label">Requested access</p>',
15
+ ` <code class="target">${escapeHtml(identity.resource ?? 'Every server on this hub')}</code>`
16
+ ];
17
+ if (identity.clientId) {
18
+ lines.push(' <p class="label">Identified by</p>', ` <code class="target">${escapeHtml(identity.clientId)}</code>`);
19
+ }
20
+ lines.push(' <p class="label">Codes will be sent to</p>', ` <code class="target">${escapeHtml(redirectUri)}</code>`);
21
+ if (identity.loopbackOnly) {
22
+ lines.push(' <p class="error">This client only accepts codes on this machine, so any program running here could be the one asking.</p>');
23
+ }
24
+ return lines.join('\n');
25
+ }
4
26
  /** Shared chrome for the two interactive pages, login and consent. */
5
27
  export function renderPage(title, body) {
6
28
  return `<!doctype html>
@@ -1 +1 @@
1
- {"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/auth/page.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;AACjE,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,IAAY;IACpD,OAAO;;;;;;SAMA,UAAU,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;EAexB,IAAI;;QAEE,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/auth/page.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;AACjE,CAAC;AAaD;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,WAAmB,EAAE,QAAwB;IAC1E,MAAM,KAAK,GAAG;QACZ,yCAAyC;QACzC,0BAA0B,UAAU,CAAC,QAAQ,CAAC,QAAQ,IAAI,0BAA0B,CAAC,SAAS;KAC/F,CAAC;IACF,IAAI,QAAQ,CAAC,QAAQ,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,sCAAsC,EAAE,0BAA0B,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IACvH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,8CAA8C,EAAE,0BAA0B,UAAU,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC;IACvH,IAAI,QAAQ,CAAC,YAAY,EAAE,CAAC;QAC1B,KAAK,CAAC,IAAI,CAAC,6HAA6H,CAAC,CAAC;IAC5I,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,IAAY;IACpD,OAAO;;;;;;SAMA,UAAU,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;EAexB,IAAI;;QAEE,CAAC;AACT,CAAC"}
@@ -0,0 +1,129 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import net from 'node:net';
4
+ /**
5
+ * An HTTPS request that connects to an address someone already vetted.
6
+ *
7
+ * Checking a hostname and then handing it to `fetch` leaves a gap: the check
8
+ * resolves the name once, `fetch` resolves it again, and a DNS zone the caller
9
+ * controls can answer differently the second time. A short TTL alternating
10
+ * between a public address and `169.254.169.254` wins that race reliably. The
11
+ * only way to close it is to stop resolving twice — so the vetted address is
12
+ * pinned into the connection here, while the hostname is still what TLS
13
+ * validates the certificate against.
14
+ *
15
+ * Deliberately `node:https` rather than a custom `fetch` dispatcher: undici is
16
+ * not a dependency of this project, and its `Agent` would be the only reason
17
+ * to add one.
18
+ */
19
+ /** Answers every lookup with the one address that was already checked. */
20
+ export function pinnedLookup(address) {
21
+ const family = net.isIP(address);
22
+ return ((hostname, options, callback) => {
23
+ const done = (typeof options === 'function' ? options : callback);
24
+ const wantsAll = typeof options === 'object' && options !== null && options.all === true;
25
+ if (wantsAll)
26
+ done(null, [{ address, family }]);
27
+ else
28
+ done(null, address, family);
29
+ });
30
+ }
31
+ export async function guardedRequest(url, options) {
32
+ // Only https reaches this in production — the caller rejects anything else
33
+ // before resolving. Plain http is here so the transport can be exercised
34
+ // without a certificate authority, and for the local-development escape hatch.
35
+ const transport = url.protocol === 'http:' ? http : https;
36
+ return new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ // Declared up here because finish() may run before the timer below is
39
+ // assigned — an immediate connection error would otherwise hit the
40
+ // temporal dead zone instead of rejecting.
41
+ let deadline;
42
+ const finish = (error, response) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ clearTimeout(deadline);
47
+ if (error)
48
+ reject(error);
49
+ else
50
+ resolve(response);
51
+ };
52
+ const request = transport.request({
53
+ hostname: url.hostname.replace(/^\[|\]$/g, ''),
54
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
55
+ path: `${url.pathname}${url.search}`,
56
+ method: options.method ?? 'GET',
57
+ // No compression: this path decodes nothing, and a compressed body
58
+ // would make the byte cap below measure the wrong thing.
59
+ headers: {
60
+ ...options.headers,
61
+ host: url.host,
62
+ 'accept-encoding': 'identity',
63
+ ...(options.body !== undefined ? { 'content-length': String(Buffer.byteLength(options.body)) } : {})
64
+ },
65
+ lookup: pinnedLookup(options.pinnedAddress)
66
+ }, response => {
67
+ const status = response.statusCode ?? 0;
68
+ // The caller asked for no redirects; a hop would be resolved and
69
+ // connected without going through the address check again.
70
+ if (status >= 300 && status < 400) {
71
+ response.destroy();
72
+ request.destroy();
73
+ finish(new Error(`refused to follow a redirect (HTTP ${status})`));
74
+ return;
75
+ }
76
+ const declared = Number(response.headers['content-length']);
77
+ if (Number.isFinite(declared) && declared > options.maxBytes) {
78
+ response.destroy();
79
+ request.destroy();
80
+ finish(new Error(`response exceeds ${options.maxBytes} bytes`));
81
+ return;
82
+ }
83
+ const chunks = [];
84
+ let size = 0;
85
+ response.on('data', (chunk) => {
86
+ size += chunk.byteLength;
87
+ if (size > options.maxBytes) {
88
+ response.destroy();
89
+ request.destroy();
90
+ finish(new Error(`response exceeds ${options.maxBytes} bytes`));
91
+ return;
92
+ }
93
+ chunks.push(chunk);
94
+ });
95
+ response.on('error', error => finish(error));
96
+ response.on('end', () => {
97
+ if (settled)
98
+ return;
99
+ const headers = new Headers();
100
+ for (const [key, value] of Object.entries(response.headers)) {
101
+ if (value === undefined)
102
+ continue;
103
+ try {
104
+ if (Array.isArray(value))
105
+ for (const entry of value)
106
+ headers.append(key, entry);
107
+ else
108
+ headers.set(key, value);
109
+ }
110
+ catch {
111
+ // A header the Fetch API will not represent is not one this
112
+ // caller reads; dropping it beats failing the whole request.
113
+ }
114
+ }
115
+ // A zero-length body has to be null: Response refuses an empty
116
+ // buffer for the statuses that may not carry one.
117
+ finish(undefined, new Response(chunks.length > 0 ? Buffer.concat(chunks) : null, { status, headers }));
118
+ });
119
+ });
120
+ // Bounds the whole exchange, not just socket inactivity — a body trickled
121
+ // a byte at a time would otherwise keep the request alive indefinitely.
122
+ deadline = setTimeout(() => {
123
+ request.destroy(new Error(`timed out after ${options.timeoutMs}ms`));
124
+ }, options.timeoutMs);
125
+ request.on('error', error => finish(error));
126
+ request.end(options.body);
127
+ });
128
+ }
129
+ //# sourceMappingURL=pinned-fetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pinned-fetch.js","sourceRoot":"","sources":["../../src/auth/pinned-fetch.ts"],"names":[],"mappings":"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"}
@@ -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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protected-resource.js","sourceRoot":"","sources":["../../src/auth/protected-resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEjC,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAQnD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,6BAA6B,CAAC,OAAiC;IAC7E,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;IACxB,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/C,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,SAAS,CAAC;IAEvD,wEAAwE;IACxE,2EAA2E;IAC3E,gEAAgE;IAChE,MAAM,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IAEhC,MAAM,CAAC,GAAG,CAAC,uCAAuC,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QAChE,GAAG,CAAC,IAAI,CAAC;YACP,QAAQ,EAAE,SAAS,CAAC,IAAI;YACxB,qBAAqB,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC;YAC5C,aAAa,EAAE,YAAY;SAC5B,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,GAAG,CAAC,gDAAgD,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QACxE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,uCAAuC,EAAE,EAAE,CAAC,CAAC;QAC7E,GAAG,CAAC,IAAI,CAAC;YACP,QAAQ,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM;YACnC,qBAAqB,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC;YAC5C,wBAAwB,EAAE,CAAC,QAAQ,CAAC;YACpC,aAAa,EAAE,YAAY;SAC5B,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC"}