@ferrlabs/mcp-core 6.3.2 → 6.4.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/dist/auth/__tests__/oauth.test.js +39 -1
- package/dist/auth/oauth.d.ts +22 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +50 -14
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generatePkcePair } from '../oauth.js';
|
|
2
|
+
import { generatePkcePair, __testOnly } from '../oauth.js';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
4
5
|
describe('generatePkcePair', () => {
|
|
5
6
|
it('returns a verifier between 43 and 128 chars (RFC 7636)', () => {
|
|
6
7
|
const { verifier } = generatePkcePair();
|
|
@@ -24,3 +25,40 @@ describe('generatePkcePair', () => {
|
|
|
24
25
|
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
25
26
|
});
|
|
26
27
|
});
|
|
28
|
+
function closeServer(server) {
|
|
29
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
30
|
+
}
|
|
31
|
+
async function freePort() {
|
|
32
|
+
const probe = createServer();
|
|
33
|
+
await new Promise((resolve) => probe.listen(0, '127.0.0.1', () => resolve()));
|
|
34
|
+
const addr = probe.address();
|
|
35
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
36
|
+
await closeServer(probe);
|
|
37
|
+
return port;
|
|
38
|
+
}
|
|
39
|
+
describe('bindCallbackListener (port fallback)', () => {
|
|
40
|
+
it('rejects synchronously with EADDRINUSE when the port is already bound', async () => {
|
|
41
|
+
const port = await freePort();
|
|
42
|
+
const squatter = createServer();
|
|
43
|
+
await new Promise((resolve) => squatter.listen(port, '127.0.0.1', () => resolve()));
|
|
44
|
+
try {
|
|
45
|
+
await expect(__testOnly.bindCallbackListener(port, 'st')).rejects.toThrow(/EADDRINUSE/);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await closeServer(squatter);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
it('resolves with the bound port when the socket is free', async () => {
|
|
52
|
+
const port = await freePort();
|
|
53
|
+
const bound = await __testOnly.bindCallbackListener(port, 'st');
|
|
54
|
+
expect(bound.port).toBe(port);
|
|
55
|
+
// Attach a catch handler *before* triggering the rejection so the
|
|
56
|
+
// unhandled-rejection guard in vitest stays quiet.
|
|
57
|
+
const callbackSettled = bound.callback.catch((err) => err);
|
|
58
|
+
const res = await fetch(`http://127.0.0.1:${port}/cb?code=x&state=wrong`);
|
|
59
|
+
expect(res.status).toBe(400);
|
|
60
|
+
const settled = await callbackSettled;
|
|
61
|
+
expect(settled).toBeInstanceOf(Error);
|
|
62
|
+
expect(settled.message).toMatch(/state mismatch/);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/dist/auth/oauth.d.ts
CHANGED
|
@@ -3,10 +3,32 @@ interface PkcePair {
|
|
|
3
3
|
challenge: string;
|
|
4
4
|
}
|
|
5
5
|
export declare function generatePkcePair(): PkcePair;
|
|
6
|
+
interface CallbackResult {
|
|
7
|
+
code: string;
|
|
8
|
+
state: string;
|
|
9
|
+
}
|
|
10
|
+
interface BoundListener {
|
|
11
|
+
port: number;
|
|
12
|
+
callback: Promise<CallbackResult>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Bind a loopback HTTP listener on `port` and return a resolved promise
|
|
16
|
+
* only once the socket is actually `listening`. Rejects with the bind
|
|
17
|
+
* error (e.g. `EADDRINUSE`) before returning, so callers can synchronously
|
|
18
|
+
* fall back to a different port via `await` + `try/catch`.
|
|
19
|
+
*
|
|
20
|
+
* The returned `callback` promise lives for the duration of the OAuth
|
|
21
|
+
* flow and resolves when `/cb` is hit with a matching `state`, or rejects
|
|
22
|
+
* on CSRF mismatch / internal error.
|
|
23
|
+
*/
|
|
24
|
+
declare function bindCallbackListener(port: number, expectedState: string): Promise<BoundListener>;
|
|
6
25
|
export interface RunLoopbackFlowResult {
|
|
7
26
|
token: string;
|
|
8
27
|
scope?: string;
|
|
9
28
|
}
|
|
29
|
+
export declare const __testOnly: {
|
|
30
|
+
bindCallbackListener: typeof bindCallbackListener;
|
|
31
|
+
};
|
|
10
32
|
export declare function runLoopbackOauthFlow(): Promise<RunLoopbackFlowResult>;
|
|
11
33
|
export {};
|
|
12
34
|
//# sourceMappingURL=oauth.d.ts.map
|
package/dist/auth/oauth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAWA,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,IAAI,QAAQ,CAI3C;
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAWA,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,IAAI,QAAQ,CAI3C;AAwBD,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;CACnC;AAED;;;;;;;;;GASG;AACH,iBAAS,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAwDzF;AA4BD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,UAAU;;CAEtB,CAAC;AAEF,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAoD3E"}
|
package/dist/auth/oauth.js
CHANGED
|
@@ -32,8 +32,24 @@ function openBrowser(url) {
|
|
|
32
32
|
});
|
|
33
33
|
child.unref();
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Bind a loopback HTTP listener on `port` and return a resolved promise
|
|
37
|
+
* only once the socket is actually `listening`. Rejects with the bind
|
|
38
|
+
* error (e.g. `EADDRINUSE`) before returning, so callers can synchronously
|
|
39
|
+
* fall back to a different port via `await` + `try/catch`.
|
|
40
|
+
*
|
|
41
|
+
* The returned `callback` promise lives for the duration of the OAuth
|
|
42
|
+
* flow and resolves when `/cb` is hit with a matching `state`, or rejects
|
|
43
|
+
* on CSRF mismatch / internal error.
|
|
44
|
+
*/
|
|
45
|
+
function bindCallbackListener(port, expectedState) {
|
|
46
|
+
return new Promise((resolveBound, rejectBound) => {
|
|
47
|
+
let resolveCallback;
|
|
48
|
+
let rejectCallback;
|
|
49
|
+
const callback = new Promise((res, rej) => {
|
|
50
|
+
resolveCallback = res;
|
|
51
|
+
rejectCallback = rej;
|
|
52
|
+
});
|
|
37
53
|
const server = createServer((req, res) => {
|
|
38
54
|
try {
|
|
39
55
|
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
@@ -49,21 +65,33 @@ function listenForCallback(port, expectedState) {
|
|
|
49
65
|
}
|
|
50
66
|
if (state !== expectedState) {
|
|
51
67
|
res.writeHead(400).end('state mismatch');
|
|
52
|
-
|
|
68
|
+
rejectCallback(new Error('OAuth callback state mismatch (CSRF protection triggered)'));
|
|
53
69
|
server.close();
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
56
72
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
|
|
57
73
|
res.end("<!doctype html><html><body style='font-family:sans-serif;padding:2rem;color:#1e293b'><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
58
74
|
setImmediate(() => server.close());
|
|
59
|
-
|
|
75
|
+
resolveCallback({ code, state });
|
|
60
76
|
}
|
|
61
77
|
catch (err) {
|
|
62
78
|
res.writeHead(500).end('Internal error');
|
|
63
|
-
|
|
79
|
+
rejectCallback(err instanceof Error ? err : new Error(String(err)));
|
|
64
80
|
}
|
|
65
81
|
});
|
|
66
|
-
|
|
82
|
+
const onBindError = (err) => {
|
|
83
|
+
server.removeListener('listening', onListening);
|
|
84
|
+
rejectBound(err);
|
|
85
|
+
};
|
|
86
|
+
const onListening = () => {
|
|
87
|
+
server.removeListener('error', onBindError);
|
|
88
|
+
// Once bound successfully, route runtime errors into the callback
|
|
89
|
+
// promise (e.g. unexpected close after we've started serving).
|
|
90
|
+
server.on('error', (err) => rejectCallback(err));
|
|
91
|
+
resolveBound({ port, callback });
|
|
92
|
+
};
|
|
93
|
+
server.once('error', onBindError);
|
|
94
|
+
server.once('listening', onListening);
|
|
67
95
|
server.listen(port, '127.0.0.1');
|
|
68
96
|
});
|
|
69
97
|
}
|
|
@@ -87,24 +115,32 @@ async function exchangeCodeForToken(code, codeVerifier, redirectUri) {
|
|
|
87
115
|
throw new Error('OAuth exchange returned no token');
|
|
88
116
|
return data.token;
|
|
89
117
|
}
|
|
118
|
+
export const __testOnly = {
|
|
119
|
+
bindCallbackListener,
|
|
120
|
+
};
|
|
90
121
|
export async function runLoopbackOauthFlow() {
|
|
91
122
|
const pkce = generatePkcePair();
|
|
92
123
|
const state = generateState();
|
|
93
|
-
let
|
|
94
|
-
|
|
124
|
+
let bound = null;
|
|
125
|
+
const bindErrors = [];
|
|
95
126
|
for (const candidate of REDIRECT_PORTS) {
|
|
96
127
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
// eslint-disable-next-line no-await-in-loop -- intentional: probe ports sequentially
|
|
129
|
+
bound = await bindCallbackListener(candidate, state);
|
|
99
130
|
break;
|
|
100
131
|
}
|
|
101
|
-
catch {
|
|
102
|
-
|
|
132
|
+
catch (err) {
|
|
133
|
+
bindErrors.push({
|
|
134
|
+
port: candidate,
|
|
135
|
+
error: err instanceof Error ? err.message : String(err),
|
|
136
|
+
});
|
|
103
137
|
}
|
|
104
138
|
}
|
|
105
|
-
if (
|
|
106
|
-
|
|
139
|
+
if (!bound) {
|
|
140
|
+
const detail = bindErrors.map((e) => `${e.port}: ${e.error}`).join('; ');
|
|
141
|
+
throw new Error(`Could not bind any of the loopback ports (${REDIRECT_PORTS.join(', ')}). ${detail}. Close whatever is holding the ports (commonly an MCP subprocess from another Claude session stuck in OAuth), then retry.`);
|
|
107
142
|
}
|
|
143
|
+
const { port, callback: callbackPromise } = bound;
|
|
108
144
|
const redirectUri = `http://127.0.0.1:${port}/cb`;
|
|
109
145
|
const authorizeUrl = new URL(`${AUTH_BASE}/authorize`);
|
|
110
146
|
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
package/package.json
CHANGED