@ni-c/mcp-hub 0.10.0 → 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 +388 -0
- package/README.md +77 -12
- package/dist/admin.js +1 -1
- package/dist/admin.js.map +1 -1
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/api-tokens.js.map +1 -0
- package/dist/auth/headers.js +22 -1
- package/dist/auth/headers.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/protected-resource.js +41 -0
- package/dist/auth/protected-resource.js.map +1 -0
- package/dist/auth/rate-limit.js +115 -2
- package/dist/auth/rate-limit.js.map +1 -1
- package/dist/auth/redirect-uri.js +33 -1
- package/dist/auth/redirect-uri.js.map +1 -1
- package/dist/auth/registration.js +1 -1
- package/dist/auth/registration.js.map +1 -1
- package/dist/auth/session.js +43 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/store.js +160 -1
- package/dist/auth/store.js.map +1 -1
- package/dist/config.js +55 -2
- 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/hub.js +309 -29
- package/dist/hub.js.map +1 -1
- package/dist/index.js +103 -21
- 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 +246 -40
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +71 -6
- package/dist/stdio.js.map +1 -1
- package/dist/subscriptions.js +236 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/supervisor.js +322 -22
- 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 +1 -0
- package/dist/tool-filter.js.map +1 -1
- 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 +1 -1
- package/dist/upstream/auth.js.map +1 -1
- package/dist/upstream/provider.js.map +1 -1
- package/dist/upstream/routes.js +3 -2
- package/dist/upstream/routes.js.map +1 -1
- package/package.json +19 -6
- package/dist/auth/private-key-jwt.js +0 -213
- package/dist/auth/private-key-jwt.js.map +0 -1
- package/dist/auth/provider.js +0 -437
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/routes.js +0 -249
- package/dist/auth/routes.js.map +0 -1
package/dist/auth/headers.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* sits there with no visible error, so the pages have to name the one origin
|
|
8
8
|
* their flow legitimately ends on.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
function contentSecurityPolicy(formActionOrigins = []) {
|
|
11
11
|
const formAction = ["'self'", ...formActionOrigins].join(' ');
|
|
12
12
|
return `default-src 'none'; style-src 'unsafe-inline'; form-action ${formAction}; frame-ancestors 'none'; base-uri 'none'`;
|
|
13
13
|
}
|
|
@@ -30,4 +30,25 @@ export function allowFormActionTo(res, redirectUri) {
|
|
|
30
30
|
return;
|
|
31
31
|
res.set('Content-Security-Policy', contentSecurityPolicy([origin]));
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The headers every page and token response of the auth surface carries.
|
|
35
|
+
*
|
|
36
|
+
* Shared rather than repeated: the mounted authorization server is a Koa app
|
|
37
|
+
* the Express router never enters, so these have to be put in front of it
|
|
38
|
+
* explicitly -- and two copies of the list would drift the moment one of them
|
|
39
|
+
* gained a header.
|
|
40
|
+
*
|
|
41
|
+
* RFC 6749 §5.1 requires `no-store` on token responses; the login form has no
|
|
42
|
+
* business being cached either. The CSP and the legacy frame header keep an
|
|
43
|
+
* attacker from clickjacking approval or password entry. This application has
|
|
44
|
+
* no legitimate framing use case.
|
|
45
|
+
*/
|
|
46
|
+
export const authSecurityHeaders = (_req, res, next) => {
|
|
47
|
+
res.set('Cache-Control', 'no-store');
|
|
48
|
+
res.set('Content-Security-Policy', contentSecurityPolicy());
|
|
49
|
+
res.set('X-Frame-Options', 'DENY');
|
|
50
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
51
|
+
res.set('Referrer-Policy', 'no-referrer');
|
|
52
|
+
next();
|
|
53
|
+
};
|
|
33
54
|
//# sourceMappingURL=headers.js.map
|
package/dist/auth/headers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../../src/auth/headers.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../../src/auth/headers.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,SAAS,qBAAqB,CAAC,iBAAiB,GAAsB,EAAE;IACtE,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,GAAG,iBAAiB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9D,OAAO,8DAA8D,UAAU,2CAA2C,CAAC;AAC7H,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAa,EAAE,WAAmB;IAClE,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,4EAA4E;IACtF,CAAC;IACD,uEAAuE;IACvE,qEAAqE;IACrE,IAAI,MAAM,KAAK,MAAM;QAAE,OAAO;IAC9B,GAAG,CAAC,GAAG,CAAC,yBAAyB,EAAE,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAmB,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACrE,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IACrC,GAAG,CAAC,GAAG,CAAC,yBAAyB,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAC5D,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;IACnC,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,SAAS,CAAC,CAAC;IAC7C,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,aAAa,CAAC,CAAC;IAC1C,IAAI,EAAE,CAAC;AACT,CAAC,CAAC"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { errors } from 'oidc-provider';
|
|
2
|
+
import { isClientIdMetadataUrl } from '../cimd.js';
|
|
3
|
+
import { withoutPhantomSecret } from './quirks.js';
|
|
4
|
+
/**
|
|
5
|
+
* oidc-provider's ten models, all backed by the one `AuthStore` the hub already
|
|
6
|
+
* serialises every other write through.
|
|
7
|
+
*
|
|
8
|
+
* The store is not replaced by this and does not shrink: API tokens, upstream
|
|
9
|
+
* credentials and the cross-process lock the admin CLI shares are mcp-hub's own
|
|
10
|
+
* concepts and have nothing to do with the authorization server. What this adds
|
|
11
|
+
* is a flat artifact slot the library owns the shape of.
|
|
12
|
+
*
|
|
13
|
+
* Two behaviours are deliberately in the store rather than here:
|
|
14
|
+
*
|
|
15
|
+
* - the `revokedBefore` cutoff, because `find()` returning falsy IS the
|
|
16
|
+
* revocation as far as the library is concerned (base_model.js treats any
|
|
17
|
+
* falsy return as "not found"), and
|
|
18
|
+
* - `consumed`, because a consumed authorization code has to be
|
|
19
|
+
* distinguishable from an unknown one or replay detection cannot work.
|
|
20
|
+
*/
|
|
21
|
+
export function createOidcAdapter(store, cimd) {
|
|
22
|
+
return (model) => (model === 'Client' ? clientAdapter(store, cimd) : artifactAdapter(store, model));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Clients are NOT stored in the generic artifact slot.
|
|
26
|
+
*
|
|
27
|
+
* `mcp-hub-admin clients list|prune|revoke`, the ceiling on unapproved clients,
|
|
28
|
+
* the activity stamps behind ageing registrations out, and the approval records
|
|
29
|
+
* all read `state.clients`. Putting oidc-provider's clients anywhere else would
|
|
30
|
+
* leave the admin CLI looking at an empty hub while the authorization server
|
|
31
|
+
* happily served a full one — two stores, one truth, no error message.
|
|
32
|
+
*
|
|
33
|
+
* The shapes already agree: both are RFC 7591 metadata keyed by `client_id`.
|
|
34
|
+
*/
|
|
35
|
+
function clientAdapter(store, cimd) {
|
|
36
|
+
return {
|
|
37
|
+
async upsert(id, payload) {
|
|
38
|
+
// A public client's record must not carry a secret; see withoutPhantomSecret.
|
|
39
|
+
const record = { ...withoutPhantomSecret(payload), client_id: id };
|
|
40
|
+
// A NEW client goes through addClient, which is what enforces the ceiling
|
|
41
|
+
// on unapproved registrations and creates the lifecycle entry the
|
|
42
|
+
// activity stamps and `mcp-hub-admin clients prune` are built on. Writing
|
|
43
|
+
// it with saveClient would leave a client nothing ages out.
|
|
44
|
+
if (store.getClient(id)) {
|
|
45
|
+
store.updateClient(record);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// False means the hub is full of clients that were all confirmed, and
|
|
49
|
+
// dropping one of those would take a connector somebody uses offline.
|
|
50
|
+
// Refusing the newcomer is the failure an operator can see and fix, so it
|
|
51
|
+
// has to surface rather than be swallowed into a successful-looking
|
|
52
|
+
// registration that stored nothing.
|
|
53
|
+
if (!store.addClient(record)) {
|
|
54
|
+
const full = new errors.OIDCProviderError(400, 'too_many_requests');
|
|
55
|
+
full.error_description = 'the hub is not accepting new client registrations right now';
|
|
56
|
+
throw full;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
/**
|
|
60
|
+
* Client ID Metadata Documents are resolved HERE, deliberately.
|
|
61
|
+
*
|
|
62
|
+
* models/client.js consults the adapter BEFORE its own metadata-document
|
|
63
|
+
* fetcher, so returning the document from here means oidc-provider's
|
|
64
|
+
* fetcher is never reached — and the hub keeps a materially stricter
|
|
65
|
+
* policy than the library's: DNS pinning against rebinding, an origin
|
|
66
|
+
* allowlist, per-origin failure counting so distinct client_ids on one host
|
|
67
|
+
* cannot be used to hammer it, and negative caching. oidc-provider has none
|
|
68
|
+
* of those. `allowFetch` is wired to refuse, so the built-in path stays
|
|
69
|
+
* closed even if this returns nothing.
|
|
70
|
+
*
|
|
71
|
+
* Nothing is written: a CIMD client is not registered, which is the point
|
|
72
|
+
* of it — the client reinstalls and is still the same client.
|
|
73
|
+
*/
|
|
74
|
+
async find(id) {
|
|
75
|
+
const registered = store.getClient(id);
|
|
76
|
+
if (registered)
|
|
77
|
+
return registered;
|
|
78
|
+
if (cimd && isClientIdMetadataUrl(id)) {
|
|
79
|
+
return (await cimd.resolve(id));
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
},
|
|
83
|
+
async findByUid() {
|
|
84
|
+
return undefined;
|
|
85
|
+
},
|
|
86
|
+
async findByUserCode() {
|
|
87
|
+
return undefined;
|
|
88
|
+
},
|
|
89
|
+
async consume() {
|
|
90
|
+
// Clients are not single-use.
|
|
91
|
+
},
|
|
92
|
+
async destroy(id) {
|
|
93
|
+
store.deleteClient(id);
|
|
94
|
+
},
|
|
95
|
+
async revokeByGrantId() {
|
|
96
|
+
// A grant belongs to a client; revoking it never deletes the client.
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function artifactAdapter(store, model) {
|
|
101
|
+
return {
|
|
102
|
+
async upsert(id, payload, expiresIn) {
|
|
103
|
+
store.oidcUpsert(model, id, payload, expiresIn);
|
|
104
|
+
},
|
|
105
|
+
async find(id) {
|
|
106
|
+
return store.oidcFind(model, id);
|
|
107
|
+
},
|
|
108
|
+
async findByUid(uid) {
|
|
109
|
+
return store.oidcFindBy(model, 'uid', uid);
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Device flow only, and the hub does not enable it. Returning undefined is
|
|
113
|
+
* the honest answer for a model that is never written; throwing would turn
|
|
114
|
+
* a disabled feature into a 500 if it were ever reached.
|
|
115
|
+
*/
|
|
116
|
+
async findByUserCode() {
|
|
117
|
+
return undefined;
|
|
118
|
+
},
|
|
119
|
+
async consume(id) {
|
|
120
|
+
store.oidcConsume(model, id);
|
|
121
|
+
},
|
|
122
|
+
async destroy(id) {
|
|
123
|
+
store.oidcDestroy(model, id);
|
|
124
|
+
},
|
|
125
|
+
/**
|
|
126
|
+
* Crosses model boundaries by design: revoking a grant has to take the
|
|
127
|
+
* access tokens, refresh tokens and codes minted under it with it, and the
|
|
128
|
+
* library calls this on exactly one of the models.
|
|
129
|
+
*/
|
|
130
|
+
async revokeByGrantId(grantId) {
|
|
131
|
+
store.oidcRevokeByGrantId(grantId);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../../src/auth/oidc/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAGvC,OAAO,EAAqB,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAEtE,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAgB,EAAE,IAAmB;IACrE,OAAO,CAAC,KAAa,EAAW,EAAE,CAAC,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;AACvH,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,aAAa,CAAC,KAAgB,EAAE,IAAmB;IAC1D,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,OAAuB;YAC9C,8EAA8E;YAC9E,MAAM,MAAM,GAAG,EAAE,GAAG,oBAAoB,CAAC,OAAkC,CAAC,EAAE,SAAS,EAAE,EAAE,EAAW,CAAC;YACvG,0EAA0E;YAC1E,kEAAkE;YAClE,0EAA0E;YAC1E,4DAA4D;YAC5D,IAAI,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;gBACxB,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;gBAC3B,OAAO;YACT,CAAC;YACD,sEAAsE;YACtE,sEAAsE;YACtE,0EAA0E;YAC1E,oEAAoE;YACpE,oCAAoC;YACpC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC7B,MAAM,IAAI,GAAG,IAAI,MAAM,CAAC,iBAAiB,CAAC,GAAG,EAAE,mBAAmB,CAAC,CAAC;gBACpE,IAAI,CAAC,iBAAiB,GAAG,6DAA6D,CAAC;gBACvF,MAAM,IAAI,CAAC;YACb,CAAC;QACH,CAAC;QAED;;;;;;;;;;;;;;WAcG;QACH,KAAK,CAAC,IAAI,CAAC,EAAU;YACnB,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;YACvC,IAAI,UAAU;gBAAE,OAAO,UAA4B,CAAC;YACpD,IAAI,IAAI,IAAI,qBAAqB,CAAC,EAAE,CAAC,EAAE,CAAC;gBACtC,OAAO,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAA+B,CAAC;YAChE,CAAC;YACD,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,KAAK,CAAC,SAAS;YACb,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,KAAK,CAAC,cAAc;YAClB,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,KAAK,CAAC,OAAO;YACX,8BAA8B;QAChC,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,EAAU;YACtB,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;QACzB,CAAC;QACD,KAAK,CAAC,eAAe;YACnB,qEAAqE;QACvE,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,KAAgB,EAAE,KAAa;IACtD,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,OAAuB,EAAE,SAAkB;YAClE,KAAK,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,EAAE,OAAkC,EAAE,SAAS,CAAC,CAAC;QAC7E,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,EAAU;YACnB,OAAO,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAA+B,CAAC;QACjE,CAAC;QAED,KAAK,CAAC,SAAS,CAAC,GAAW;YACzB,OAAO,KAAK,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,CAA+B,CAAC;QAC3E,CAAC;QAED;;;;WAIG;QACH,KAAK,CAAC,cAAc;YAClB,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,KAAK,CAAC,OAAO,CAAC,EAAU;YACtB,KAAK,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAC/B,CAAC;QAED,KAAK,CAAC,OAAO,CAAC,EAAU;YACtB,KAAK,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAC/B,CAAC;QAED;;;;WAIG;QACH,KAAK,CAAC,eAAe,CAAC,OAAe;YACnC,KAAK,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC;QACrC,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import express, { Router } from 'express';
|
|
4
|
+
import { renderConsentPage } from '../consent-page.js';
|
|
5
|
+
import { allowFormActionTo, authSecurityHeaders } from '../headers.js';
|
|
6
|
+
import { renderLoginPage } from '../login-page.js';
|
|
7
|
+
import { earlyRateLimit, LoginRateLimiter } from '../rate-limit.js';
|
|
8
|
+
import { isLoopbackOnly } from '../redirect-uri.js';
|
|
9
|
+
import { createSessionCookie, csrfToken, readSessionCookie, SESSION_COOKIE, SESSION_TTL_MS, verifyCsrfToken } from '../session.js';
|
|
10
|
+
import { logSafe } from '../text.js';
|
|
11
|
+
import { HUB_ACCOUNT_ID } from './provider.js';
|
|
12
|
+
/**
|
|
13
|
+
* The hub's own login and consent pages, driven by oidc-provider's interaction
|
|
14
|
+
* loop instead of by HubOAuthProvider's hand-rolled one.
|
|
15
|
+
*
|
|
16
|
+
* The pages themselves are unchanged. They post to a RELATIVE `login` and
|
|
17
|
+
* `consent`, and that resolves to `/interaction/<uid>/login` because the page is
|
|
18
|
+
* served from a path ending in a slash — which it has to be anyway, since
|
|
19
|
+
* oidc-provider scopes the interaction cookie to exactly that path. The
|
|
20
|
+
* interaction id still travels in the hidden `request` field too, and is checked
|
|
21
|
+
* so a form cannot be replayed against a different interaction.
|
|
22
|
+
*
|
|
23
|
+
* Two behaviours from the old flow are load-bearing and preserved here:
|
|
24
|
+
*
|
|
25
|
+
* - **Typing the password is the consent.** A first-time client is approved
|
|
26
|
+
* by the login itself, so the consent page is only ever shown to someone
|
|
27
|
+
* who is already signed in and is being asked about a NEW client.
|
|
28
|
+
* - **One session cookie.** `mcp_hub_session` is set alongside
|
|
29
|
+
* oidc-provider's own, because `hasValidSession()` is read outside the auth
|
|
30
|
+
* layer by the upstream OAuth callback. Without it the operator would have
|
|
31
|
+
* to log in twice for two different things.
|
|
32
|
+
*/
|
|
33
|
+
export function createOidcInteractionRoutes(options) {
|
|
34
|
+
const { provider, store } = options;
|
|
35
|
+
const router = Router();
|
|
36
|
+
const rateLimiter = new LoginRateLimiter();
|
|
37
|
+
// The pages this router serves are the ones with a password field on them, so
|
|
38
|
+
// they need the anti-clickjacking and no-store headers at least as much as
|
|
39
|
+
// the token endpoint does. `allowFormActionTo` narrows the CSP afterwards for
|
|
40
|
+
// the single page that has a redirect target to allow.
|
|
41
|
+
router.use(authSecurityHeaders);
|
|
42
|
+
const secure = new URL(options.externalUrl).protocol === 'https:';
|
|
43
|
+
const checkPassword = (password) => {
|
|
44
|
+
if (options.passwordHash)
|
|
45
|
+
return bcrypt.compareSync(password, options.passwordHash);
|
|
46
|
+
const expected = Buffer.from(options.password ?? '');
|
|
47
|
+
const given = Buffer.from(password);
|
|
48
|
+
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
|
|
49
|
+
};
|
|
50
|
+
/** What the page may say about who is asking, and what it must not claim. */
|
|
51
|
+
const identityOf = async (clientId, resource) => {
|
|
52
|
+
const client = await provider.Client.find(clientId);
|
|
53
|
+
const metadata = client?.metadata();
|
|
54
|
+
return {
|
|
55
|
+
clientName: typeof metadata?.client_name === 'string' ? metadata.client_name : undefined,
|
|
56
|
+
resource,
|
|
57
|
+
// A metadata-document client is identified by a URL nobody can forge, so
|
|
58
|
+
// the page can show where the self-declared name came from.
|
|
59
|
+
clientId: options.cimd && clientId.startsWith('https://') ? clientId : undefined,
|
|
60
|
+
loopbackOnly: isLoopbackOnly(metadata?.redirect_uris)
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const expired = (res, status, message) => {
|
|
64
|
+
res.status(status).type('html').send(`<p>${message}</p>`);
|
|
65
|
+
};
|
|
66
|
+
// The two POSTs below carry a limiter and this GET did not, which is the
|
|
67
|
+
// asymmetry CodeQL noticed and it was right. The page is unauthenticated by
|
|
68
|
+
// construction — the uid is all a caller has — and rendering it costs a
|
|
69
|
+
// provider store lookup, so a flood is cheap to send and not free to serve.
|
|
70
|
+
// The same window as its siblings; a person opening a login page a hundred
|
|
71
|
+
// times in fifteen minutes has a different problem.
|
|
72
|
+
router.get('/interaction/:uid', earlyRateLimit(15 * 60_000, 100, 500), async (req, res, next) => {
|
|
73
|
+
try {
|
|
74
|
+
const details = await provider.interactionDetails(req, res);
|
|
75
|
+
const params = details.params;
|
|
76
|
+
const redirectUri = String(params.redirect_uri ?? '');
|
|
77
|
+
const identity = await identityOf(String(params.client_id), params.resource);
|
|
78
|
+
allowFormActionTo(res, redirectUri);
|
|
79
|
+
if (details.prompt.name === 'login') {
|
|
80
|
+
res.status(200).type('html').send(renderLoginPage(details.uid, redirectUri, identity));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
|
|
84
|
+
if (!session) {
|
|
85
|
+
expired(res, 401, 'Session expired. Close this window and connect again.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
res
|
|
89
|
+
.status(200)
|
|
90
|
+
.type('html')
|
|
91
|
+
.send(renderConsentPage(details.uid, csrfToken(session, store.cookieSecret), redirectUri, identity));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// interactionDetails throws for an unknown or expired uid, which is not
|
|
95
|
+
// an error worth a stack trace: the window was left open too long.
|
|
96
|
+
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
97
|
+
void next;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
router.post('/interaction/:uid/login', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
|
|
101
|
+
const ip = req.ip ?? 'unknown';
|
|
102
|
+
const { password, request } = req.body;
|
|
103
|
+
let details;
|
|
104
|
+
try {
|
|
105
|
+
details = await provider.interactionDetails(req, res);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (typeof request !== 'string' || request !== details.uid) {
|
|
112
|
+
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (rateLimiter.isBlocked(ip)) {
|
|
116
|
+
console.warn(`mcp-hub: login rate limit exceeded from ${logSafe(ip)}`);
|
|
117
|
+
expired(res, 429, 'Too many attempts. Try again later.');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const params = details.params;
|
|
121
|
+
const redirectUri = String(params.redirect_uri ?? '');
|
|
122
|
+
if (typeof password !== 'string' || !checkPassword(password)) {
|
|
123
|
+
rateLimiter.recordFailure(ip);
|
|
124
|
+
console.warn(`mcp-hub: authentication failure from ${logSafe(ip)}`);
|
|
125
|
+
const identity = await identityOf(String(params.client_id), params.resource);
|
|
126
|
+
allowFormActionTo(res, redirectUri);
|
|
127
|
+
res.status(401).type('html').send(renderLoginPage(details.uid, redirectUri, identity, 'Wrong password'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
rateLimiter.reset(ip);
|
|
131
|
+
console.log(`mcp-hub: successful login from ${logSafe(ip)}`);
|
|
132
|
+
// Typing the password is the consent for the client that triggered it.
|
|
133
|
+
const clientId = String(params.client_id);
|
|
134
|
+
const client = await provider.Client.find(clientId);
|
|
135
|
+
const clientName = client?.metadata()?.client_name;
|
|
136
|
+
store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined);
|
|
137
|
+
console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(redirectUri)}`);
|
|
138
|
+
res.cookie(SESSION_COOKIE, createSessionCookie(store.cookieSecret), {
|
|
139
|
+
httpOnly: true,
|
|
140
|
+
secure,
|
|
141
|
+
sameSite: 'lax',
|
|
142
|
+
maxAge: SESSION_TTL_MS,
|
|
143
|
+
path: '/'
|
|
144
|
+
});
|
|
145
|
+
await provider.interactionFinished(req, res, { login: { accountId: HUB_ACCOUNT_ID } }, { mergeWithLastSubmission: false });
|
|
146
|
+
});
|
|
147
|
+
router.post('/interaction/:uid/consent', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
|
|
148
|
+
const { request, csrf, action } = req.body;
|
|
149
|
+
let details;
|
|
150
|
+
try {
|
|
151
|
+
details = await provider.interactionDetails(req, res);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof request !== 'string' || request !== details.uid) {
|
|
158
|
+
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
|
|
162
|
+
if (!session) {
|
|
163
|
+
expired(res, 401, 'Session expired. Close this window and connect again.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!verifyCsrfToken(session, csrf, store.cookieSecret)) {
|
|
167
|
+
console.warn(`mcp-hub: consent with an invalid CSRF token from ${logSafe(req.ip ?? 'unknown')}`);
|
|
168
|
+
expired(res, 403, 'This form is no longer valid. Close this window and connect again.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (action !== 'approve') {
|
|
172
|
+
await provider.interactionFinished(req, res, { error: 'access_denied' }, { mergeWithLastSubmission: false });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const params = details.params;
|
|
176
|
+
const clientId = String(params.client_id);
|
|
177
|
+
const client = await provider.Client.find(clientId);
|
|
178
|
+
const clientName = client?.metadata()?.client_name;
|
|
179
|
+
store.saveApproval(clientId, String(params.redirect_uri ?? ''), typeof clientName === 'string' ? clientName : undefined);
|
|
180
|
+
console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(String(params.redirect_uri ?? ''))}`);
|
|
181
|
+
// The grant itself is minted by loadExistingGrant on the resumed request,
|
|
182
|
+
// which is the same code path an already-approved client takes.
|
|
183
|
+
await provider.interactionFinished(req, res, { consent: {} }, { mergeWithLastSubmission: true });
|
|
184
|
+
});
|
|
185
|
+
return router;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=interactions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interactions.js","sourceRoot":"","sources":["../../../src/auth/oidc/interactions.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { earlyRateLimit } from '../rate-limit.js';
|
|
2
|
+
import { defaultScope, stripPhantomSecret } from './quirks.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every path the provider answers on, given the `routes` reconfiguration in
|
|
5
|
+
* provider.ts.
|
|
6
|
+
*
|
|
7
|
+
* Two of these are not endpoints and are easy to miss:
|
|
8
|
+
*
|
|
9
|
+
* - `/authorize/:uid` is the RESUME route (initialize_app.js registers it at
|
|
10
|
+
* `${routes.authorization}/:uid`). Without it the redirect back from the
|
|
11
|
+
* login page lands in the hub's 404 handler and the authorization flow
|
|
12
|
+
* dead-ends silently, with a plausible-looking error.
|
|
13
|
+
* - `/register/:id` is RFC 7592 registration management.
|
|
14
|
+
*
|
|
15
|
+
* Both `.well-known` paths are served by the provider with an identical body,
|
|
16
|
+
* which is exactly the alias the hub used to build by hand for ChatGPT.
|
|
17
|
+
*/
|
|
18
|
+
export const OIDC_PATHS = [
|
|
19
|
+
'/authorize',
|
|
20
|
+
'/authorize/:uid',
|
|
21
|
+
'/token',
|
|
22
|
+
'/revoke',
|
|
23
|
+
'/register',
|
|
24
|
+
'/register/:id',
|
|
25
|
+
'/jwks',
|
|
26
|
+
'/.well-known/oauth-authorization-server',
|
|
27
|
+
'/.well-known/openid-configuration'
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Makes EXTERNAL_URL authoritative for every URL the provider publishes.
|
|
31
|
+
*
|
|
32
|
+
* oidc-provider builds endpoint URLs from the REQUEST (`urlFor` resolves against
|
|
33
|
+
* `ctx.href`), not from the issuer. Two consequences the hub cannot accept:
|
|
34
|
+
*
|
|
35
|
+
* - behind a reverse proxy the document would advertise the internal host, and
|
|
36
|
+
* - a request carrying `Host: evil.example` would be answered with a discovery
|
|
37
|
+
* document pointing at evil.example.
|
|
38
|
+
*
|
|
39
|
+
* The hub already treats EXTERNAL_URL as the single source of truth and compares
|
|
40
|
+
* it byte for byte, so overwriting the request's idea of its own origin is the
|
|
41
|
+
* faithful translation rather than a workaround.
|
|
42
|
+
*
|
|
43
|
+
* It also closes the `provider.proxy` gap. That flag is a boolean — it trusts
|
|
44
|
+
* any `X-Forwarded-*` — whereas Express was configured with a CIDR list. Because
|
|
45
|
+
* all three headers are overwritten here, the client cannot influence them, and
|
|
46
|
+
* `X-Forwarded-For` is replaced with the address Express already resolved
|
|
47
|
+
* against TRUSTED_PROXIES.
|
|
48
|
+
*/
|
|
49
|
+
export function forceExternalOrigin(externalUrl) {
|
|
50
|
+
const url = new URL(externalUrl);
|
|
51
|
+
const proto = url.protocol.replace(':', '');
|
|
52
|
+
return (req, _res, next) => {
|
|
53
|
+
req.headers.host = url.host;
|
|
54
|
+
req.headers['x-forwarded-host'] = url.host;
|
|
55
|
+
req.headers['x-forwarded-proto'] = proto;
|
|
56
|
+
if (req.ip)
|
|
57
|
+
req.headers['x-forwarded-for'] = req.ip;
|
|
58
|
+
else
|
|
59
|
+
delete req.headers['x-forwarded-for'];
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The same budgets the hub applied before, per path.
|
|
65
|
+
*
|
|
66
|
+
* They belong here rather than at the call site because the mounted Koa app is
|
|
67
|
+
* never entered by the Express router: a limit that is not put in front of the
|
|
68
|
+
* mount simply does not exist, and nothing about the resulting configuration
|
|
69
|
+
* looks wrong. `/register` is the tightest — registration is open — and it also
|
|
70
|
+
* has to cover `/register/:id`, whose own budget is more generous because
|
|
71
|
+
* routine management would otherwise use up a client's ability to register.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultRateLimits() {
|
|
74
|
+
return {
|
|
75
|
+
'/register': [earlyRateLimit(60 * 60_000, 20, 200)],
|
|
76
|
+
'/register/:id': [earlyRateLimit(60 * 60_000, 60, 600)],
|
|
77
|
+
'/authorize': [earlyRateLimit(15 * 60_000, 100, 1_000)],
|
|
78
|
+
'/authorize/:uid': [earlyRateLimit(15 * 60_000, 100, 1_000)],
|
|
79
|
+
'/token': [earlyRateLimit(15 * 60_000, 50, 500)]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Mounts the authorization server into the hub's Express app.
|
|
84
|
+
*
|
|
85
|
+
* `app.all(path, ...)` and NOT `app.use(path, ...)`: `use` strips the prefix
|
|
86
|
+
* from `req.url`, while oidc-provider derives its own mount path from the
|
|
87
|
+
* issuer (provider.js `#mountPath = new URL(issuer).pathname`). The hub's
|
|
88
|
+
* issuer is the origin root, so stripping would leave the router looking at
|
|
89
|
+
* paths the published URLs do not match.
|
|
90
|
+
*
|
|
91
|
+
* The mounted app is also a dead end — Koa's `handleRequest` always responds,
|
|
92
|
+
* so anything that reaches it never returns to Express. Mounting per path
|
|
93
|
+
* rather than at the root is what keeps `/livez`, `/health` and every MCP route
|
|
94
|
+
* reachable.
|
|
95
|
+
*/
|
|
96
|
+
export function mountOidcProvider(app, provider, store, options) {
|
|
97
|
+
const callback = provider.callback();
|
|
98
|
+
// Safe only in combination with forceExternalOrigin, which overwrites every
|
|
99
|
+
// forwarded header the client could otherwise set.
|
|
100
|
+
provider.proxy = true;
|
|
101
|
+
/**
|
|
102
|
+
* Errors do not cross the boundary. A throw inside the Koa app is caught by
|
|
103
|
+
* `ctx.onerror` and never reaches the hub's four-argument Express error
|
|
104
|
+
* handler, so without this an authorization-server fault would be invisible
|
|
105
|
+
* in the hub's logs.
|
|
106
|
+
*/
|
|
107
|
+
provider.on('server_error', (_ctx, error) => {
|
|
108
|
+
console.error(`mcp-hub: authorization server failed: ${error.message}`);
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* RFC 8414 also defines a path-insertion form, and clients probing a specific
|
|
112
|
+
* resource look up `/.well-known/oauth-authorization-server/<name>/mcp` before
|
|
113
|
+
* falling back to the root document. oidc-provider registers only the exact
|
|
114
|
+
* paths, so without this the suffix form 404s — which the hub answered before.
|
|
115
|
+
* One authorization server covers every resource, so the answer is the same
|
|
116
|
+
* document.
|
|
117
|
+
*/
|
|
118
|
+
for (const base of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) {
|
|
119
|
+
app.get(`${base}/{*splat}`, ...(options.common ?? []), forceExternalOrigin(options.externalUrl), (req, res) => {
|
|
120
|
+
req.url = base;
|
|
121
|
+
callback(req, res);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Built once: each entry holds a counter, and rebuilding per path would give
|
|
125
|
+
// every route a fresh, empty limiter on every iteration.
|
|
126
|
+
const before = options.before ?? defaultRateLimits();
|
|
127
|
+
for (const path of OIDC_PATHS) {
|
|
128
|
+
const handlers = [
|
|
129
|
+
...(options.common ?? []),
|
|
130
|
+
...(before[path] ?? []),
|
|
131
|
+
forceExternalOrigin(options.externalUrl)
|
|
132
|
+
];
|
|
133
|
+
if (path === '/authorize')
|
|
134
|
+
handlers.push(defaultScope);
|
|
135
|
+
// Reads the body, so it has to BE the terminal handler rather than sit in
|
|
136
|
+
// front of one: the request stream can only be consumed once.
|
|
137
|
+
if (path === '/token')
|
|
138
|
+
handlers.push(stripPhantomSecret(store, callback));
|
|
139
|
+
else
|
|
140
|
+
handlers.push((req, res) => callback(req, res));
|
|
141
|
+
app.all(path, ...handlers);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=mount.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.js","sourceRoot":"","sources":["../../../src/auth/oidc/mount.ts"],"names":[],"mappings":"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"}
|