@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +388 -0
  2. package/README.md +77 -12
  3. package/dist/admin.js +1 -1
  4. package/dist/admin.js.map +1 -1
  5. package/dist/auth/api-tokens.js +27 -0
  6. package/dist/auth/api-tokens.js.map +1 -0
  7. package/dist/auth/headers.js +22 -1
  8. package/dist/auth/headers.js.map +1 -1
  9. package/dist/auth/oidc/adapter.js +135 -0
  10. package/dist/auth/oidc/adapter.js.map +1 -0
  11. package/dist/auth/oidc/interactions.js +187 -0
  12. package/dist/auth/oidc/interactions.js.map +1 -0
  13. package/dist/auth/oidc/mount.js +144 -0
  14. package/dist/auth/oidc/mount.js.map +1 -0
  15. package/dist/auth/oidc/provider.js +440 -0
  16. package/dist/auth/oidc/provider.js.map +1 -0
  17. package/dist/auth/oidc/quirks.js +234 -0
  18. package/dist/auth/oidc/quirks.js.map +1 -0
  19. package/dist/auth/oidc/verifier.js +121 -0
  20. package/dist/auth/oidc/verifier.js.map +1 -0
  21. package/dist/auth/protected-resource.js +41 -0
  22. package/dist/auth/protected-resource.js.map +1 -0
  23. package/dist/auth/rate-limit.js +115 -2
  24. package/dist/auth/rate-limit.js.map +1 -1
  25. package/dist/auth/redirect-uri.js +33 -1
  26. package/dist/auth/redirect-uri.js.map +1 -1
  27. package/dist/auth/registration.js +1 -1
  28. package/dist/auth/registration.js.map +1 -1
  29. package/dist/auth/session.js +43 -0
  30. package/dist/auth/session.js.map +1 -0
  31. package/dist/auth/store.js +160 -1
  32. package/dist/auth/store.js.map +1 -1
  33. package/dist/config.js +55 -2
  34. package/dist/config.js.map +1 -1
  35. package/dist/docker-proxy/policy.js +1 -0
  36. package/dist/docker-proxy/policy.js.map +1 -1
  37. package/dist/docker-proxy/server.js +1 -0
  38. package/dist/docker-proxy/server.js.map +1 -1
  39. package/dist/elicitation.js +0 -0
  40. package/dist/elicitation.js.map +1 -0
  41. package/dist/forward.js +0 -0
  42. package/dist/forward.js.map +1 -0
  43. package/dist/hub.js +309 -29
  44. package/dist/hub.js.map +1 -1
  45. package/dist/index.js +103 -21
  46. package/dist/index.js.map +1 -1
  47. package/dist/limits.js +13 -1
  48. package/dist/limits.js.map +1 -1
  49. package/dist/mcp-limits.js +14 -2
  50. package/dist/mcp-limits.js.map +1 -1
  51. package/dist/proxy.js +246 -40
  52. package/dist/proxy.js.map +1 -1
  53. package/dist/stdio.js +71 -6
  54. package/dist/stdio.js.map +1 -1
  55. package/dist/subscriptions.js +236 -0
  56. package/dist/subscriptions.js.map +1 -0
  57. package/dist/supervisor.js +322 -22
  58. package/dist/supervisor.js.map +1 -1
  59. package/dist/timings.js +61 -0
  60. package/dist/timings.js.map +1 -0
  61. package/dist/tool-filter.js +1 -0
  62. package/dist/tool-filter.js.map +1 -1
  63. package/dist/transports/docker.js.map +1 -1
  64. package/dist/transports/stream.js +33 -20
  65. package/dist/transports/stream.js.map +1 -1
  66. package/dist/upstream/auth.js +1 -1
  67. package/dist/upstream/auth.js.map +1 -1
  68. package/dist/upstream/provider.js.map +1 -1
  69. package/dist/upstream/routes.js +3 -2
  70. package/dist/upstream/routes.js.map +1 -1
  71. package/package.json +19 -6
  72. package/dist/auth/private-key-jwt.js +0 -213
  73. package/dist/auth/private-key-jwt.js.map +0 -1
  74. package/dist/auth/provider.js +0 -437
  75. package/dist/auth/provider.js.map +0 -1
  76. package/dist/auth/routes.js +0 -249
  77. package/dist/auth/routes.js.map +0 -1
@@ -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
- export function contentSecurityPolicy(formActionOrigins = []) {
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
@@ -1 +1 @@
1
- {"version":3,"file":"headers.js","sourceRoot":"","sources":["../../src/auth/headers.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CAAC,iBAAiB,GAAsB,EAAE;IAC7E,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"}
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":"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"}
@@ -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":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,MAAM,MAAM,UAAU,CAAC;AAC9B,OAAO,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAI1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEnI,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAW/C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,2BAA2B,CAAC,OAA+B;IACzE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC;IACpC,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;IACxB,MAAM,WAAW,GAAG,IAAI,gBAAgB,EAAE,CAAC;IAE3C,8EAA8E;IAC9E,2EAA2E;IAC3E,8EAA8E;IAC9E,uDAAuD;IACvD,MAAM,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IAChC,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC;IAElE,MAAM,aAAa,GAAG,CAAC,QAAgB,EAAW,EAAE;QAClD,IAAI,OAAO,CAAC,YAAY;YAAE,OAAO,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;QACpF,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC;QACrD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpC,OAAO,QAAQ,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,MAAM,CAAC,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACrF,CAAC,CAAC;IAEF,6EAA6E;IAC7E,MAAM,UAAU,GAAG,KAAK,EAAE,QAAgB,EAAE,QAAiB,EAAE,EAAE;QAC/D,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpD,MAAM,QAAQ,GAAG,MAAM,EAAE,QAAQ,EAAyC,CAAC;QAC3E,OAAO;YACL,UAAU,EAAE,OAAO,QAAQ,EAAE,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;YACxF,QAAQ;YACR,yEAAyE;YACzE,4DAA4D;YAC5D,QAAQ,EAAE,OAAO,CAAC,IAAI,IAAI,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS;YAChF,YAAY,EAAE,cAAc,CAAC,QAAQ,EAAE,aAAqC,CAAC;SAC9E,CAAC;IACJ,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,CAAC,GAAqB,EAAE,MAAc,EAAE,OAAe,EAAQ,EAAE;QAC/E,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,OAAO,MAAM,CAAC,CAAC;IAC5D,CAAC,CAAC;IAEF,yEAAyE;IACzE,4EAA4E;IAC5E,wEAAwE;IACxE,4EAA4E;IAC5E,2EAA2E;IAC3E,oDAAoD;IACpD,MAAM,CAAC,GAAG,CAAC,mBAAmB,EAAE,cAAc,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC9F,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YAC5D,MAAM,MAAM,GAAG,OAAO,CAAC,MAA0E,CAAC;YAClG,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC;YACtD,MAAM,QAAQ,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;YAC7E,iBAAiB,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;YAEpC,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBACpC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;gBACvF,OAAO;YACT,CAAC;YACD,MAAM,OAAO,GAAG,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;YAC1E,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,uDAAuD,CAAC,CAAC;gBAC3E,OAAO;YACT,CAAC;YACD,GAAG;iBACA,MAAM,CAAC,GAAG,CAAC;iBACX,IAAI,CAAC,MAAM,CAAC;iBACZ,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,GAAG,EAAE,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;QACzG,CAAC;QAAC,MAAM,CAAC;YACP,wEAAwE;YACxE,mEAAmE;YACnE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qEAAqE,CAAC,CAAC;YACzF,KAAK,IAAI,CAAC;QACZ,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,IAAI,CACT,yBAAyB,EACzB,cAAc,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,EACrC,OAAO,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EACvC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACjB,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,IAAI,SAAS,CAAC;QAC/B,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,GAAG,CAAC,IAA+C,CAAC;QAClF,IAAI,OAAO,CAAC;QACZ,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,QAAQ,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACxD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qEAAqE,CAAC,CAAC;YACzF,OAAO;QACT,CAAC;QACD,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC;YAC3D,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qEAAqE,CAAC,CAAC;YACzF,OAAO;QACT,CAAC;QACD,IAAI,WAAW,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;YAC9B,OAAO,CAAC,IAAI,CAAC,2CAA2C,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;YACvE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qCAAqC,CAAC,CAAC;YACzD,OAAO;QACT,CAAC;QAED,MAAM,MAAM,GAAG,OAAO,CAAC,MAA0E,CAAC;QAClG,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC;QACtD,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC7D,WAAW,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;YAC9B,OAAO,CAAC,IAAI,CAAC,wCAAwC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;YACpE,MAAM,QAAQ,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;YAC7E,iBAAiB,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;YACpC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,WAAW,EAAE,QAAQ,EAAE,gBAAgB,CAAC,CAAC,CAAC;YACzG,OAAO;QACT,CAAC;QAED,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACtB,OAAO,CAAC,GAAG,CAAC,kCAAkC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7D,uEAAuE;QACvE,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpD,MAAM,UAAU,GAAI,MAAM,EAAE,QAAQ,EAA0C,EAAE,WAAW,CAAC;QAC5F,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACnG,OAAO,CAAC,GAAG,CAAC,kCAAkC,OAAO,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QAE/F,GAAG,CAAC,MAAM,CAAC,cAAc,EAAE,mBAAmB,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE;YAClE,QAAQ,EAAE,IAAI;YACd,MAAM;YACN,QAAQ,EAAE,KAAK;YACf,MAAM,EAAE,cAAc;YACtB,IAAI,EAAE,GAAG;SACV,CAAC,CAAC;QACH,MAAM,QAAQ,CAAC,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,EAAE,EAAE,EAAE,uBAAuB,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7H,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,2BAA2B,EAC3B,cAAc,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,EACrC,OAAO,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EACvC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACjB,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,IAA4D,CAAC;QACnG,IAAI,OAAO,CAAC;QACZ,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,QAAQ,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACxD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qEAAqE,CAAC,CAAC;YACzF,OAAO;QACT,CAAC;QACD,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC;YAC3D,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,qEAAqE,CAAC,CAAC;YACzF,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;QAC1E,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,uDAAuD,CAAC,CAAC;YAC3E,OAAO;QACT,CAAC;QACD,IAAI,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC;YACxD,OAAO,CAAC,IAAI,CAAC,oDAAoD,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,SAAS,CAAC,EAAE,CAAC,CAAC;YACjG,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,oEAAoE,CAAC,CAAC;YACxF,OAAO;QACT,CAAC;QAED,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,QAAQ,CAAC,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,EAAE,uBAAuB,EAAE,KAAK,EAAE,CAAC,CAAC;YAC7G,OAAO;QACT,CAAC;QAED,MAAM,MAAM,GAAG,OAAO,CAAC,MAAuD,CAAC;QAC/E,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpD,MAAM,UAAU,GAAI,MAAM,EAAE,QAAQ,EAA0C,EAAE,WAAW,CAAC;QAC5F,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACzH,OAAO,CAAC,GAAG,CAAC,kCAAkC,OAAO,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;QACrH,0EAA0E;QAC1E,gEAAgE;QAChE,MAAM,QAAQ,CAAC,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,uBAAuB,EAAE,IAAI,EAAE,CAAC,CAAC;IACnG,CAAC,CACF,CAAC;IAEF,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -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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"}