@ni-c/mcp-hub 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +619 -0
- package/README.md +189 -64
- package/dist/admin.js +232 -8
- package/dist/admin.js.map +1 -1
- package/dist/auth/address.js +89 -0
- package/dist/auth/address.js.map +1 -0
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/api-tokens.js.map +1 -0
- package/dist/auth/cimd.js +367 -0
- package/dist/auth/cimd.js.map +1 -0
- package/dist/auth/consent-page.js +4 -7
- package/dist/auth/consent-page.js.map +1 -1
- package/dist/auth/headers.js +22 -1
- package/dist/auth/headers.js.map +1 -1
- package/dist/auth/login-page.js +4 -7
- package/dist/auth/login-page.js.map +1 -1
- package/dist/auth/oidc/adapter.js +135 -0
- package/dist/auth/oidc/adapter.js.map +1 -0
- package/dist/auth/oidc/interactions.js +187 -0
- package/dist/auth/oidc/interactions.js.map +1 -0
- package/dist/auth/oidc/mount.js +144 -0
- package/dist/auth/oidc/mount.js.map +1 -0
- package/dist/auth/oidc/provider.js +440 -0
- package/dist/auth/oidc/provider.js.map +1 -0
- package/dist/auth/oidc/quirks.js +234 -0
- package/dist/auth/oidc/quirks.js.map +1 -0
- package/dist/auth/oidc/verifier.js +121 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/page.js +22 -0
- package/dist/auth/page.js.map +1 -1
- package/dist/auth/pinned-fetch.js +129 -0
- package/dist/auth/pinned-fetch.js.map +1 -0
- package/dist/auth/protected-resource.js +41 -0
- package/dist/auth/protected-resource.js.map +1 -0
- package/dist/auth/rate-limit.js +156 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/redirect-uri.js +90 -0
- package/dist/auth/redirect-uri.js.map +1 -0
- package/dist/auth/registration.js +146 -0
- package/dist/auth/registration.js.map +1 -0
- package/dist/auth/session.js +43 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/signed-token.js +49 -0
- package/dist/auth/signed-token.js.map +1 -0
- package/dist/auth/store.js +516 -5
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/text.js +51 -0
- package/dist/auth/text.js.map +1 -0
- package/dist/config.js +184 -5
- package/dist/config.js.map +1 -1
- package/dist/docker-proxy/policy.js +1 -0
- package/dist/docker-proxy/policy.js.map +1 -1
- package/dist/docker-proxy/server.js +1 -0
- package/dist/docker-proxy/server.js.map +1 -1
- package/dist/elicitation.js +0 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/forward.js +0 -0
- package/dist/forward.js.map +1 -0
- package/dist/health.js +22 -3
- package/dist/health.js.map +1 -1
- package/dist/hub.js +337 -29
- package/dist/hub.js.map +1 -1
- package/dist/index.js +218 -23
- package/dist/index.js.map +1 -1
- package/dist/limits.js +13 -1
- package/dist/limits.js.map +1 -1
- package/dist/mcp-limits.js +14 -2
- package/dist/mcp-limits.js.map +1 -1
- package/dist/proxy.js +262 -34
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +97 -7
- package/dist/stdio.js.map +1 -1
- package/dist/subscriptions.js +236 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/supervisor.js +472 -28
- package/dist/supervisor.js.map +1 -1
- package/dist/timings.js +61 -0
- package/dist/timings.js.map +1 -0
- package/dist/tool-filter.js +59 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/transports/docker.js.map +1 -1
- package/dist/transports/stream.js +33 -20
- package/dist/transports/stream.js.map +1 -1
- package/dist/upstream/auth.js +435 -0
- package/dist/upstream/auth.js.map +1 -0
- package/dist/upstream/login.js +94 -0
- package/dist/upstream/login.js.map +1 -0
- package/dist/upstream/provider.js +286 -0
- package/dist/upstream/provider.js.map +1 -0
- package/dist/upstream/routes.js +97 -0
- package/dist/upstream/routes.js.map +1 -0
- package/package.json +23 -7
- package/dist/auth/provider.js +0 -380
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/routes.js +0 -223
- package/dist/auth/routes.js.map +0 -1
|
@@ -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>
|
package/dist/auth/page.js.map
CHANGED
|
@@ -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"}
|