@thuyphuocthinh/mcp-auth-proxy 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/cli.js +2 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +183 -0
- package/dist/src/oauth/browser.d.ts +5 -0
- package/dist/src/oauth/browser.js +27 -0
- package/dist/src/oauth/client-registration.d.ts +13 -0
- package/dist/src/oauth/client-registration.js +31 -0
- package/dist/src/oauth/discovery.d.ts +9 -0
- package/dist/src/oauth/discovery.js +42 -0
- package/dist/src/oauth/pkce.d.ts +7 -0
- package/dist/src/oauth/pkce.js +17 -0
- package/dist/src/oauth/server.d.ts +10 -0
- package/dist/src/oauth/server.js +84 -0
- package/dist/src/proxy.d.ts +30 -0
- package/dist/src/proxy.js +126 -0
- package/dist/src/store/token-store.d.ts +29 -0
- package/dist/src/store/token-store.js +99 -0
- package/dist/tests/cli.test.d.ts +1 -0
- package/dist/tests/cli.test.js +50 -0
- package/dist/tests/oauth/browser.test.d.ts +1 -0
- package/dist/tests/oauth/browser.test.js +35 -0
- package/dist/tests/oauth/client-registration.test.d.ts +1 -0
- package/dist/tests/oauth/client-registration.test.js +72 -0
- package/dist/tests/oauth/discovery.test.d.ts +1 -0
- package/dist/tests/oauth/discovery.test.js +94 -0
- package/dist/tests/oauth/pkce.test.d.ts +1 -0
- package/dist/tests/oauth/pkce.test.js +40 -0
- package/dist/tests/oauth/server.test.d.ts +1 -0
- package/dist/tests/oauth/server.test.js +53 -0
- package/dist/tests/proxy.test.d.ts +1 -0
- package/dist/tests/proxy.test.js +153 -0
- package/dist/tests/smoke.test.d.ts +1 -0
- package/dist/tests/smoke.test.js +8 -0
- package/dist/tests/store/token-store.test.d.ts +1 -0
- package/dist/tests/store/token-store.test.js +160 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thuy Phuoc Thinh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thuyphuocthinh/mcp-auth-proxy 🔐
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & silent token refresh.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/thuyphuocthinh/mcp-auth-proxy/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@thuyphuocthinh/mcp-auth-proxy)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[]()
|
|
10
|
+
|
|
11
|
+
`mcp-auth-proxy` acts as a zero-config bridge between your AI Client (Google Antigravity, Cursor, Windsurf, Claude Code, VS Code Copilot) and any remote Model Context Protocol (MCP) server protected by OAuth 2.0 (e.g., 200Lab AI Tutor, Notion, Linear, GitHub).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 🌟 Highlights
|
|
16
|
+
|
|
17
|
+
- 🚀 **Zero Runtime Dependencies**: Pure Node.js (`node:http`, `node:crypto`, `node:net`), lightweight, launches immediately via `npx`.
|
|
18
|
+
- 🔍 **Automated OAuth Discovery**: Intercepts `401 Unauthorized`, parses `WWW-Authenticate: Bearer resource_metadata="..."` and resolves `.well-known` endpoints automatically.
|
|
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
|
+
- 🌐 **Cross-Platform Auto Browser Launch**: Automatically opens default browser on Windows (`start`), macOS (`open`), and Linux (`xdg-open`).
|
|
21
|
+
- 🔄 **Silent Background Refresh**: Automatically renews expiring tokens in the background via `grant_type=refresh_token` before the 5-minute expiry threshold.
|
|
22
|
+
- 🛡️ **Static Header Bypass**: Support for `--header` flag to pass static API keys or personal access tokens directly.
|
|
23
|
+
- 🚦 **Dynamic Port Selection**: Automatically scans for free local ports starting from `8080` (8081, 8082...), eliminating `EADDRINUSE` port collision errors.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 📦 Usage
|
|
28
|
+
|
|
29
|
+
### 1. Antigravity / Cursor Configuration
|
|
30
|
+
|
|
31
|
+
Add this entry to your `mcp_config.json` or `claude_desktop_config.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"200lab-ai-tutor": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": [
|
|
39
|
+
"-y",
|
|
40
|
+
"@thuyphuocthinh/mcp-auth-proxy",
|
|
41
|
+
"https://mcp.200lab.io/mcp/learner"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
*On first launch, your browser will automatically pop up to approve authorization. Subsequent launches will run silently forever with automatic token refresh!*
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### 2. CLI Options
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx @thuyphuocthinh/mcp-auth-proxy <remote-url> [options]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | Description |
|
|
59
|
+
| :--- | :--- |
|
|
60
|
+
| `--client-id <id>` | Pre-configured Client ID (for servers without RFC 7591 dynamic registration) |
|
|
61
|
+
| `--header "Key: Value"` | Custom request header (e.g. bypass OAuth with static API key) |
|
|
62
|
+
| `--no-browser` | Disable automatic browser launch; prints login URL to stderr (for headless/VPS environments) |
|
|
63
|
+
| `--login-only` | Perform interactive OAuth authentication, save credentials, and exit |
|
|
64
|
+
| `-h, --help` | Display help instructions |
|
|
65
|
+
| `-v, --version` | Display version number |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🧪 Testing
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm run build
|
|
73
|
+
npm test
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
49 unit tests covering all core modules with 100% pass rate.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 📄 License
|
|
81
|
+
|
|
82
|
+
MIT © [Thuy Phuoc Thinh](https://github.com/thuyphuocthinh)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const VERSION = "1.0.0";
|
|
2
|
+
export interface CliOptions {
|
|
3
|
+
remoteUrl?: string;
|
|
4
|
+
clientId?: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
noBrowser: boolean;
|
|
7
|
+
loginOnly: boolean;
|
|
8
|
+
help: boolean;
|
|
9
|
+
version: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseArgs(args: string[]): CliOptions;
|
|
12
|
+
export declare function printHelp(): void;
|
|
13
|
+
export declare function main(args?: string[]): Promise<void>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { discoverOAuth } from './oauth/discovery.js';
|
|
2
|
+
import { generatePKCE } from './oauth/pkce.js';
|
|
3
|
+
import { resolveClientId } from './oauth/client-registration.js';
|
|
4
|
+
import { openBrowser } from './oauth/browser.js';
|
|
5
|
+
import { startCallbackServer } from './oauth/server.js';
|
|
6
|
+
import { TokenStore } from './store/token-store.js';
|
|
7
|
+
import { startProxy } from './proxy.js';
|
|
8
|
+
export const VERSION = '1.0.0';
|
|
9
|
+
export function parseArgs(args) {
|
|
10
|
+
const options = {
|
|
11
|
+
headers: {},
|
|
12
|
+
noBrowser: false,
|
|
13
|
+
loginOnly: false,
|
|
14
|
+
help: false,
|
|
15
|
+
version: false
|
|
16
|
+
};
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
if (arg === '--help' || arg === '-h') {
|
|
20
|
+
options.help = true;
|
|
21
|
+
}
|
|
22
|
+
else if (arg === '--version' || arg === '-v') {
|
|
23
|
+
options.version = true;
|
|
24
|
+
}
|
|
25
|
+
else if (arg === '--no-browser') {
|
|
26
|
+
options.noBrowser = true;
|
|
27
|
+
}
|
|
28
|
+
else if (arg === '--login-only') {
|
|
29
|
+
options.loginOnly = true;
|
|
30
|
+
}
|
|
31
|
+
else if (arg === '--client-id' && i + 1 < args.length) {
|
|
32
|
+
options.clientId = args[++i];
|
|
33
|
+
}
|
|
34
|
+
else if (arg === '--header' && i + 1 < args.length) {
|
|
35
|
+
const headerStr = args[++i];
|
|
36
|
+
const colonIdx = headerStr.indexOf(':');
|
|
37
|
+
if (colonIdx > 0) {
|
|
38
|
+
const key = headerStr.substring(0, colonIdx).trim();
|
|
39
|
+
const value = headerStr.substring(colonIdx + 1).trim();
|
|
40
|
+
options.headers[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (!arg.startsWith('-') && !options.remoteUrl) {
|
|
44
|
+
options.remoteUrl = arg;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return options;
|
|
48
|
+
}
|
|
49
|
+
export function printHelp() {
|
|
50
|
+
// All operational logging and help text MUST be printed to stderr
|
|
51
|
+
// so it does not interfere with the Stdio JSON-RPC stream on stdout!
|
|
52
|
+
console.error(`
|
|
53
|
+
mcp-oauth-proxy v${VERSION}
|
|
54
|
+
Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE & silent refresh.
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
mcp-oauth-proxy <remote-url> [options]
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--client-id <id> Pre-configured Client ID (for servers without RFC 7591 dynamic registration)
|
|
61
|
+
--header "K: V" Custom request header (e.g. bypass OAuth using static API key)
|
|
62
|
+
--no-browser Do not automatically open default browser; print authorization URL to stderr
|
|
63
|
+
--login-only Perform interactive OAuth authentication, save credentials, and exit
|
|
64
|
+
-h, --help Display this help message
|
|
65
|
+
-v, --version Display version number
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
mcp-oauth-proxy https://mcp.200lab.io/mcp/learner
|
|
69
|
+
mcp-oauth-proxy https://api.example.com/mcp --header "Authorization: Bearer my_api_key"
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
export async function main(args = process.argv.slice(2)) {
|
|
73
|
+
const options = parseArgs(args);
|
|
74
|
+
if (options.help) {
|
|
75
|
+
printHelp();
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
if (options.version) {
|
|
79
|
+
console.error(`mcp-oauth-proxy v${VERSION}`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
if (!options.remoteUrl) {
|
|
83
|
+
console.error(`[-] Missing required <remote-url> parameter. Run with --help for instructions.`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const remoteUrl = options.remoteUrl;
|
|
87
|
+
const store = new TokenStore();
|
|
88
|
+
const getToken = async () => {
|
|
89
|
+
// If user provided a manual Authorization header via --header
|
|
90
|
+
if (options.headers['Authorization']) {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
// 1. Discover OAuth Metadata from remote server
|
|
94
|
+
const metadata = await discoverOAuth(remoteUrl);
|
|
95
|
+
const clientId = await resolveClientId({
|
|
96
|
+
registrationEndpoint: metadata.registrationEndpoint,
|
|
97
|
+
redirectUri: 'http://localhost:8080/callback',
|
|
98
|
+
explicitClientId: options.clientId,
|
|
99
|
+
log: console.error
|
|
100
|
+
});
|
|
101
|
+
// 2. Try to get existing token or execute silent background refresh
|
|
102
|
+
const existingToken = await store.getOrRefreshToken({
|
|
103
|
+
resource: metadata.resource,
|
|
104
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
105
|
+
clientId,
|
|
106
|
+
log: console.error
|
|
107
|
+
});
|
|
108
|
+
if (existingToken) {
|
|
109
|
+
return existingToken;
|
|
110
|
+
}
|
|
111
|
+
// 3. No valid token: start interactive OAuth PKCE flow
|
|
112
|
+
console.error(`[*] Initiating OAuth authentication flow for: ${metadata.resource}`);
|
|
113
|
+
const callbackServer = await startCallbackServer();
|
|
114
|
+
const pkce = generatePKCE();
|
|
115
|
+
// Re-resolve client with exact dynamic callback port
|
|
116
|
+
const effectiveClientId = await resolveClientId({
|
|
117
|
+
registrationEndpoint: metadata.registrationEndpoint,
|
|
118
|
+
redirectUri: callbackServer.redirectUri,
|
|
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);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
console.error(`[*] Open this URL in your browser to complete authorization:\n${authUrl.toString()}\n`);
|
|
135
|
+
}
|
|
136
|
+
const code = await callbackServer.waitForCode();
|
|
137
|
+
console.error(`[*] Authorization code received. Exchanging for token...`);
|
|
138
|
+
const tokenRes = await fetch(metadata.tokenEndpoint, {
|
|
139
|
+
method: 'POST',
|
|
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;
|
|
163
|
+
};
|
|
164
|
+
if (options.loginOnly) {
|
|
165
|
+
await getToken();
|
|
166
|
+
console.error(`[+] Pre-authentication complete (--login-only).`);
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
// Launch Stdio runtime proxy loop
|
|
170
|
+
await startProxy({
|
|
171
|
+
remoteUrl,
|
|
172
|
+
getToken,
|
|
173
|
+
extraHeaders: options.headers,
|
|
174
|
+
logError: console.error
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// When executed directly as a script
|
|
178
|
+
if (process.argv[1]?.endsWith('cli.js') || process.argv[1]?.endsWith('index.js')) {
|
|
179
|
+
main().catch((err) => {
|
|
180
|
+
console.error(`[-] Fatal error: ${err.message}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
export function getOpenBrowserCommand(url, platform = os.platform()) {
|
|
4
|
+
if (platform === 'win32') {
|
|
5
|
+
return `start "" "${url}"`;
|
|
6
|
+
}
|
|
7
|
+
if (platform === 'darwin') {
|
|
8
|
+
return `open "${url}"`;
|
|
9
|
+
}
|
|
10
|
+
return `xdg-open "${url}"`;
|
|
11
|
+
}
|
|
12
|
+
export function openBrowser(url, log = console.log) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
log(`[*] Automatically opening default browser for OAuth authentication...`);
|
|
15
|
+
log(`[*] If the browser does not open automatically, open this URL in your browser:\n${url}\n`);
|
|
16
|
+
const command = getOpenBrowserCommand(url);
|
|
17
|
+
exec(command, (error) => {
|
|
18
|
+
if (error) {
|
|
19
|
+
log(`[-] Failed to launch browser automatically: ${error.message}`);
|
|
20
|
+
resolve(false);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
resolve(true);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ClientRegistrationResponse {
|
|
2
|
+
client_id: string;
|
|
3
|
+
client_name?: string;
|
|
4
|
+
redirect_uris?: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function registerDynamicClient(registrationEndpoint: string, redirectUri: string, clientName?: string, fetchFn?: typeof fetch): Promise<string>;
|
|
7
|
+
export declare function resolveClientId(options: {
|
|
8
|
+
registrationEndpoint?: string;
|
|
9
|
+
redirectUri: string;
|
|
10
|
+
explicitClientId?: string;
|
|
11
|
+
fetchFn?: typeof fetch;
|
|
12
|
+
log?: (msg: string) => void;
|
|
13
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export async function registerDynamicClient(registrationEndpoint, redirectUri, clientName = 'mcp-oauth-proxy', fetchFn = fetch) {
|
|
2
|
+
const response = await fetchFn(registrationEndpoint, {
|
|
3
|
+
method: 'POST',
|
|
4
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5
|
+
body: JSON.stringify({
|
|
6
|
+
client_name: clientName,
|
|
7
|
+
redirect_uris: [redirectUri]
|
|
8
|
+
})
|
|
9
|
+
});
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
const errorText = await response.text();
|
|
12
|
+
throw new Error(`Dynamic Client Registration failed (HTTP ${response.status}): ${errorText}`);
|
|
13
|
+
}
|
|
14
|
+
const data = (await response.json());
|
|
15
|
+
if (!data.client_id) {
|
|
16
|
+
throw new Error(`Registration response missing client_id`);
|
|
17
|
+
}
|
|
18
|
+
return data.client_id;
|
|
19
|
+
}
|
|
20
|
+
export async function resolveClientId(options) {
|
|
21
|
+
const { registrationEndpoint, redirectUri, explicitClientId, fetchFn = fetch, log = console.log } = options;
|
|
22
|
+
if (explicitClientId) {
|
|
23
|
+
log(`[*] Using configured Client ID: ${explicitClientId}`);
|
|
24
|
+
return explicitClientId;
|
|
25
|
+
}
|
|
26
|
+
if (registrationEndpoint) {
|
|
27
|
+
log(`[*] Registering dynamic Client ID with: ${registrationEndpoint}`);
|
|
28
|
+
return await registerDynamicClient(registrationEndpoint, redirectUri, 'mcp-oauth-proxy', fetchFn);
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Server does not support Dynamic Client Registration (RFC 7591). Please provide an explicit --client-id <id>`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface OAuthMetadata {
|
|
2
|
+
resource: string;
|
|
3
|
+
authorizationEndpoint: string;
|
|
4
|
+
tokenEndpoint: string;
|
|
5
|
+
registrationEndpoint?: string;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function extractResourceMetadataUrl(authHeader: string | null): string | null;
|
|
9
|
+
export declare function discoverOAuth(remoteUrl: string, fetchFn?: typeof fetch): Promise<OAuthMetadata>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function extractResourceMetadataUrl(authHeader) {
|
|
2
|
+
if (!authHeader)
|
|
3
|
+
return null;
|
|
4
|
+
const match = authHeader.match(/resource_metadata="([^"]+)"/i);
|
|
5
|
+
return match ? match[1] : null;
|
|
6
|
+
}
|
|
7
|
+
export async function discoverOAuth(remoteUrl, fetchFn = fetch) {
|
|
8
|
+
// Step 1: Probe remote endpoint for 401 and WWW-Authenticate header
|
|
9
|
+
const probeResponse = await fetchFn(remoteUrl, { method: 'GET' });
|
|
10
|
+
const authHeader = probeResponse.headers.get('www-authenticate');
|
|
11
|
+
const metadataUrl = extractResourceMetadataUrl(authHeader);
|
|
12
|
+
if (!metadataUrl) {
|
|
13
|
+
throw new Error(`Missing 'WWW-Authenticate: Bearer resource_metadata="..."' header from ${remoteUrl}`);
|
|
14
|
+
}
|
|
15
|
+
// Step 2: Retrieve Protected Resource Metadata (RFC 8707)
|
|
16
|
+
const resourceRes = await fetchFn(metadataUrl);
|
|
17
|
+
if (!resourceRes.ok) {
|
|
18
|
+
throw new Error(`Failed to fetch Resource Metadata from ${metadataUrl}: HTTP ${resourceRes.status}`);
|
|
19
|
+
}
|
|
20
|
+
const resourceData = (await resourceRes.json());
|
|
21
|
+
const authServer = resourceData.authorization_servers?.[0];
|
|
22
|
+
if (!authServer) {
|
|
23
|
+
throw new Error(`Resource Metadata does not contain valid authorization_servers`);
|
|
24
|
+
}
|
|
25
|
+
// Step 3: Retrieve Authorization Server Metadata (RFC 8414)
|
|
26
|
+
const serverMetaUrl = `${authServer.replace(/\/$/, '')}/.well-known/oauth-authorization-server`;
|
|
27
|
+
const serverRes = await fetchFn(serverMetaUrl);
|
|
28
|
+
if (!serverRes.ok) {
|
|
29
|
+
throw new Error(`Failed to fetch Auth Server Metadata from ${serverMetaUrl}: HTTP ${serverRes.status}`);
|
|
30
|
+
}
|
|
31
|
+
const serverData = (await serverRes.json());
|
|
32
|
+
if (!serverData.authorization_endpoint || !serverData.token_endpoint) {
|
|
33
|
+
throw new Error(`Auth Server Metadata is missing authorization_endpoint or token_endpoint`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
resource: resourceData.resource || remoteUrl,
|
|
37
|
+
authorizationEndpoint: serverData.authorization_endpoint,
|
|
38
|
+
tokenEndpoint: serverData.token_endpoint,
|
|
39
|
+
registrationEndpoint: serverData.registration_endpoint,
|
|
40
|
+
scopes: resourceData.scopes_supported || ['learner']
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
export function base64url(buffer) {
|
|
3
|
+
return buffer.toString('base64')
|
|
4
|
+
.replace(/=/g, '')
|
|
5
|
+
.replace(/\+/g, '-')
|
|
6
|
+
.replace(/\//g, '_');
|
|
7
|
+
}
|
|
8
|
+
export function generatePKCE() {
|
|
9
|
+
// RFC 7636: verifier must have minimum 43 characters and maximum 128 characters
|
|
10
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
11
|
+
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
12
|
+
return {
|
|
13
|
+
verifier,
|
|
14
|
+
challenge,
|
|
15
|
+
method: 'S256'
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
export declare function getAvailablePort(startPort?: number): Promise<number>;
|
|
3
|
+
export interface CallbackServerInstance {
|
|
4
|
+
server: http.Server;
|
|
5
|
+
port: number;
|
|
6
|
+
redirectUri: string;
|
|
7
|
+
waitForCode: () => Promise<string>;
|
|
8
|
+
close: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function startCallbackServer(port?: number): Promise<CallbackServerInstance>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
export function getAvailablePort(startPort = 8080) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const tester = net.createServer();
|
|
6
|
+
tester.listen(startPort, () => {
|
|
7
|
+
const port = tester.address().port;
|
|
8
|
+
tester.close(() => resolve(port));
|
|
9
|
+
});
|
|
10
|
+
tester.on('error', () => {
|
|
11
|
+
resolve(getAvailablePort(startPort + 1));
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function startCallbackServer(port) {
|
|
16
|
+
const selectedPort = port ?? (await getAvailablePort(8080));
|
|
17
|
+
const redirectUri = `http://localhost:${selectedPort}/callback`;
|
|
18
|
+
let resolveCode;
|
|
19
|
+
let rejectCode;
|
|
20
|
+
const codePromise = new Promise((res, rej) => {
|
|
21
|
+
resolveCode = res;
|
|
22
|
+
rejectCode = rej;
|
|
23
|
+
});
|
|
24
|
+
// Prevent unhandledRejection if consumer awaits slightly after callback resolves/rejects
|
|
25
|
+
codePromise.catch(() => { });
|
|
26
|
+
const server = http.createServer((req, res) => {
|
|
27
|
+
const url = new URL(req.url || '/', `http://localhost:${selectedPort}`);
|
|
28
|
+
if (url.pathname === '/callback') {
|
|
29
|
+
const code = url.searchParams.get('code');
|
|
30
|
+
const error = url.searchParams.get('error');
|
|
31
|
+
const errorDesc = url.searchParams.get('error_description') || error;
|
|
32
|
+
if (error) {
|
|
33
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
34
|
+
res.end(`
|
|
35
|
+
<div style="font-family: sans-serif; text-align: center; padding: 40px;">
|
|
36
|
+
<h1 style="color: #ef4444;">✘ Authentication Failed</h1>
|
|
37
|
+
<p>${errorDesc}</p>
|
|
38
|
+
</div>
|
|
39
|
+
`);
|
|
40
|
+
rejectCode(new Error(`OAuth authentication failed: ${errorDesc}`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!code) {
|
|
44
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
45
|
+
res.end(`
|
|
46
|
+
<div style="font-family: sans-serif; text-align: center; padding: 40px;">
|
|
47
|
+
<h1 style="color: #ef4444;">✘ Authorization Code Missing</h1>
|
|
48
|
+
<p>The authorization server did not return a valid code.</p>
|
|
49
|
+
</div>
|
|
50
|
+
`);
|
|
51
|
+
rejectCode(new Error('Missing authorization code in callback'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
55
|
+
res.end(`
|
|
56
|
+
<div style="font-family: sans-serif; text-align: center; padding: 40px;">
|
|
57
|
+
<h1 style="color: #10b981;">✔ Authentication Successful!</h1>
|
|
58
|
+
<p>Credentials have been securely saved to your local environment.</p>
|
|
59
|
+
<p>You may now close this tab and return to your application.</p>
|
|
60
|
+
</div>
|
|
61
|
+
`);
|
|
62
|
+
resolveCode(code);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
66
|
+
res.end('Not Found');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
server.listen(selectedPort, () => resolve());
|
|
71
|
+
});
|
|
72
|
+
const close = () => {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
server.close(() => resolve());
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
server,
|
|
79
|
+
port: selectedPort,
|
|
80
|
+
redirectUri,
|
|
81
|
+
waitForCode: () => codePromise,
|
|
82
|
+
close
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ProxyOptions {
|
|
2
|
+
remoteUrl: string;
|
|
3
|
+
getToken: () => Promise<string>;
|
|
4
|
+
extraHeaders?: Record<string, string>;
|
|
5
|
+
input?: NodeJS.ReadableStream;
|
|
6
|
+
output?: NodeJS.WritableStream;
|
|
7
|
+
fetchFn?: typeof fetch;
|
|
8
|
+
logError?: (msg: string) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extracts JSON-RPC message payloads from SSE (text/event-stream) response chunks.
|
|
12
|
+
* Conforms to standard Model Context Protocol (MCP) Streamable HTTP specification:
|
|
13
|
+
* event: message
|
|
14
|
+
* data: {"jsonrpc":"2.0", ...}
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractJsonRpcFromSse(responseText: string): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Forwards a single JSON-RPC line from Stdio to the Remote HTTP MCP Server.
|
|
19
|
+
*/
|
|
20
|
+
export declare function forwardJsonRpc(options: {
|
|
21
|
+
line: string;
|
|
22
|
+
remoteUrl: string;
|
|
23
|
+
headers: Record<string, string>;
|
|
24
|
+
fetchFn?: typeof fetch;
|
|
25
|
+
}): Promise<string[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Starts Stdio bidirectional proxy loop.
|
|
28
|
+
* Reads newline-delimited JSON-RPC from stdin, attaches valid token, and writes to stdout.
|
|
29
|
+
*/
|
|
30
|
+
export declare function startProxy(options: ProxyOptions): Promise<void>;
|