mcp-compress-router 2.0.0 → 3.0.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/build/cli/login-callback-server.js +201 -0
- package/build/cli/login-command.js +110 -133
- package/build/services/oauth-discovery.js +114 -29
- package/build/services/oauth.js +46 -11
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
/**
|
|
4
|
+
* Starts a temporary loopback HTTP server on `callbackPort`, runs the
|
|
5
|
+
* authorization-code flow through `beginAuthorization`, and waits for the
|
|
6
|
+
* browser redirect carrying the authorization code. The callback must echo
|
|
7
|
+
* the generated `state`; when it carries `iss`, the value must match the
|
|
8
|
+
* expected authorization-server issuer.
|
|
9
|
+
*
|
|
10
|
+
* The callback timeout comes from `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS`
|
|
11
|
+
* (default 120 seconds).
|
|
12
|
+
*
|
|
13
|
+
* @param options - Callback server inputs.
|
|
14
|
+
* @returns The authorization code and the `startAuthorization` result.
|
|
15
|
+
* @throws If the server cannot bind, `beginAuthorization` fails, the
|
|
16
|
+
* authorization server redirects with an `error`, or the callback does
|
|
17
|
+
* not arrive before the timeout. A callback that fails state or issuer
|
|
18
|
+
* validation is ignored — it never settles the flow, so it surfaces as
|
|
19
|
+
* a timeout instead.
|
|
20
|
+
*/
|
|
21
|
+
export async function acquireAuthorizationCode(options) {
|
|
22
|
+
return startCallbackServerAndWait({
|
|
23
|
+
options,
|
|
24
|
+
state: randomBytes(32).toString('base64url'),
|
|
25
|
+
timeoutMs: readTimeoutMs(),
|
|
26
|
+
timeoutHandle: { current: undefined },
|
|
27
|
+
authResultRef: { value: undefined },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reads the OAuth login timeout from the
|
|
32
|
+
* `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS` env var, defaulting to 120 seconds.
|
|
33
|
+
*
|
|
34
|
+
* @returns Timeout in milliseconds.
|
|
35
|
+
*/
|
|
36
|
+
function readTimeoutMs() {
|
|
37
|
+
const env = process.env.MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS;
|
|
38
|
+
if (env) {
|
|
39
|
+
const parsed = parseInt(env, 10);
|
|
40
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
return 120_000;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Creates the temporary HTTP server, binds it to the loopback interface,
|
|
47
|
+
* and resolves with the authorization code once the browser delivers the
|
|
48
|
+
* callback.
|
|
49
|
+
*
|
|
50
|
+
* @param flow - Callback flow state for this login attempt.
|
|
51
|
+
* @returns The authorization code and the `startAuthorization` result.
|
|
52
|
+
*/
|
|
53
|
+
async function startCallbackServerAndWait(flow) {
|
|
54
|
+
const authorizationCode = await new Promise((resolve, reject) => {
|
|
55
|
+
const tempServer = http.createServer();
|
|
56
|
+
tempServer.on('request', makeCallbackHandler(flow, tempServer, resolve, reject));
|
|
57
|
+
tempServer.listen(flow.options.callbackPort, '127.0.0.1', onServerListen(flow, tempServer, reject));
|
|
58
|
+
tempServer.on('error', (err) => {
|
|
59
|
+
if (flow.timeoutHandle.current)
|
|
60
|
+
clearTimeout(flow.timeoutHandle.current);
|
|
61
|
+
reject(err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
if (flow.timeoutHandle.current)
|
|
65
|
+
clearTimeout(flow.timeoutHandle.current);
|
|
66
|
+
return { authorizationCode, authResult: flow.authResultRef.value };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validates the callback query against the login attempt: the CSRF
|
|
70
|
+
* `state` must match, and a present `iss` must equal the expected
|
|
71
|
+
* authorization-server issuer (a missing `iss` fails validation when the
|
|
72
|
+
* server advertised RFC 9207 support).
|
|
73
|
+
*
|
|
74
|
+
* @param flow - Callback flow state for this login attempt.
|
|
75
|
+
* @param url - The parsed callback URL.
|
|
76
|
+
* @returns An error message when the callback must be ignored, or
|
|
77
|
+
* undefined when it is valid.
|
|
78
|
+
*/
|
|
79
|
+
function validateCallback(flow, url) {
|
|
80
|
+
if (url.searchParams.get('state') !== flow.state) {
|
|
81
|
+
return 'OAuth callback state mismatch: the response does not belong to this login attempt.';
|
|
82
|
+
}
|
|
83
|
+
const iss = url.searchParams.get('iss');
|
|
84
|
+
if (iss !== null && iss !== flow.options.expectedIssuer) {
|
|
85
|
+
return `OAuth callback issuer mismatch: expected "${flow.options.expectedIssuer}", received "${iss}".`;
|
|
86
|
+
}
|
|
87
|
+
if (iss === null && flow.options.requireIssuer) {
|
|
88
|
+
return 'OAuth callback is missing the issuer parameter (iss) advertised by the authorization server.';
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Escapes the HTML special characters in a reflected value, so an OAuth
|
|
94
|
+
* error string cannot inject markup into the callback page.
|
|
95
|
+
*
|
|
96
|
+
* @param value - The raw value to escape.
|
|
97
|
+
* @returns The escaped value.
|
|
98
|
+
*/
|
|
99
|
+
function escapeHtml(value) {
|
|
100
|
+
return value
|
|
101
|
+
.replaceAll('&', '&')
|
|
102
|
+
.replaceAll('<', '<')
|
|
103
|
+
.replaceAll('>', '>')
|
|
104
|
+
.replaceAll('"', '"')
|
|
105
|
+
.replaceAll("'", ''');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Creates the HTTP request listener for the temporary callback server.
|
|
109
|
+
*
|
|
110
|
+
* Validates the CSRF `state` echoed by the authorization server and, when
|
|
111
|
+
* present, the RFC 9207 `iss` issuer parameter before acting on the
|
|
112
|
+
* callback. A callback that fails validation is ignored: it gets a 400
|
|
113
|
+
* response but never settles the flow, so a stray loopback request cannot
|
|
114
|
+
* abort a login whose legitimate callback is still on its way. Uses
|
|
115
|
+
* `tempServer` captured via closure to close the listener once the
|
|
116
|
+
* callback settles.
|
|
117
|
+
*
|
|
118
|
+
* @param flow - Callback flow state for this login attempt.
|
|
119
|
+
* @param tempServer - The temporary callback server (closed on completion).
|
|
120
|
+
* @param resolve - Promise resolve function (authorization code).
|
|
121
|
+
* @param reject - Promise reject function.
|
|
122
|
+
* @returns An HTTP request listener.
|
|
123
|
+
*/
|
|
124
|
+
function makeCallbackHandler(flow, tempServer, resolve, reject) {
|
|
125
|
+
const fail = (message) => {
|
|
126
|
+
if (flow.timeoutHandle.current)
|
|
127
|
+
clearTimeout(flow.timeoutHandle.current);
|
|
128
|
+
tempServer.close();
|
|
129
|
+
reject(new Error(message));
|
|
130
|
+
};
|
|
131
|
+
return (req, res) => {
|
|
132
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
133
|
+
const error = url.searchParams.get('error');
|
|
134
|
+
const code = url.searchParams.get('code');
|
|
135
|
+
// Only authorization responses (a code or an error) carry a state to
|
|
136
|
+
// validate; anything else is not a callback.
|
|
137
|
+
if (error !== null || code !== null) {
|
|
138
|
+
const validationError = validateCallback(flow, url);
|
|
139
|
+
if (validationError) {
|
|
140
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
141
|
+
res.end('<html><body><h1>Authorization failed</h1><p>Invalid OAuth callback.</p></body></html>');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (error !== null) {
|
|
146
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
147
|
+
res.end(`<html><body><h1>Authorization failed</h1><p>${escapeHtml(error)}</p></body></html>`);
|
|
148
|
+
fail(`Authorization failed: ${error}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (code === null) {
|
|
152
|
+
res.writeHead(404);
|
|
153
|
+
res.end('Not found');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (flow.timeoutHandle.current)
|
|
157
|
+
clearTimeout(flow.timeoutHandle.current);
|
|
158
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
159
|
+
res.end('<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>');
|
|
160
|
+
tempServer.close();
|
|
161
|
+
resolve(code);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Listen callback: records the bound port, prepares the authorization
|
|
166
|
+
* request, opens the browser, and arms the callback timeout. Closes the
|
|
167
|
+
* listener when preparation fails so the process is not kept alive while
|
|
168
|
+
* the error is reported.
|
|
169
|
+
*
|
|
170
|
+
* @param flow - Callback flow state for this login attempt.
|
|
171
|
+
* @param tempServer - The temporary callback server.
|
|
172
|
+
* @param reject - Promise reject function.
|
|
173
|
+
* @returns The async listen handler.
|
|
174
|
+
*/
|
|
175
|
+
function onServerListen(flow, tempServer, reject) {
|
|
176
|
+
return async () => {
|
|
177
|
+
try {
|
|
178
|
+
const address = tempServer.address();
|
|
179
|
+
if (!address || typeof address === 'string') {
|
|
180
|
+
throw new Error('Failed to get server address');
|
|
181
|
+
}
|
|
182
|
+
// Record the OS-assigned port before the authorization request, so
|
|
183
|
+
// it (and the token exchange that follows) carries the real
|
|
184
|
+
// redirect URI.
|
|
185
|
+
flow.options.mgr.setActualPort(address.port);
|
|
186
|
+
flow.authResultRef.value = await flow.options.beginAuthorization(flow.state);
|
|
187
|
+
await flow.options.mgr.saveCodeVerifier(flow.authResultRef.value.codeVerifier);
|
|
188
|
+
void flow.options.openBrowser(flow.authResultRef.value.authorizationUrl.toString());
|
|
189
|
+
flow.timeoutHandle.current = setTimeout(() => {
|
|
190
|
+
tempServer.close();
|
|
191
|
+
reject(new Error(`OAuth login timed out after ${flow.timeoutMs / 1000} seconds. Please try again.`));
|
|
192
|
+
}, flow.timeoutMs);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// Close the listener so a failed setup cannot keep the CLI process
|
|
196
|
+
// alive while the error is reported.
|
|
197
|
+
tempServer.close();
|
|
198
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -2,6 +2,8 @@ import { Logger } from '../utils/index.js';
|
|
|
2
2
|
import { ensureConfigDir, readConfigFile } from './config-io.js';
|
|
3
3
|
import { loadConfig } from '../services/config.js';
|
|
4
4
|
import { discoverAuth, discoverSingleServer, saveToolCache } from '../services/index.js';
|
|
5
|
+
import { OAUTH_LOOPBACK_URI } from '../services/oauth.js';
|
|
6
|
+
import { acquireAuthorizationCode } from './login-callback-server.js';
|
|
5
7
|
/**
|
|
6
8
|
* Validates that a server exists in config and is eligible for OAuth login.
|
|
7
9
|
*
|
|
@@ -39,158 +41,124 @@ async function _getSdkAuth() {
|
|
|
39
41
|
}
|
|
40
42
|
return _sdkAuth;
|
|
41
43
|
}
|
|
44
|
+
// Cached lazy import for the browser launcher.
|
|
45
|
+
let _openBrowser;
|
|
42
46
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
47
|
+
* Returns the browser launcher, loading the module on first use.
|
|
48
|
+
*
|
|
49
|
+
* @returns The `openBrowser` function.
|
|
50
|
+
*/
|
|
51
|
+
async function _getOpenBrowser() {
|
|
52
|
+
if (!_openBrowser) {
|
|
53
|
+
_openBrowser = (await import('../utils/open-browser.js')).openBrowser;
|
|
54
|
+
}
|
|
55
|
+
return _openBrowser;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Discovers the OAuth authorization server metadata for a server.
|
|
45
59
|
*
|
|
46
|
-
* @param mgr - OAuth credential manager for the target server.
|
|
47
60
|
* @param targetServer - Typed downstream server configuration.
|
|
48
61
|
* @param name - Server name (for error messages).
|
|
49
|
-
* @returns The discovered OAuth metadata
|
|
50
|
-
*
|
|
62
|
+
* @returns The discovered OAuth metadata and whether the authorization
|
|
63
|
+
* server commits to the RFC 9207 `iss` parameter (read from the raw
|
|
64
|
+
* metadata document, so it also covers OpenID Connect discovery).
|
|
65
|
+
* @throws If the server does not expose OAuth metadata.
|
|
51
66
|
*/
|
|
52
|
-
async function
|
|
53
|
-
const { registerClient } = await _getSdkAuth();
|
|
67
|
+
async function discoverOAuthMetadata(targetServer, name) {
|
|
54
68
|
const serverUrl = new URL(targetServer.url);
|
|
55
69
|
const discovered = await discoverAuth(serverUrl);
|
|
56
70
|
const metadata = discovered.serverMetadata;
|
|
57
71
|
if (!metadata) {
|
|
58
72
|
throw new Error(`Server "${name}" does not expose OAuth metadata.`);
|
|
59
73
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!mgr.hasStaticClient() && !(await mgr.clientInformation())) {
|
|
65
|
-
if (!metadata.registration_endpoint) {
|
|
66
|
-
throw new Error(`Server "${name}" does not support dynamic client registration. Configure an "oauth.clientId" override in mcp.json with a pre-registered client ID.`);
|
|
67
|
-
}
|
|
68
|
-
const regResult = await registerClient(new URL(metadata.registration_endpoint), {
|
|
69
|
-
metadata,
|
|
70
|
-
clientMetadata: mgr.clientMetadata,
|
|
71
|
-
});
|
|
72
|
-
await mgr.saveClientInformation(regResult);
|
|
73
|
-
}
|
|
74
|
-
return metadata;
|
|
74
|
+
return {
|
|
75
|
+
metadata: metadata,
|
|
76
|
+
requireIssuer: discovered.authorizationResponseIssParameterSupported,
|
|
77
|
+
};
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
80
|
+
* Whether a stored client registration covers the portless loopback
|
|
81
|
+
* redirect URI. The comparison ignores the port (RFC 8252 §8.4 excludes
|
|
82
|
+
* it from loopback redirect matching) but requires the scheme, host, and
|
|
83
|
+
* path to match, so a registration for a different host (e.g.
|
|
84
|
+
* `localhost`) is not reused.
|
|
79
85
|
*
|
|
80
|
-
* @param
|
|
81
|
-
* @
|
|
82
|
-
*
|
|
83
|
-
* @returns The authorization code and the full `startAuthorization` result.
|
|
84
|
-
* @throws If the callback times out or the authorization is denied.
|
|
86
|
+
* @param client - The stored client registration.
|
|
87
|
+
* @returns True when at least one registered redirect URI matches
|
|
88
|
+
* {@link OAUTH_LOOPBACK_URI} ignoring the port.
|
|
85
89
|
*/
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
function _registrationCoversLoopback(client) {
|
|
91
|
+
if (!('redirect_uris' in client) || client.redirect_uris.length === 0) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const expected = new URL(OAUTH_LOOPBACK_URI);
|
|
95
|
+
return client.redirect_uris.some((entry) => {
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(entry);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return (parsed.protocol === expected.protocol &&
|
|
104
|
+
parsed.hostname === expected.hostname &&
|
|
105
|
+
parsed.pathname === expected.pathname);
|
|
106
|
+
});
|
|
91
107
|
}
|
|
92
108
|
/**
|
|
93
|
-
*
|
|
109
|
+
* Registers a client through dynamic client registration (RFC 7591) unless
|
|
110
|
+
* a static `oauth.clientId` override is configured or a stored registration
|
|
111
|
+
* already covers the loopback redirect URI. Registration does not depend on
|
|
112
|
+
* the callback port: the registered URI is the portless
|
|
113
|
+
* {@link OAUTH_LOOPBACK_URI}, and RFC 8252 §8.4 excludes the port from
|
|
114
|
+
* loopback redirect matching.
|
|
94
115
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param tempServer - The temporary HTTP server (to close on completion).
|
|
101
|
-
* @param resolve - Promise resolve function.
|
|
102
|
-
* @param reject - Promise reject function.
|
|
103
|
-
* @returns An HTTP request listener.
|
|
116
|
+
* @param mgr - OAuth credential manager for the target server.
|
|
117
|
+
* @param metadata - Discovered OAuth metadata.
|
|
118
|
+
* @param name - Server name (for error messages).
|
|
119
|
+
* @throws If the server does not support dynamic client registration and no
|
|
120
|
+
* reusable registration or static client is available.
|
|
104
121
|
*/
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
clearTimeout(timeoutHandle.current);
|
|
121
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
122
|
-
res.end(`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`);
|
|
123
|
-
tempServer.close();
|
|
124
|
-
reject(new Error(`Authorization failed: ${error}`));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
res.writeHead(404);
|
|
128
|
-
res.end('Not found');
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
/** Creates a temp HTTP server, starts OAuth flow, waits for callback. */
|
|
133
|
-
async function _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, callbackPort) {
|
|
134
|
-
const http = await import('node:http');
|
|
135
|
-
const timeoutHandle = { current: undefined };
|
|
136
|
-
const authResultRef = { value: undefined };
|
|
137
|
-
const authorizationCode = await new Promise((resolve, reject) => {
|
|
138
|
-
const tempServer = http.createServer();
|
|
139
|
-
tempServer.on('request', _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject));
|
|
140
|
-
tempServer.listen(callbackPort, _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject));
|
|
141
|
-
tempServer.on('error', (err) => {
|
|
142
|
-
if (timeoutHandle.current)
|
|
143
|
-
clearTimeout(timeoutHandle.current);
|
|
144
|
-
reject(err);
|
|
145
|
-
});
|
|
122
|
+
async function registerClientIfNeeded(mgr, metadata, name) {
|
|
123
|
+
if (mgr.hasStaticClient()) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const stored = await mgr.clientInformation();
|
|
127
|
+
if (stored && _registrationCoversLoopback(stored)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!metadata.registration_endpoint) {
|
|
131
|
+
throw new Error(`Server "${name}" does not support dynamic client registration. Configure an "oauth.clientId" override in mcp.json with a pre-registered client ID.`);
|
|
132
|
+
}
|
|
133
|
+
const { registerClient } = await _getSdkAuth();
|
|
134
|
+
const registration = await registerClient(new URL(metadata.registration_endpoint), {
|
|
135
|
+
metadata,
|
|
136
|
+
clientMetadata: mgr.clientMetadata,
|
|
146
137
|
});
|
|
147
|
-
|
|
148
|
-
clearTimeout(timeoutHandle.current);
|
|
149
|
-
return { authorizationCode, authResult: authResultRef.value };
|
|
150
|
-
}
|
|
151
|
-
/** Listen callback: sets port, starts auth, opens browser, arms timeout. */
|
|
152
|
-
function _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject) {
|
|
153
|
-
return async () => {
|
|
154
|
-
try {
|
|
155
|
-
const address = tempServer.address();
|
|
156
|
-
if (!address || typeof address === 'string') {
|
|
157
|
-
reject(new Error('Failed to get server address'));
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const actualPort = address.port;
|
|
161
|
-
mgr.setActualPort(actualPort);
|
|
162
|
-
authResultRef.value = await startAuthorization(new URL(metadata.authorization_endpoint), {
|
|
163
|
-
metadata,
|
|
164
|
-
clientInformation: (await mgr.clientInformation()),
|
|
165
|
-
redirectUrl: mgr.redirectUrl,
|
|
166
|
-
scope: targetServer.oauth?.scope,
|
|
167
|
-
});
|
|
168
|
-
await mgr.saveCodeVerifier(authResultRef.value.codeVerifier);
|
|
169
|
-
void openBrowser(authResultRef.value.authorizationUrl.toString());
|
|
170
|
-
timeoutHandle.current = setTimeout(() => {
|
|
171
|
-
tempServer.close();
|
|
172
|
-
reject(new Error(`OAuth login timed out after ${TIMEOUT_MS / 1000} seconds. Please try again.`));
|
|
173
|
-
}, TIMEOUT_MS);
|
|
174
|
-
}
|
|
175
|
-
catch (err) {
|
|
176
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
177
|
-
}
|
|
178
|
-
};
|
|
138
|
+
await mgr.saveClientInformation(registration);
|
|
179
139
|
}
|
|
180
140
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
141
|
+
* Registers the client if needed and builds the authorization URL. Passed to
|
|
142
|
+
* the callback server as its post-listen hook, so it runs once the real
|
|
143
|
+
* callback port is known and the authorization request carries it.
|
|
183
144
|
*
|
|
184
|
-
* @
|
|
145
|
+
* @param mgr - OAuth credential manager for the target server.
|
|
146
|
+
* @param metadata - Discovered OAuth metadata.
|
|
147
|
+
* @param targetServer - Typed downstream server configuration.
|
|
148
|
+
* @param name - Server name (for error messages).
|
|
149
|
+
* @param state - CSRF state generated for this login attempt.
|
|
150
|
+
* @returns The `startAuthorization` result.
|
|
185
151
|
*/
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
152
|
+
async function beginAuthorization(mgr, metadata, targetServer, name, state) {
|
|
153
|
+
await registerClientIfNeeded(mgr, metadata, name);
|
|
154
|
+
const { startAuthorization } = await _getSdkAuth();
|
|
155
|
+
return startAuthorization(new URL(metadata.authorization_endpoint), {
|
|
156
|
+
metadata,
|
|
157
|
+
clientInformation: (await mgr.clientInformation()),
|
|
158
|
+
redirectUrl: mgr.redirectUrl,
|
|
159
|
+
scope: targetServer.oauth?.scope,
|
|
160
|
+
state,
|
|
161
|
+
});
|
|
194
162
|
}
|
|
195
163
|
/**
|
|
196
164
|
* Validates a requested callback port override from the `--port` flag.
|
|
@@ -230,9 +198,11 @@ function _resolveCallbackPort(override, server) {
|
|
|
230
198
|
*
|
|
231
199
|
* Validates the server exists in config and is an HTTP type.
|
|
232
200
|
* For HTTP servers, runs the OAuth authorization-code flow
|
|
233
|
-
* using the SDK's OAuth client infrastructure. The flow
|
|
234
|
-
*
|
|
235
|
-
*
|
|
201
|
+
* using the SDK's OAuth client infrastructure. The flow binds a
|
|
202
|
+
* temporary loopback callback server, registers the client (when
|
|
203
|
+
* needed) with the portless loopback callback URI, opens a browser,
|
|
204
|
+
* handles the redirect callback, exchanges the authorization code for
|
|
205
|
+
* tokens, and persists them in credentials.json.
|
|
236
206
|
*
|
|
237
207
|
* @param configPath - Absolute path to the mcp.json file.
|
|
238
208
|
* @param name - Server name to authenticate.
|
|
@@ -249,8 +219,15 @@ export async function handleLogin(configPath, name, portOverride) {
|
|
|
249
219
|
const callbackPort = _resolveCallbackPort(_validatePortOverride(portOverride), targetServer);
|
|
250
220
|
const { OAuthCredentialManager } = await import('../services/oauth.js');
|
|
251
221
|
const mgr = new OAuthCredentialManager(configPath, targetServer);
|
|
252
|
-
const metadata = await
|
|
253
|
-
const { authorizationCode, authResult } = await acquireAuthorizationCode(
|
|
222
|
+
const { metadata, requireIssuer } = await discoverOAuthMetadata(targetServer, name);
|
|
223
|
+
const { authorizationCode, authResult } = await acquireAuthorizationCode({
|
|
224
|
+
mgr,
|
|
225
|
+
callbackPort,
|
|
226
|
+
openBrowser: await _getOpenBrowser(),
|
|
227
|
+
expectedIssuer: metadata.issuer,
|
|
228
|
+
requireIssuer,
|
|
229
|
+
beginAuthorization: (state) => beginAuthorization(mgr, metadata, targetServer, name, state),
|
|
230
|
+
});
|
|
254
231
|
const { exchangeAuthorization } = await _getSdkAuth();
|
|
255
232
|
const realRedirectUrl = mgr.redirectUrl;
|
|
256
233
|
const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
|
|
@@ -33,6 +33,49 @@ function toUrl(value) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a fetch function so the raw JSON body of every successful
|
|
38
|
+
* response is captured. The SDK parses OpenID Connect discovery metadata
|
|
39
|
+
* with a schema that strips undeclared fields (such as the RFC 9207
|
|
40
|
+
* `authorization_response_iss_parameter_supported` flag), so the raw
|
|
41
|
+
* document is the only place those fields remain observable.
|
|
42
|
+
*
|
|
43
|
+
* The SDK's discovery helper returns as soon as one discovery URL yields
|
|
44
|
+
* metadata, so the last captured body belongs to the response the
|
|
45
|
+
* returned metadata was parsed from.
|
|
46
|
+
*
|
|
47
|
+
* @param base - The fetch function to wrap.
|
|
48
|
+
* @param capture - Sink for the raw JSON body.
|
|
49
|
+
* @returns A fetch function that captures successful JSON responses.
|
|
50
|
+
*/
|
|
51
|
+
function createRawMetadataFetch(base, capture) {
|
|
52
|
+
return async (input, init) => {
|
|
53
|
+
const response = await base(input, init);
|
|
54
|
+
if (response.ok) {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await response.clone().json();
|
|
57
|
+
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
|
|
58
|
+
capture.raw = raw;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A non-JSON body is a clean discovery miss, not metadata; the
|
|
63
|
+
// SDK reports it to the caller.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return response;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads the RFC 9207 `authorization_response_iss_parameter_supported`
|
|
71
|
+
* flag from the captured raw metadata document.
|
|
72
|
+
*
|
|
73
|
+
* @param capture - The captured raw metadata, when one was parsed.
|
|
74
|
+
* @returns True when the document declares the flag as `true`.
|
|
75
|
+
*/
|
|
76
|
+
function readIssParameterSupported(capture) {
|
|
77
|
+
return capture.raw?.authorization_response_iss_parameter_supported === true;
|
|
78
|
+
}
|
|
36
79
|
/**
|
|
37
80
|
* Races every candidate probe in parallel and resolves with the first
|
|
38
81
|
* candidate that finds metadata. Candidates that miss (or error) are
|
|
@@ -40,7 +83,8 @@ function toUrl(value) {
|
|
|
40
83
|
* delay a hit found by another candidate.
|
|
41
84
|
*
|
|
42
85
|
* @param urls - The candidate URLs to probe.
|
|
43
|
-
* @param probe - The per-candidate probe callback (never throws
|
|
86
|
+
* @param probe - The per-candidate probe callback (never throws; returns
|
|
87
|
+
* `undefined` on a miss).
|
|
44
88
|
* @returns The first hit, or `undefined` when every candidate missed.
|
|
45
89
|
*/
|
|
46
90
|
async function raceCandidates(urls, probe) {
|
|
@@ -48,13 +92,58 @@ async function raceCandidates(urls, probe) {
|
|
|
48
92
|
return undefined;
|
|
49
93
|
}
|
|
50
94
|
return Promise.any(urls.map(async (url) => {
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
95
|
+
const result = await probe(url);
|
|
96
|
+
if (!result) {
|
|
53
97
|
throw new Error(`No OAuth metadata at ${url.href}`);
|
|
54
98
|
}
|
|
55
|
-
return
|
|
99
|
+
return result;
|
|
56
100
|
})).catch(() => undefined);
|
|
57
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Builds the authorization-server candidate URL groups for discovery.
|
|
104
|
+
* Advertised AS URLs (from RFC 9728 Protected Resource Metadata) take
|
|
105
|
+
* precedence; the legacy fallback group — the server URL itself and, for
|
|
106
|
+
* subpath URLs, its origin root — is only probed when no advertised AS
|
|
107
|
+
* yields metadata.
|
|
108
|
+
*
|
|
109
|
+
* @param serverUrl - The downstream MCP server URL.
|
|
110
|
+
* @param resourceMetadata - Discovered Protected Resource Metadata, if any.
|
|
111
|
+
* @returns The advertised and fallback candidate URL lists.
|
|
112
|
+
*/
|
|
113
|
+
function buildCandidateUrls(serverUrl, resourceMetadata) {
|
|
114
|
+
const advertised = (resourceMetadata?.authorization_servers ?? [])
|
|
115
|
+
.map((value) => toUrl(value))
|
|
116
|
+
.filter((url) => url !== undefined);
|
|
117
|
+
const fallback = [serverUrl];
|
|
118
|
+
if (serverUrl.pathname !== '/') {
|
|
119
|
+
fallback.push(new URL(serverUrl.origin));
|
|
120
|
+
}
|
|
121
|
+
return { advertised, fallback };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Builds the discovery result from an optional candidate hit.
|
|
125
|
+
*
|
|
126
|
+
* @param resourceMetadata - Discovered Protected Resource Metadata, if any.
|
|
127
|
+
* @param hit - The winning AS candidate, or `undefined` on a full miss.
|
|
128
|
+
* @param serverUrl - The downstream MCP server URL (fallback AS URL).
|
|
129
|
+
* @returns The discovery result.
|
|
130
|
+
*/
|
|
131
|
+
function toDiscoveredAuth(resourceMetadata, hit, serverUrl) {
|
|
132
|
+
if (!hit) {
|
|
133
|
+
return {
|
|
134
|
+
resourceMetadata,
|
|
135
|
+
serverMetadata: undefined,
|
|
136
|
+
authorizationServerUrl: serverUrl,
|
|
137
|
+
authorizationResponseIssParameterSupported: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
resourceMetadata,
|
|
142
|
+
serverMetadata: hit.metadata,
|
|
143
|
+
authorizationServerUrl: hit.url,
|
|
144
|
+
authorizationResponseIssParameterSupported: hit.authorizationResponseIssParameterSupported,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
58
147
|
/**
|
|
59
148
|
* Discovers OAuth metadata for a downstream MCP server following the
|
|
60
149
|
* MCP 2025-06-18 authorization spec two-step flow:
|
|
@@ -104,9 +193,22 @@ export async function discoverAuth(serverUrl) {
|
|
|
104
193
|
let lastError;
|
|
105
194
|
// Tolerant AS discovery: any error (404-as-throw, 5xx, network) is
|
|
106
195
|
// recorded and treated as "not found" so the other candidates are tried.
|
|
196
|
+
// The raw metadata document is captured alongside the SDK's parsed
|
|
197
|
+
// result so RFC 9207 fields stripped by the OIDC schema stay visible.
|
|
107
198
|
const safeDiscoverAs = async (url) => {
|
|
199
|
+
const capture = {};
|
|
108
200
|
try {
|
|
109
|
-
|
|
201
|
+
const metadata = await discoverAuthorizationServerMetadata(url, {
|
|
202
|
+
fetchFn: createRawMetadataFetch(fetchFn, capture),
|
|
203
|
+
});
|
|
204
|
+
if (!metadata) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
url,
|
|
209
|
+
metadata,
|
|
210
|
+
authorizationResponseIssParameterSupported: readIssParameterSupported(capture),
|
|
211
|
+
};
|
|
110
212
|
}
|
|
111
213
|
catch (err) {
|
|
112
214
|
if (!isNonJsonResponse(err)) {
|
|
@@ -126,32 +228,15 @@ export async function discoverAuth(serverUrl) {
|
|
|
126
228
|
catch {
|
|
127
229
|
// No PRM published; fall through to direct AS discovery below.
|
|
128
230
|
}
|
|
129
|
-
// Step 2: probe the candidates in parallel, first hit wins.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// advertised AS yields metadata.
|
|
133
|
-
const advertisedUrls = (resourceMetadata?.authorization_servers ?? [])
|
|
134
|
-
.map((value) => toUrl(value))
|
|
135
|
-
.filter((url) => url !== undefined);
|
|
136
|
-
const fallbackUrls = [serverUrl];
|
|
137
|
-
if (serverUrl.pathname !== '/') {
|
|
138
|
-
fallbackUrls.push(new URL(serverUrl.origin));
|
|
139
|
-
}
|
|
140
|
-
const advertisedHit = await raceCandidates(advertisedUrls, safeDiscoverAs);
|
|
231
|
+
// Step 2: probe the candidates in parallel, first hit wins.
|
|
232
|
+
const { advertised, fallback } = buildCandidateUrls(serverUrl, resourceMetadata);
|
|
233
|
+
const advertisedHit = await raceCandidates(advertised, safeDiscoverAs);
|
|
141
234
|
if (advertisedHit) {
|
|
142
|
-
return
|
|
143
|
-
resourceMetadata,
|
|
144
|
-
serverMetadata: advertisedHit.metadata,
|
|
145
|
-
authorizationServerUrl: advertisedHit.url,
|
|
146
|
-
};
|
|
235
|
+
return toDiscoveredAuth(resourceMetadata, advertisedHit, serverUrl);
|
|
147
236
|
}
|
|
148
|
-
const fallbackHit = await raceCandidates(
|
|
237
|
+
const fallbackHit = await raceCandidates(fallback, safeDiscoverAs);
|
|
149
238
|
if (fallbackHit) {
|
|
150
|
-
return
|
|
151
|
-
resourceMetadata,
|
|
152
|
-
serverMetadata: fallbackHit.metadata,
|
|
153
|
-
authorizationServerUrl: fallbackHit.url,
|
|
154
|
-
};
|
|
239
|
+
return toDiscoveredAuth(resourceMetadata, fallbackHit, serverUrl);
|
|
155
240
|
}
|
|
156
241
|
// No metadata found anywhere. If any candidate actually errored (vs. a
|
|
157
242
|
// clean 404 or a non-JSON response), surface that so callers can report
|
|
@@ -159,5 +244,5 @@ export async function discoverAuth(serverUrl) {
|
|
|
159
244
|
if (lastError !== undefined) {
|
|
160
245
|
throw lastError;
|
|
161
246
|
}
|
|
162
|
-
return
|
|
247
|
+
return toDiscoveredAuth(resourceMetadata, undefined, serverUrl);
|
|
163
248
|
}
|
package/build/services/oauth.js
CHANGED
|
@@ -42,16 +42,28 @@ function needsRefresh(expiresAtIso) {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* The OAuth redirect callback path served by the temporary local HTTP
|
|
45
|
-
* server started during `login`. The
|
|
46
|
-
* `http://
|
|
47
|
-
* `<port>` is assigned by the OS. Register this path (on
|
|
48
|
-
* any port) with OAuth providers that require a pre-registered
|
|
45
|
+
* server started during `login`. The authorization-request redirect URI
|
|
46
|
+
* is `http://127.0.0.1:<port>/mcp-compress-router/oauth-callback`, where
|
|
47
|
+
* `<port>` is assigned by the OS. Register this path (on the loopback
|
|
48
|
+
* interface, any port) with OAuth providers that require a pre-registered
|
|
49
|
+
* client.
|
|
49
50
|
*
|
|
50
51
|
* @internal Exported for tests only; not part of the public module API.
|
|
51
|
-
* The constant is consumed internally by `redirectUrl
|
|
52
|
-
* it directly to avoid hardcoding
|
|
52
|
+
* The constant is consumed internally by `redirectUrl` and
|
|
53
|
+
* `OAUTH_LOOPBACK_URI`; tests import it directly to avoid hardcoding
|
|
54
|
+
* the path string.
|
|
53
55
|
*/
|
|
54
56
|
export const OAUTH_CALLBACK_PATH = '/mcp-compress-router/oauth-callback';
|
|
57
|
+
/**
|
|
58
|
+
* The loopback redirect URI registered with OAuth providers through
|
|
59
|
+
* dynamic client registration. It carries no port: RFC 8252 §8.4
|
|
60
|
+
* excludes the port from loopback redirect matching, so one registration
|
|
61
|
+
* stays valid across logins no matter which port the callback server
|
|
62
|
+
* binds. The authorization request and the token exchange use
|
|
63
|
+
* {@link OAuthCredentialManager.redirectUrl} instead, which carries the
|
|
64
|
+
* actual port.
|
|
65
|
+
*/
|
|
66
|
+
export const OAUTH_LOOPBACK_URI = `http://127.0.0.1${OAUTH_CALLBACK_PATH}`;
|
|
55
67
|
/**
|
|
56
68
|
* Implements OAuthClientProvider backed by credentials.json credential storage.
|
|
57
69
|
*
|
|
@@ -85,20 +97,43 @@ export class OAuthCredentialManager {
|
|
|
85
97
|
this._staticClientInfo = info;
|
|
86
98
|
}
|
|
87
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The redirect URI sent with the authorization request and with the
|
|
102
|
+
* token exchange. Carries the actual port assigned by the OS (0 until
|
|
103
|
+
* {@link setActualPort} is called); both requests must send the same
|
|
104
|
+
* value (RFC 6749 §4.1.3). Dynamic client registration does not use
|
|
105
|
+
* it — the registered URI is the portless {@link OAUTH_LOOPBACK_URI}.
|
|
106
|
+
*/
|
|
88
107
|
get redirectUrl() {
|
|
89
|
-
|
|
90
|
-
// Falls back to port 0 until setActualPort() is called by login-command.
|
|
91
|
-
return `http://localhost:${this._actualPort}${OAUTH_CALLBACK_PATH}`;
|
|
108
|
+
return `http://127.0.0.1:${this._actualPort}${OAUTH_CALLBACK_PATH}`;
|
|
92
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Client metadata used for dynamic client registration. The registered
|
|
112
|
+
* redirect URI is the portless loopback form
|
|
113
|
+
* ({@link OAUTH_LOOPBACK_URI}): RFC 8252 §8.4 excludes the port from
|
|
114
|
+
* loopback redirect matching, so the registration stays valid across
|
|
115
|
+
* logins regardless of the port the callback server binds.
|
|
116
|
+
*
|
|
117
|
+
* `application_type` is required for native clients by the MCP
|
|
118
|
+
* authorization specification (and by OIDC-aware registration
|
|
119
|
+
* endpoints, which otherwise default to "web"). The SDK's
|
|
120
|
+
* OAuthClientMetadata type does not declare it yet, so the return
|
|
121
|
+
* type widens explicitly; the SDK spreads this object into the
|
|
122
|
+
* registration body unchanged, so the field reaches the server.
|
|
123
|
+
*/
|
|
93
124
|
get clientMetadata() {
|
|
94
125
|
return {
|
|
95
|
-
redirect_uris: [
|
|
126
|
+
redirect_uris: [OAUTH_LOOPBACK_URI],
|
|
96
127
|
client_name: 'mcp-compress-router',
|
|
128
|
+
application_type: 'native',
|
|
97
129
|
};
|
|
98
130
|
}
|
|
99
131
|
/**
|
|
100
132
|
* Sets the actual listening port of the temporary HTTP callback server.
|
|
101
|
-
* Must be called before startAuthorization so the
|
|
133
|
+
* Must be called before startAuthorization so the authorization request
|
|
134
|
+
* (and the token exchange that follows it) carries the correct
|
|
135
|
+
* redirect_uri. Dynamic client registration does not depend on it: the
|
|
136
|
+
* registered URI is the portless {@link OAUTH_LOOPBACK_URI}.
|
|
102
137
|
*
|
|
103
138
|
* @param port - The actual port the callback server is listening on.
|
|
104
139
|
*/
|