@thuyphuocthinh/mcp-auth-proxy 1.0.0 β 1.0.1
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/README.md +22 -7
- package/dist/src/index.d.ts +12 -1
- package/dist/src/index.js +115 -67
- package/dist/src/proxy.js +2 -1
- package/dist/src/store/token-store.d.ts +3 -2
- package/dist/src/store/token-store.js +13 -4
- package/dist/tests/cli.test.js +15 -0
- package/dist/tests/smoke.test.js +2 -2
- package/dist/tests/store/token-store.test.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @thuyphuocthinh/mcp-auth-proxy π
|
|
2
2
|
|
|
3
|
-
> **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & silent token refresh.**
|
|
3
|
+
> **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & non-intrusive silent token refresh.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/thuyphuocthinh/mcp-auth-proxy/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/@thuyphuocthinh/mcp-auth-proxy)
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
- π **Zero Runtime Dependencies**: Pure Node.js (`node:http`, `node:crypto`, `node:net`), lightweight, launches immediately via `npx`.
|
|
18
18
|
- π **Automated OAuth Discovery**: Intercepts `401 Unauthorized`, parses `WWW-Authenticate: Bearer resource_metadata="..."` and resolves `.well-known` endpoints automatically.
|
|
19
19
|
- π **Dynamic Client Registration (RFC 7591) & PKCE (RFC 7636)**: Automatically registers client IDs on the fly with S256 challenge, eliminating the need for hardcoded client secrets.
|
|
20
|
-
-
|
|
21
|
-
- π **Silent Background Refresh**:
|
|
20
|
+
- π§ **Non-Intrusive Stdio Mode**: Never interrupts your workflow by popping up a browser unexpectedly when running as a background proxy for AI IDEs.
|
|
21
|
+
- π **Bound Silent Background Refresh**: Persists the registered `clientId` alongside your tokens and silently refreshes via `grant_type=refresh_token`, preventing `invalid_grant` client-mismatch errors.
|
|
22
|
+
- πͺ **Explicit `login` Command**: Authenticate upfront whenever you want using `mcp-auth-proxy login <url>`.
|
|
22
23
|
- π‘οΈ **Static Header Bypass**: Support for `--header` flag to pass static API keys or personal access tokens directly.
|
|
23
24
|
- π¦ **Dynamic Port Selection**: Automatically scans for free local ports starting from `8080` (8081, 8082...), eliminating `EADDRINUSE` port collision errors.
|
|
24
25
|
|
|
@@ -45,22 +46,36 @@ Add this entry to your `mcp_config.json` or `claude_desktop_config.json`:
|
|
|
45
46
|
}
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
### 2. Explicit Login (Recommended Before First Use)
|
|
50
|
+
|
|
51
|
+
Authenticate in advance so background sessions run smoothly:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @thuyphuocthinh/mcp-auth-proxy login https://mcp.200lab.io/mcp/learner
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This opens your browser, receives the authorization code, securely stores the credentials in `~/.mcp-oauth-proxy/tokens.json`, and exits cleanly. All subsequent background proxy launches will run silently forever with automatic token refresh!
|
|
49
58
|
|
|
50
59
|
---
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
## π οΈ CLI Reference
|
|
53
62
|
|
|
54
63
|
```bash
|
|
64
|
+
# Run as Stdio proxy
|
|
55
65
|
npx @thuyphuocthinh/mcp-auth-proxy <remote-url> [options]
|
|
66
|
+
|
|
67
|
+
# Explicit login command
|
|
68
|
+
npx @thuyphuocthinh/mcp-auth-proxy login <remote-url> [options]
|
|
56
69
|
```
|
|
57
70
|
|
|
58
71
|
| Option | Description |
|
|
59
72
|
| :--- | :--- |
|
|
73
|
+
| `login <remote-url>` | Perform interactive OAuth authentication, save credentials, and exit |
|
|
60
74
|
| `--client-id <id>` | Pre-configured Client ID (for servers without RFC 7591 dynamic registration) |
|
|
61
75
|
| `--header "Key: Value"` | Custom request header (e.g. bypass OAuth with static API key) |
|
|
76
|
+
| `--auto-browser` | Allow proxy to automatically launch browser when authentication is required |
|
|
62
77
|
| `--no-browser` | Disable automatic browser launch; prints login URL to stderr (for headless/VPS environments) |
|
|
63
|
-
| `--login-only` |
|
|
78
|
+
| `--login-only` | Alias for the `login` command |
|
|
64
79
|
| `-h, --help` | Display help instructions |
|
|
65
80
|
| `-v, --version` | Display version number |
|
|
66
81
|
|
|
@@ -73,7 +88,7 @@ npm run build
|
|
|
73
88
|
npm test
|
|
74
89
|
```
|
|
75
90
|
|
|
76
|
-
|
|
91
|
+
54 unit tests covering all core modules with 100% pass rate.
|
|
77
92
|
|
|
78
93
|
---
|
|
79
94
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
import { TokenStore } from './store/token-store.js';
|
|
2
|
+
export declare const VERSION = "1.0.1";
|
|
2
3
|
export interface CliOptions {
|
|
4
|
+
command: 'proxy' | 'login';
|
|
3
5
|
remoteUrl?: string;
|
|
4
6
|
clientId?: string;
|
|
5
7
|
headers: Record<string, string>;
|
|
6
8
|
noBrowser: boolean;
|
|
9
|
+
autoBrowser: boolean;
|
|
7
10
|
loginOnly: boolean;
|
|
8
11
|
help: boolean;
|
|
9
12
|
version: boolean;
|
|
10
13
|
}
|
|
11
14
|
export declare function parseArgs(args: string[]): CliOptions;
|
|
12
15
|
export declare function printHelp(): void;
|
|
16
|
+
export declare function performLogin(options: {
|
|
17
|
+
remoteUrl: string;
|
|
18
|
+
clientId?: string;
|
|
19
|
+
noBrowser?: boolean;
|
|
20
|
+
store: TokenStore;
|
|
21
|
+
fetchFn?: typeof fetch;
|
|
22
|
+
log?: (msg: string) => void;
|
|
23
|
+
}): Promise<string>;
|
|
13
24
|
export declare function main(args?: string[]): Promise<void>;
|
package/dist/src/index.js
CHANGED
|
@@ -5,15 +5,18 @@ import { openBrowser } from './oauth/browser.js';
|
|
|
5
5
|
import { startCallbackServer } from './oauth/server.js';
|
|
6
6
|
import { TokenStore } from './store/token-store.js';
|
|
7
7
|
import { startProxy } from './proxy.js';
|
|
8
|
-
export const VERSION = '1.0.
|
|
8
|
+
export const VERSION = '1.0.1';
|
|
9
9
|
export function parseArgs(args) {
|
|
10
10
|
const options = {
|
|
11
|
+
command: 'proxy',
|
|
11
12
|
headers: {},
|
|
12
13
|
noBrowser: false,
|
|
14
|
+
autoBrowser: false,
|
|
13
15
|
loginOnly: false,
|
|
14
16
|
help: false,
|
|
15
17
|
version: false
|
|
16
18
|
};
|
|
19
|
+
const positional = [];
|
|
17
20
|
for (let i = 0; i < args.length; i++) {
|
|
18
21
|
const arg = args[i];
|
|
19
22
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -25,8 +28,12 @@ export function parseArgs(args) {
|
|
|
25
28
|
else if (arg === '--no-browser') {
|
|
26
29
|
options.noBrowser = true;
|
|
27
30
|
}
|
|
31
|
+
else if (arg === '--auto-browser') {
|
|
32
|
+
options.autoBrowser = true;
|
|
33
|
+
}
|
|
28
34
|
else if (arg === '--login-only') {
|
|
29
35
|
options.loginOnly = true;
|
|
36
|
+
options.command = 'login';
|
|
30
37
|
}
|
|
31
38
|
else if (arg === '--client-id' && i + 1 < args.length) {
|
|
32
39
|
options.clientId = args[++i];
|
|
@@ -40,8 +47,19 @@ export function parseArgs(args) {
|
|
|
40
47
|
options.headers[key] = value;
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
|
-
else if (!arg.startsWith('-')
|
|
44
|
-
|
|
50
|
+
else if (!arg.startsWith('-')) {
|
|
51
|
+
positional.push(arg);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (positional.length > 0) {
|
|
55
|
+
if (positional[0] === 'login') {
|
|
56
|
+
options.command = 'login';
|
|
57
|
+
if (positional.length > 1) {
|
|
58
|
+
options.remoteUrl = positional[1];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
options.remoteUrl = positional[0];
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
return options;
|
|
@@ -55,20 +73,85 @@ Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE
|
|
|
55
73
|
|
|
56
74
|
Usage:
|
|
57
75
|
mcp-oauth-proxy <remote-url> [options]
|
|
76
|
+
mcp-oauth-proxy login <remote-url> [options]
|
|
77
|
+
|
|
78
|
+
Commands:
|
|
79
|
+
login <remote-url> Perform interactive OAuth authentication, save credentials, and exit
|
|
58
80
|
|
|
59
81
|
Options:
|
|
60
82
|
--client-id <id> Pre-configured Client ID (for servers without RFC 7591 dynamic registration)
|
|
61
83
|
--header "K: V" Custom request header (e.g. bypass OAuth using static API key)
|
|
84
|
+
--auto-browser Automatically launch browser when re-authentication is required in proxy mode
|
|
62
85
|
--no-browser Do not automatically open default browser; print authorization URL to stderr
|
|
63
|
-
--login-only
|
|
86
|
+
--login-only Alias for the login command
|
|
64
87
|
-h, --help Display this help message
|
|
65
88
|
-v, --version Display version number
|
|
66
89
|
|
|
67
90
|
Examples:
|
|
91
|
+
mcp-oauth-proxy login https://mcp.200lab.io/mcp/learner
|
|
68
92
|
mcp-oauth-proxy https://mcp.200lab.io/mcp/learner
|
|
69
93
|
mcp-oauth-proxy https://api.example.com/mcp --header "Authorization: Bearer my_api_key"
|
|
70
94
|
`);
|
|
71
95
|
}
|
|
96
|
+
export async function performLogin(options) {
|
|
97
|
+
const { remoteUrl, clientId, noBrowser = false, store, fetchFn = fetch, log = console.error } = options;
|
|
98
|
+
log(`[*] Discovering OAuth metadata for: ${remoteUrl}`);
|
|
99
|
+
const metadata = await discoverOAuth(remoteUrl, fetchFn);
|
|
100
|
+
log(`[*] Starting local callback server...`);
|
|
101
|
+
const callbackServer = await startCallbackServer();
|
|
102
|
+
const pkce = generatePKCE();
|
|
103
|
+
const effectiveClientId = await resolveClientId({
|
|
104
|
+
registrationEndpoint: metadata.registrationEndpoint,
|
|
105
|
+
redirectUri: callbackServer.redirectUri,
|
|
106
|
+
explicitClientId: clientId,
|
|
107
|
+
fetchFn,
|
|
108
|
+
log
|
|
109
|
+
});
|
|
110
|
+
const authUrl = new URL(metadata.authorizationEndpoint);
|
|
111
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
112
|
+
authUrl.searchParams.set('client_id', effectiveClientId);
|
|
113
|
+
authUrl.searchParams.set('redirect_uri', callbackServer.redirectUri);
|
|
114
|
+
authUrl.searchParams.set('scope', metadata.scopes.join(' '));
|
|
115
|
+
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
116
|
+
authUrl.searchParams.set('code_challenge_method', pkce.method);
|
|
117
|
+
authUrl.searchParams.set('resource', metadata.resource);
|
|
118
|
+
if (!noBrowser) {
|
|
119
|
+
await openBrowser(authUrl.toString(), log);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
log(`[*] Open this URL in your browser to complete authorization:\n${authUrl.toString()}\n`);
|
|
123
|
+
}
|
|
124
|
+
const code = await callbackServer.waitForCode();
|
|
125
|
+
log(`[*] Authorization code received. Exchanging for token...`);
|
|
126
|
+
const tokenRes = await fetchFn(metadata.tokenEndpoint, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
129
|
+
body: new URLSearchParams({
|
|
130
|
+
grant_type: 'authorization_code',
|
|
131
|
+
client_id: effectiveClientId,
|
|
132
|
+
code,
|
|
133
|
+
redirect_uri: callbackServer.redirectUri,
|
|
134
|
+
code_verifier: pkce.verifier
|
|
135
|
+
})
|
|
136
|
+
});
|
|
137
|
+
if (!tokenRes.ok) {
|
|
138
|
+
const errText = await tokenRes.text();
|
|
139
|
+
await callbackServer.close();
|
|
140
|
+
throw new Error(`Token exchange failed (HTTP ${tokenRes.status}): ${errText}`);
|
|
141
|
+
}
|
|
142
|
+
const tokenData = (await tokenRes.json());
|
|
143
|
+
store.saveToken(metadata.resource, {
|
|
144
|
+
accessToken: tokenData.access_token,
|
|
145
|
+
refreshToken: tokenData.refresh_token,
|
|
146
|
+
expiresAt: Date.now() + tokenData.expires_in * 1000,
|
|
147
|
+
tokenType: tokenData.token_type,
|
|
148
|
+
scope: tokenData.scope,
|
|
149
|
+
clientId: effectiveClientId
|
|
150
|
+
});
|
|
151
|
+
log(`[+] Authentication successful! Credentials securely stored.`);
|
|
152
|
+
await callbackServer.close();
|
|
153
|
+
return tokenData.access_token;
|
|
154
|
+
}
|
|
72
155
|
export async function main(args = process.argv.slice(2)) {
|
|
73
156
|
const options = parseArgs(args);
|
|
74
157
|
if (options.help) {
|
|
@@ -85,6 +168,16 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
85
168
|
}
|
|
86
169
|
const remoteUrl = options.remoteUrl;
|
|
87
170
|
const store = new TokenStore();
|
|
171
|
+
if (options.command === 'login') {
|
|
172
|
+
await performLogin({
|
|
173
|
+
remoteUrl,
|
|
174
|
+
clientId: options.clientId,
|
|
175
|
+
noBrowser: options.noBrowser,
|
|
176
|
+
store
|
|
177
|
+
});
|
|
178
|
+
console.error(`[+] Authentication complete.`);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
88
181
|
const getToken = async () => {
|
|
89
182
|
// If user provided a manual Authorization header via --header
|
|
90
183
|
if (options.headers['Authorization']) {
|
|
@@ -92,80 +185,35 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
92
185
|
}
|
|
93
186
|
// 1. Discover OAuth Metadata from remote server
|
|
94
187
|
const metadata = await discoverOAuth(remoteUrl);
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
redirectUri: 'http://localhost:8080/callback',
|
|
98
|
-
explicitClientId: options.clientId,
|
|
99
|
-
log: console.error
|
|
100
|
-
});
|
|
188
|
+
const storedToken = store.getToken(metadata.resource);
|
|
189
|
+
const existingClientId = storedToken?.clientId || options.clientId;
|
|
101
190
|
// 2. Try to get existing token or execute silent background refresh
|
|
102
191
|
const existingToken = await store.getOrRefreshToken({
|
|
103
192
|
resource: metadata.resource,
|
|
104
193
|
tokenEndpoint: metadata.tokenEndpoint,
|
|
105
|
-
clientId,
|
|
194
|
+
clientId: existingClientId,
|
|
106
195
|
log: console.error
|
|
107
196
|
});
|
|
108
197
|
if (existingToken) {
|
|
109
198
|
return existingToken;
|
|
110
199
|
}
|
|
111
|
-
// 3. No valid token
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
explicitClientId: options.clientId,
|
|
120
|
-
log: console.error
|
|
121
|
-
});
|
|
122
|
-
const authUrl = new URL(metadata.authorizationEndpoint);
|
|
123
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
124
|
-
authUrl.searchParams.set('client_id', effectiveClientId);
|
|
125
|
-
authUrl.searchParams.set('redirect_uri', callbackServer.redirectUri);
|
|
126
|
-
authUrl.searchParams.set('scope', metadata.scopes.join(' '));
|
|
127
|
-
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
128
|
-
authUrl.searchParams.set('code_challenge_method', pkce.method);
|
|
129
|
-
authUrl.searchParams.set('resource', metadata.resource);
|
|
130
|
-
if (!options.noBrowser) {
|
|
131
|
-
await openBrowser(authUrl.toString(), console.error);
|
|
200
|
+
// 3. No valid token found or refresh failed:
|
|
201
|
+
if (options.autoBrowser) {
|
|
202
|
+
return await performLogin({
|
|
203
|
+
remoteUrl,
|
|
204
|
+
clientId: options.clientId,
|
|
205
|
+
noBrowser: options.noBrowser,
|
|
206
|
+
store
|
|
207
|
+
});
|
|
132
208
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
141
|
-
body: new URLSearchParams({
|
|
142
|
-
grant_type: 'authorization_code',
|
|
143
|
-
client_id: effectiveClientId,
|
|
144
|
-
code,
|
|
145
|
-
redirect_uri: callbackServer.redirectUri,
|
|
146
|
-
code_verifier: pkce.verifier
|
|
147
|
-
})
|
|
148
|
-
});
|
|
149
|
-
if (!tokenRes.ok) {
|
|
150
|
-
const errText = await tokenRes.text();
|
|
151
|
-
await callbackServer.close();
|
|
152
|
-
throw new Error(`Token exchange failed (HTTP ${tokenRes.status}): ${errText}`);
|
|
153
|
-
}
|
|
154
|
-
const tokenData = (await tokenRes.json());
|
|
155
|
-
store.saveToken(metadata.resource, {
|
|
156
|
-
accessToken: tokenData.access_token,
|
|
157
|
-
refreshToken: tokenData.refresh_token,
|
|
158
|
-
expiresAt: Date.now() + tokenData.expires_in * 1000
|
|
159
|
-
});
|
|
160
|
-
console.error(`[+] Authentication successful! Credentials securely stored.`);
|
|
161
|
-
await callbackServer.close();
|
|
162
|
-
return tokenData.access_token;
|
|
209
|
+
// Default Non-Intrusive mode: do not launch browser unsolicitedly
|
|
210
|
+
const loginCommand = `npx @thuyphuocthinh/mcp-auth-proxy login ${remoteUrl}`;
|
|
211
|
+
const message = `Authentication required for ${remoteUrl}. Please run: ${loginCommand}`;
|
|
212
|
+
console.error(`[-] ${message}`);
|
|
213
|
+
const authError = new Error(message);
|
|
214
|
+
authError.code = -32000;
|
|
215
|
+
throw authError;
|
|
163
216
|
};
|
|
164
|
-
if (options.loginOnly) {
|
|
165
|
-
await getToken();
|
|
166
|
-
console.error(`[+] Pre-authentication complete (--login-only).`);
|
|
167
|
-
process.exit(0);
|
|
168
|
-
}
|
|
169
217
|
// Launch Stdio runtime proxy loop
|
|
170
218
|
await startProxy({
|
|
171
219
|
remoteUrl,
|
package/dist/src/proxy.js
CHANGED
|
@@ -116,10 +116,11 @@ export async function startProxy(options) {
|
|
|
116
116
|
requestId = parsed.id ?? null;
|
|
117
117
|
}
|
|
118
118
|
catch { }
|
|
119
|
+
const errorCode = err && typeof err.code === 'number' ? err.code : -32603;
|
|
119
120
|
output.write(JSON.stringify({
|
|
120
121
|
jsonrpc: '2.0',
|
|
121
122
|
id: requestId,
|
|
122
|
-
error: { code:
|
|
123
|
+
error: { code: errorCode, message: err.message }
|
|
123
124
|
}) + '\n');
|
|
124
125
|
}
|
|
125
126
|
}
|
|
@@ -4,6 +4,7 @@ export interface StoredToken {
|
|
|
4
4
|
expiresAt: number;
|
|
5
5
|
tokenType?: string;
|
|
6
6
|
scope?: string;
|
|
7
|
+
clientId?: string;
|
|
7
8
|
}
|
|
8
9
|
export declare class TokenStore {
|
|
9
10
|
private storePath;
|
|
@@ -14,14 +15,14 @@ export declare class TokenStore {
|
|
|
14
15
|
refreshToken(options: {
|
|
15
16
|
resource: string;
|
|
16
17
|
tokenEndpoint: string;
|
|
17
|
-
clientId
|
|
18
|
+
clientId?: string;
|
|
18
19
|
fetchFn?: typeof fetch;
|
|
19
20
|
log?: (msg: string) => void;
|
|
20
21
|
}): Promise<string | null>;
|
|
21
22
|
getOrRefreshToken(options: {
|
|
22
23
|
resource: string;
|
|
23
24
|
tokenEndpoint: string;
|
|
24
|
-
clientId
|
|
25
|
+
clientId?: string;
|
|
25
26
|
fetchFn?: typeof fetch;
|
|
26
27
|
log?: (msg: string) => void;
|
|
27
28
|
}): Promise<string | null>;
|
|
@@ -22,12 +22,17 @@ export class TokenStore {
|
|
|
22
22
|
return Date.now() >= token.expiresAt - bufferMs;
|
|
23
23
|
}
|
|
24
24
|
async refreshToken(options) {
|
|
25
|
-
const { resource, tokenEndpoint,
|
|
25
|
+
const { resource, tokenEndpoint, fetchFn = fetch, log = console.log } = options;
|
|
26
26
|
const current = this.getToken(resource);
|
|
27
27
|
if (!current?.refreshToken) {
|
|
28
28
|
log(`[-] No refresh_token available for resource: ${resource}`);
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
|
+
const effectiveClientId = current.clientId || options.clientId;
|
|
32
|
+
if (!effectiveClientId) {
|
|
33
|
+
log(`[-] No client_id available for silent token refresh for resource: ${resource}`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
31
36
|
try {
|
|
32
37
|
log(`[*] Performing silent background token refresh...`);
|
|
33
38
|
const response = await fetchFn(tokenEndpoint, {
|
|
@@ -35,7 +40,7 @@ export class TokenStore {
|
|
|
35
40
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
36
41
|
body: new URLSearchParams({
|
|
37
42
|
grant_type: 'refresh_token',
|
|
38
|
-
client_id:
|
|
43
|
+
client_id: effectiveClientId,
|
|
39
44
|
refresh_token: current.refreshToken
|
|
40
45
|
})
|
|
41
46
|
});
|
|
@@ -50,7 +55,8 @@ export class TokenStore {
|
|
|
50
55
|
refreshToken: data.refresh_token || current.refreshToken,
|
|
51
56
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
52
57
|
tokenType: data.token_type || current.tokenType,
|
|
53
|
-
scope: data.scope || current.scope
|
|
58
|
+
scope: data.scope || current.scope,
|
|
59
|
+
clientId: effectiveClientId
|
|
54
60
|
};
|
|
55
61
|
this.saveToken(resource, updatedToken);
|
|
56
62
|
log(`[+] Token refreshed successfully! Valid for ${data.expires_in}s.`);
|
|
@@ -73,7 +79,10 @@ export class TokenStore {
|
|
|
73
79
|
}
|
|
74
80
|
// Expiring soon: silently refresh if refresh_token exists
|
|
75
81
|
if (current.refreshToken) {
|
|
76
|
-
const refreshed = await this.refreshToken(
|
|
82
|
+
const refreshed = await this.refreshToken({
|
|
83
|
+
...options,
|
|
84
|
+
clientId: current.clientId || options.clientId
|
|
85
|
+
});
|
|
77
86
|
if (refreshed) {
|
|
78
87
|
return refreshed;
|
|
79
88
|
}
|
package/dist/tests/cli.test.js
CHANGED
|
@@ -47,4 +47,19 @@ describe('CLI Argument Parser', () => {
|
|
|
47
47
|
assert.strictEqual(options.clientId, 'test_id');
|
|
48
48
|
assert.strictEqual(options.noBrowser, true);
|
|
49
49
|
});
|
|
50
|
+
it('should parse login subcommand and set command to login', () => {
|
|
51
|
+
const options = parseArgs(['login', 'https://mcp.200lab.io/mcp/learner']);
|
|
52
|
+
assert.strictEqual(options.command, 'login');
|
|
53
|
+
assert.strictEqual(options.remoteUrl, 'https://mcp.200lab.io/mcp/learner');
|
|
54
|
+
});
|
|
55
|
+
it('should parse --auto-browser flag', () => {
|
|
56
|
+
const options = parseArgs(['https://mcp.example.com', '--auto-browser']);
|
|
57
|
+
assert.strictEqual(options.command, 'proxy');
|
|
58
|
+
assert.strictEqual(options.autoBrowser, true);
|
|
59
|
+
});
|
|
60
|
+
it('should treat --login-only as command login', () => {
|
|
61
|
+
const options = parseArgs(['https://mcp.example.com', '--login-only']);
|
|
62
|
+
assert.strictEqual(options.command, 'login');
|
|
63
|
+
assert.strictEqual(options.loginOnly, true);
|
|
64
|
+
});
|
|
50
65
|
});
|
package/dist/tests/smoke.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import { VERSION } from '../src/index.js';
|
|
4
4
|
describe('Boilerplate Smoke Test', () => {
|
|
5
|
-
it('
|
|
6
|
-
assert.strictEqual(VERSION, '1.0.
|
|
5
|
+
it('should export correct version', () => {
|
|
6
|
+
assert.strictEqual(VERSION, '1.0.1');
|
|
7
7
|
});
|
|
8
8
|
});
|
|
@@ -157,4 +157,62 @@ describe('TokenStore & Silent Refresh Logic', () => {
|
|
|
157
157
|
});
|
|
158
158
|
assert.strictEqual(token, 'still_working_for_2_minutes', 'Should fallback to remaining validity');
|
|
159
159
|
});
|
|
160
|
+
it('refreshToken should prioritize persisted clientId over options.clientId and preserve it in storage', async () => {
|
|
161
|
+
const resource = 'https://mcp.bound-client.com';
|
|
162
|
+
const tokenEndpoint = 'https://api.bound-client.com/oauth/token';
|
|
163
|
+
store.saveToken(resource, {
|
|
164
|
+
accessToken: 'old_token',
|
|
165
|
+
refreshToken: 'bound_refresh_token',
|
|
166
|
+
expiresAt: Date.now() + 60_000,
|
|
167
|
+
clientId: 'original_persisted_client_id'
|
|
168
|
+
});
|
|
169
|
+
let sentClientId = null;
|
|
170
|
+
const mockFetch = async (_input, init) => {
|
|
171
|
+
const params = new URLSearchParams(init?.body);
|
|
172
|
+
sentClientId = params.get('client_id');
|
|
173
|
+
return new Response(JSON.stringify({
|
|
174
|
+
access_token: 'new_token_refreshed',
|
|
175
|
+
expires_in: 3600
|
|
176
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
177
|
+
};
|
|
178
|
+
// Even if options passes a different clientId, stored clientId MUST take precedence!
|
|
179
|
+
const result = await store.refreshToken({
|
|
180
|
+
resource,
|
|
181
|
+
tokenEndpoint,
|
|
182
|
+
clientId: 'different_new_client_id',
|
|
183
|
+
fetchFn: mockFetch,
|
|
184
|
+
log: () => { }
|
|
185
|
+
});
|
|
186
|
+
assert.strictEqual(result, 'new_token_refreshed');
|
|
187
|
+
assert.strictEqual(sentClientId, 'original_persisted_client_id', 'Must use persisted clientId tied to refresh token');
|
|
188
|
+
const updated = store.getToken(resource);
|
|
189
|
+
assert.strictEqual(updated?.clientId, 'original_persisted_client_id', 'Must preserve persisted clientId after refresh');
|
|
190
|
+
});
|
|
191
|
+
it('getOrRefreshToken should use stored clientId during silent refresh when clientId option is omitted', async () => {
|
|
192
|
+
const resource = 'https://mcp.silent.com';
|
|
193
|
+
const tokenEndpoint = 'https://api.silent.com/oauth/token';
|
|
194
|
+
store.saveToken(resource, {
|
|
195
|
+
accessToken: 'expiring_token',
|
|
196
|
+
refreshToken: 'my_refresh_token',
|
|
197
|
+
expiresAt: Date.now() + 60_000, // < 5m buffer
|
|
198
|
+
clientId: 'stored_pkce_client_id'
|
|
199
|
+
});
|
|
200
|
+
let sentClientId = null;
|
|
201
|
+
const mockFetch = async (_input, init) => {
|
|
202
|
+
const params = new URLSearchParams(init?.body);
|
|
203
|
+
sentClientId = params.get('client_id');
|
|
204
|
+
return new Response(JSON.stringify({
|
|
205
|
+
access_token: 'refreshed_access_token',
|
|
206
|
+
expires_in: 7200
|
|
207
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
208
|
+
};
|
|
209
|
+
const token = await store.getOrRefreshToken({
|
|
210
|
+
resource,
|
|
211
|
+
tokenEndpoint,
|
|
212
|
+
fetchFn: mockFetch,
|
|
213
|
+
log: () => { }
|
|
214
|
+
});
|
|
215
|
+
assert.strictEqual(token, 'refreshed_access_token');
|
|
216
|
+
assert.strictEqual(sentClientId, 'stored_pkce_client_id');
|
|
217
|
+
});
|
|
160
218
|
});
|
package/package.json
CHANGED