@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,156 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
/**
|
|
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.
|
|
44
|
+
*
|
|
45
|
+
* The SDK applies its own limits to the OAuth endpoints, but only after body
|
|
46
|
+
* parsing — which is the expensive part. This runs first, and refuses without
|
|
47
|
+
* inserting the offending address into its own table, so a flood of distinct
|
|
48
|
+
* source addresses cannot grow the map that is supposed to be bounding it.
|
|
49
|
+
*/
|
|
50
|
+
export function earlyRateLimit(windowMs, maxPerIp, maxTotal) {
|
|
51
|
+
const byIp = new Map();
|
|
52
|
+
let total = { count: 0, resetAt: 0 };
|
|
53
|
+
return (req, res, next) => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
if (total.resetAt <= now)
|
|
56
|
+
total = { count: 0, resetAt: now + windowMs };
|
|
57
|
+
const ip = rateLimitKey(req.ip ?? 'unknown');
|
|
58
|
+
let entry = byIp.get(ip);
|
|
59
|
+
if (entry && entry.resetAt <= now) {
|
|
60
|
+
byIp.delete(ip);
|
|
61
|
+
entry = undefined;
|
|
62
|
+
}
|
|
63
|
+
// Reject before inserting anything: a flood of distinct rejected IPs must
|
|
64
|
+
// not grow the map. Accepted requests bound it at maxTotal per window.
|
|
65
|
+
if ((entry?.count ?? 0) >= maxPerIp || total.count >= maxTotal) {
|
|
66
|
+
res.set('Retry-After', String(Math.max(1, Math.ceil((Math.min(entry?.resetAt ?? Infinity, total.resetAt) - now) / 1000))));
|
|
67
|
+
res.status(429).json({ error: 'too_many_requests', error_description: 'Request rate limit exceeded' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!entry) {
|
|
71
|
+
if (byIp.size >= maxTotal) {
|
|
72
|
+
for (const [candidateIp, candidate] of byIp)
|
|
73
|
+
if (candidate.resetAt <= now)
|
|
74
|
+
byIp.delete(candidateIp);
|
|
75
|
+
}
|
|
76
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
77
|
+
byIp.set(ip, entry);
|
|
78
|
+
}
|
|
79
|
+
entry.count++;
|
|
80
|
+
total.count++;
|
|
81
|
+
next();
|
|
82
|
+
};
|
|
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
|
+
}
|
|
156
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/auth/rate-limit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an authorization code is allowed to land.
|
|
3
|
+
*
|
|
4
|
+
* Both registration mechanisms end up handing a code to a URL the client chose,
|
|
5
|
+
* so both have to agree on which URLs are acceptable — they did not before:
|
|
6
|
+
* a metadata document was held to https-or-loopback while a dynamically
|
|
7
|
+
* registered client could name any scheme the SDK's three-entry denylist did
|
|
8
|
+
* not happen to cover, plain `http://` to a remote host included.
|
|
9
|
+
*/
|
|
10
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
11
|
+
/** Schemes a browser or the operating system would treat as executable content
|
|
12
|
+
* or local file access. Never a legitimate redirect target. */
|
|
13
|
+
const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:', 'blob:']);
|
|
14
|
+
function isLoopbackHostname(hostname) {
|
|
15
|
+
return LOOPBACK_HOSTNAMES.has(hostname);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* https anywhere, plain http only on this machine, and — where the policy
|
|
19
|
+
* allows it — a private-use scheme for a native client.
|
|
20
|
+
*
|
|
21
|
+
* The point of refusing remote `http://` is that the code travels in the clear
|
|
22
|
+
* on the final redirect: anyone on the path between the browser and the client
|
|
23
|
+
* reads it, and a public client has nothing else to prove itself with.
|
|
24
|
+
*/
|
|
25
|
+
export function isSafeRedirectUri(uri, policy) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(uri);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (DANGEROUS_SCHEMES.has(parsed.protocol))
|
|
34
|
+
return false;
|
|
35
|
+
if (parsed.protocol === 'https:')
|
|
36
|
+
return true;
|
|
37
|
+
if (parsed.protocol === 'http:')
|
|
38
|
+
return isLoopbackHostname(parsed.hostname);
|
|
39
|
+
return policy.allowPrivateUseSchemes;
|
|
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
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* True when every redirect URI points at this machine — the case the MCP
|
|
75
|
+
* security considerations single out, because any local program could be the
|
|
76
|
+
* one asking. Used to warn on the login and consent pages.
|
|
77
|
+
*/
|
|
78
|
+
export function isLoopbackOnly(redirectUris) {
|
|
79
|
+
if (!redirectUris?.length)
|
|
80
|
+
return false;
|
|
81
|
+
return redirectUris.every(uri => {
|
|
82
|
+
try {
|
|
83
|
+
return isLoopbackHostname(new URL(uri).hostname);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=redirect-uri.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-uri.js","sourceRoot":"","sources":["../../src/auth/redirect-uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAE/E;gEACgE;AAChE,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAE3F,SAAS,kBAAkB,CAAC,QAAgB;IAC1C,OAAO,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC1C,CAAC;AAYD;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,MAAyB;IACtE,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC5E,OAAO,MAAM,CAAC,sBAAsB,CAAC;AACvC,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAiB,EAAE,UAAkB;IACtE,IAAI,SAAS,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IAC1C,IAAI,GAAQ,CAAC;IACb,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC;QACzB,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IACzF,OAAO,CACL,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,CAC7H,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,YAAkC;IAC/D,IAAI,CAAC,YAAY,EAAE,MAAM;QAAE,OAAO,KAAK,CAAC;IACxC,OAAO,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE;QAC9B,IAAI,CAAC;YACH,OAAO,kBAAkB,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import express, { Router } from 'express';
|
|
2
|
+
import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core';
|
|
3
|
+
import { isSafeRedirectUri } from './redirect-uri.js';
|
|
4
|
+
import { earlyRateLimit } from './rate-limit.js';
|
|
5
|
+
import { clampDisplayName, logSafe } from './text.js';
|
|
6
|
+
/**
|
|
7
|
+
* RFC 7592, the management half of dynamic client registration: a client that
|
|
8
|
+
* registered itself can read, change and — the point of the exercise — delete
|
|
9
|
+
* its own registration, without the operator having to do it for them.
|
|
10
|
+
*
|
|
11
|
+
* None of this comes from the SDK. Its registration router accepts `POST` and
|
|
12
|
+
* nothing else (`allowedMethods(['POST'])`), and it knows nothing about
|
|
13
|
+
* `registration_access_token`. These routes therefore have to be mounted ahead
|
|
14
|
+
* of it, or the SDK answers `405` for every verb below.
|
|
15
|
+
*
|
|
16
|
+
* The credential is minted once at registration and only its hash is stored,
|
|
17
|
+
* so it is not recoverable from the state file — losing it means asking the
|
|
18
|
+
* operator to remove the registration.
|
|
19
|
+
*/
|
|
20
|
+
/** Where the client manages the registration it was just given. */
|
|
21
|
+
export function registrationClientUri(externalUrl, clientId) {
|
|
22
|
+
return new URL(`register/${encodeURIComponent(clientId)}`, externalUrl).href;
|
|
23
|
+
}
|
|
24
|
+
/** The body limit is far above any real client metadata document and well
|
|
25
|
+
* below anything that would matter to the process. */
|
|
26
|
+
const MAX_METADATA_BYTES = '64kb';
|
|
27
|
+
export function createRegistrationManagementRoutes(options) {
|
|
28
|
+
const { store, externalUrl } = options;
|
|
29
|
+
const router = Router();
|
|
30
|
+
const path = '/register/:clientId';
|
|
31
|
+
// Its own budget rather than the 20-per-hour one guarding POST /register.
|
|
32
|
+
// These are authenticated and cheap, and a client that reads and updates its
|
|
33
|
+
// registration should not thereby lose the ability to register again.
|
|
34
|
+
const limit = earlyRateLimit(15 * 60_000, 60, 600);
|
|
35
|
+
const present = (client) => ({
|
|
36
|
+
...client,
|
|
37
|
+
registration_client_uri: registrationClientUri(externalUrl, client.client_id)
|
|
38
|
+
});
|
|
39
|
+
// The SDK gives POST /register an open CORS policy so browser-based clients
|
|
40
|
+
// can register; these routes sit in front of it and need their own. Opening
|
|
41
|
+
// them is safe: the credential is a bearer token in a header, never a cookie,
|
|
42
|
+
// so no browser attaches it to a cross-site request on its own.
|
|
43
|
+
const allowCrossOrigin = (res) => {
|
|
44
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
45
|
+
};
|
|
46
|
+
router.options(path, limit, (_req, res) => {
|
|
47
|
+
allowCrossOrigin(res);
|
|
48
|
+
res.set({
|
|
49
|
+
'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
|
|
50
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
51
|
+
'Access-Control-Max-Age': '600'
|
|
52
|
+
});
|
|
53
|
+
res.status(204).end();
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Authenticates before anything else runs — the body parser included, so an
|
|
57
|
+
* unauthenticated caller never gets to hand us a document to parse.
|
|
58
|
+
*
|
|
59
|
+
* An unknown client and a wrong token get the same answer. Telling them apart
|
|
60
|
+
* would turn this into a way to find out which client_ids exist.
|
|
61
|
+
*/
|
|
62
|
+
const requireRegistrationToken = (req, res, next) => {
|
|
63
|
+
allowCrossOrigin(res);
|
|
64
|
+
const clientId = String(req.params.clientId);
|
|
65
|
+
const header = req.headers.authorization;
|
|
66
|
+
const token = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7).trim() : '';
|
|
67
|
+
const client = token.length > 0 && store.verifyRegistrationToken(clientId, token) ? store.getClient(clientId) : undefined;
|
|
68
|
+
if (!client) {
|
|
69
|
+
console.warn(`mcp-hub: refused registration management for ${logSafe(clientId)}`);
|
|
70
|
+
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
|
|
71
|
+
res.status(401).json({ error: 'invalid_token', error_description: 'Invalid registration access token' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
req.registrationClient = client;
|
|
75
|
+
next();
|
|
76
|
+
};
|
|
77
|
+
router.get(path, limit, requireRegistrationToken, (req, res) => {
|
|
78
|
+
res.json(present(req.registrationClient));
|
|
79
|
+
});
|
|
80
|
+
router.put(path, limit, requireRegistrationToken, express.json({ limit: MAX_METADATA_BYTES }), (req, res) => {
|
|
81
|
+
const existing = req.registrationClient;
|
|
82
|
+
const refuse = (description) => {
|
|
83
|
+
res.status(400).json({ error: 'invalid_client_metadata', error_description: description });
|
|
84
|
+
};
|
|
85
|
+
const body = (req.body ?? {});
|
|
86
|
+
// RFC 7592 §2.2: the client has to say which registration it means, and it
|
|
87
|
+
// may not use this to change its own credentials.
|
|
88
|
+
if (body.client_id !== existing.client_id) {
|
|
89
|
+
refuse('client_id must be present and match the registration being updated');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Only meaningful for a client that actually holds a secret. A public one
|
|
93
|
+
// was handed a throwaway secret in its registration response that was never
|
|
94
|
+
// stored (the accommodation ChatGPT needs), and echoing that back here must
|
|
95
|
+
// not be treated as an attempt to change anything.
|
|
96
|
+
if (existing.client_secret !== undefined && body.client_secret !== undefined && body.client_secret !== existing.client_secret) {
|
|
97
|
+
refuse('client_secret cannot be changed here');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const parsed = OAuthClientMetadataSchema.safeParse(body);
|
|
101
|
+
if (!parsed.success) {
|
|
102
|
+
refuse('The submitted client metadata is not valid');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const metadata = parsed.data;
|
|
106
|
+
for (const uri of metadata.redirect_uris ?? []) {
|
|
107
|
+
if (!isSafeRedirectUri(uri, { allowPrivateUseSchemes: true })) {
|
|
108
|
+
refuse(`redirect_uri ${uri} must be https, a loopback address or a private-use scheme`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const redirectsChanged = !sameUris(existing.redirect_uris, metadata.redirect_uris);
|
|
113
|
+
const updated = {
|
|
114
|
+
...metadata,
|
|
115
|
+
client_name: clampDisplayName(metadata.client_name),
|
|
116
|
+
// Identity and credentials are the hub's to assign, not the client's.
|
|
117
|
+
client_id: existing.client_id,
|
|
118
|
+
client_id_issued_at: existing.client_id_issued_at,
|
|
119
|
+
...(existing.client_secret !== undefined
|
|
120
|
+
? { client_secret: existing.client_secret, client_secret_expires_at: existing.client_secret_expires_at }
|
|
121
|
+
: {})
|
|
122
|
+
};
|
|
123
|
+
// Consent was given for a destination. A client that moves the destination
|
|
124
|
+
// afterwards has to be asked again, or the approval would be transferable
|
|
125
|
+
// to somewhere the user never saw.
|
|
126
|
+
store.updateClient(updated, { resetApproval: redirectsChanged });
|
|
127
|
+
if (redirectsChanged) {
|
|
128
|
+
console.log(`mcp-hub: client ${logSafe(existing.client_id)} changed its redirect URIs; its approval was withdrawn`);
|
|
129
|
+
}
|
|
130
|
+
res.json(present(updated));
|
|
131
|
+
});
|
|
132
|
+
router.delete(path, limit, requireRegistrationToken, (req, res) => {
|
|
133
|
+
const clientId = req.registrationClient.client_id;
|
|
134
|
+
store.deleteClient(clientId);
|
|
135
|
+
console.log(`mcp-hub: client ${logSafe(clientId)} deleted its own registration`);
|
|
136
|
+
res.status(204).end();
|
|
137
|
+
});
|
|
138
|
+
return router;
|
|
139
|
+
}
|
|
140
|
+
/** Order is not part of the meaning of a redirect URI list. */
|
|
141
|
+
function sameUris(before, after) {
|
|
142
|
+
const a = [...(before ?? [])].sort();
|
|
143
|
+
const b = [...(after ?? [])].sort();
|
|
144
|
+
return a.length === b.length && a.every((uri, index) => uri === b[index]);
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=registration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registration.js","sourceRoot":"","sources":["../../src/auth/registration.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/auth/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,GAAG,MAAM,CAAC;AAC1C,MAAM,CAAC,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,cAAc,CAAC,CAAC;IACpD,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,iBAAiB,CAAC,YAAgC,EAAE,MAAc;IAChF,MAAM,KAAK,GAAG,YAAY,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,cAAc,cAAc,UAAU,CAAC,CAAC,CAAC;IACtF,IAAI,CAAC,KAAK;QAAE,OAAO,SAAS,CAAC;IAC7B,MAAM,KAAK,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3C,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9C,IAAI,CAAC,OAAO,IAAI,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;QAAE,OAAO,SAAS,CAAC;IACpE,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,YAAoB,EAAE,MAAc;IAC5D,OAAO,IAAI,CAAC,QAAQ,YAAY,EAAE,EAAE,MAAM,CAAC,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,YAAoB,EAAE,KAAc,EAAE,MAAc;IAClF,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,gBAAgB,CAAC,QAAQ,YAAY,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;AACjE,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Short-lived values the hub hands to a browser and expects back unchanged.
|
|
4
|
+
*
|
|
5
|
+
* Used for the login/consent form token, the session cookie, the consent CSRF
|
|
6
|
+
* token and the `state` of an outbound OAuth login. All of them share one
|
|
7
|
+
* property: the hub must recognise its own value without storing it, and must
|
|
8
|
+
* not be talked into accepting someone else's.
|
|
9
|
+
*
|
|
10
|
+
* The key throughout is `AuthStore.cookieSecret`.
|
|
11
|
+
*/
|
|
12
|
+
export function sign(value, secret) {
|
|
13
|
+
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Constant-time comparison against the expected signature.
|
|
17
|
+
*
|
|
18
|
+
* The length check is not an optimisation — `timingSafeEqual` throws on
|
|
19
|
+
* mismatched lengths, so a value of the wrong length would otherwise be an
|
|
20
|
+
* exception rather than a rejection.
|
|
21
|
+
*/
|
|
22
|
+
export function signatureMatches(value, signature, secret) {
|
|
23
|
+
const expected = sign(value, secret);
|
|
24
|
+
if (signature.length !== expected.length)
|
|
25
|
+
return false;
|
|
26
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
27
|
+
}
|
|
28
|
+
/** `base64url(json).signature` — everything the caller needs, carried by the
|
|
29
|
+
* browser instead of by the hub. */
|
|
30
|
+
export function signPayload(payload, secret) {
|
|
31
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
32
|
+
return `${encoded}.${sign(encoded, secret)}`;
|
|
33
|
+
}
|
|
34
|
+
/** Undefined for anything that was not signed with this secret, or is not the
|
|
35
|
+
* shape it claims. Never throws on malformed input. */
|
|
36
|
+
export function readSignedPayload(token, secret) {
|
|
37
|
+
const [encoded, signature] = token.split('.');
|
|
38
|
+
if (!encoded || !signature)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!signatureMatches(encoded, signature, secret))
|
|
41
|
+
return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=signed-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signed-token.js","sourceRoot":"","sources":["../../src/auth/signed-token.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC;;;;;;;;;GASG;AAEH,MAAM,UAAU,IAAI,CAAC,KAAa,EAAE,MAAc;IAChD,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,SAAiB,EAAE,MAAc;IAC/E,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrC,IAAI,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACvD,OAAO,MAAM,CAAC,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED;qCACqC;AACrC,MAAM,UAAU,WAAW,CAAC,OAAgB,EAAE,MAAc;IAC1D,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC3E,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED;wDACwD;AACxD,MAAM,UAAU,iBAAiB,CAAI,KAAa,EAAE,MAAc;IAChE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9C,IAAI,CAAC,OAAO,IAAI,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;QAAE,OAAO,SAAS,CAAC;IACpE,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAM,CAAC;IAC7E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"}
|