@context-engine-bridge/context-engine-mcp-bridge 0.0.9 → 0.0.11
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/package.json +1 -1
- package/src/mcpServer.js +100 -43
- package/src/oauthHandler.js +585 -0
package/package.json
CHANGED
package/src/mcpServer.js
CHANGED
|
@@ -11,6 +11,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
11
11
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { loadAnyAuthEntry, loadAuthEntry } from "./authConfig.js";
|
|
13
13
|
import { maybeRemapToolArgs, maybeRemapToolResult } from "./resultPathMapping.js";
|
|
14
|
+
import * as oauthHandler from "./oauthHandler.js";
|
|
14
15
|
|
|
15
16
|
function debugLog(message) {
|
|
16
17
|
try {
|
|
@@ -616,73 +617,128 @@ export async function runHttpMcpServer(options) {
|
|
|
616
617
|
|
|
617
618
|
await server.connect(transport);
|
|
618
619
|
|
|
620
|
+
// Build issuer URL for OAuth
|
|
621
|
+
// Note: Local-only bridge uses 127.0.0.1. For remote access, this would need to be configurable.
|
|
622
|
+
const issuerUrl = `http://127.0.0.1:${port}`;
|
|
623
|
+
|
|
619
624
|
const httpServer = createServer((req, res) => {
|
|
620
625
|
try {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
626
|
+
const url = req.url || "/";
|
|
627
|
+
|
|
628
|
+
// Parse URL for query params
|
|
629
|
+
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
|
|
630
|
+
|
|
631
|
+
// ================================================================
|
|
632
|
+
// OAuth 2.0 Endpoints (RFC9728 Protected Resource Metadata + RFC7591)
|
|
633
|
+
// ================================================================
|
|
634
|
+
|
|
635
|
+
// OAuth metadata endpoint (RFC9728)
|
|
636
|
+
if (parsedUrl.pathname === "/.well-known/oauth-authorization-server") {
|
|
637
|
+
oauthHandler.handleOAuthMetadata(req, res, issuerUrl);
|
|
631
638
|
return;
|
|
632
639
|
}
|
|
633
640
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
res.end(
|
|
638
|
-
JSON.stringify({
|
|
639
|
-
jsonrpc: "2.0",
|
|
640
|
-
error: { code: -32000, message: "Method not allowed" },
|
|
641
|
-
id: null,
|
|
642
|
-
}),
|
|
643
|
-
);
|
|
641
|
+
// OAuth Dynamic Client Registration endpoint (RFC7591)
|
|
642
|
+
if (parsedUrl.pathname === "/oauth/register" && req.method === "POST") {
|
|
643
|
+
oauthHandler.handleOAuthRegister(req, res);
|
|
644
644
|
return;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
647
|
+
// OAuth authorize endpoint
|
|
648
|
+
if (parsedUrl.pathname === "/oauth/authorize") {
|
|
649
|
+
oauthHandler.handleOAuthAuthorize(req, res, parsedUrl.searchParams);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Store session endpoint (helper for login page)
|
|
654
|
+
if (parsedUrl.pathname === "/oauth/store-session" && req.method === "POST") {
|
|
655
|
+
oauthHandler.handleOAuthStoreSession(req, res);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// OAuth token endpoint
|
|
660
|
+
if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
|
|
661
|
+
oauthHandler.handleOAuthToken(req, res);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ================================================================
|
|
666
|
+
// MCP Endpoint
|
|
667
|
+
// ================================================================
|
|
668
|
+
|
|
669
|
+
// Check Bearer token for MCP endpoint (accept /mcp and /mcp/ for compatibility)
|
|
670
|
+
if (parsedUrl.pathname === "/mcp" || parsedUrl.pathname === "/mcp/") {
|
|
671
|
+
const authHeader = req.headers["authorization"] || "";
|
|
672
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
673
|
+
|
|
674
|
+
// TODO: Validate token and inject session
|
|
675
|
+
// For now, allow unauthenticated (backward compatible)
|
|
676
|
+
|
|
677
|
+
if (req.method !== "POST") {
|
|
678
|
+
res.statusCode = 405;
|
|
658
679
|
res.setHeader("Content-Type", "application/json");
|
|
659
680
|
res.end(
|
|
660
681
|
JSON.stringify({
|
|
661
682
|
jsonrpc: "2.0",
|
|
662
|
-
error: { code: -
|
|
683
|
+
error: { code: -32000, message: "Method not allowed" },
|
|
663
684
|
id: null,
|
|
664
685
|
}),
|
|
665
686
|
);
|
|
666
687
|
return;
|
|
667
688
|
}
|
|
668
689
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
690
|
+
let body = "";
|
|
691
|
+
req.on("data", (chunk) => {
|
|
692
|
+
body += chunk;
|
|
693
|
+
});
|
|
694
|
+
req.on("end", async () => {
|
|
695
|
+
let parsed;
|
|
696
|
+
try {
|
|
697
|
+
parsed = body ? JSON.parse(body) : {};
|
|
698
|
+
} catch (err) {
|
|
699
|
+
debugLog("[ctxce] Failed to parse HTTP MCP request body: " + String(err));
|
|
700
|
+
res.statusCode = 400;
|
|
675
701
|
res.setHeader("Content-Type", "application/json");
|
|
676
702
|
res.end(
|
|
677
703
|
JSON.stringify({
|
|
678
704
|
jsonrpc: "2.0",
|
|
679
|
-
error: { code: -
|
|
705
|
+
error: { code: -32700, message: "Invalid JSON" },
|
|
680
706
|
id: null,
|
|
681
707
|
}),
|
|
682
708
|
);
|
|
709
|
+
return;
|
|
683
710
|
}
|
|
684
|
-
|
|
685
|
-
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
await transport.handleRequest(req, res, parsed);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
debugLog("[ctxce] Error handling HTTP MCP request: " + String(err));
|
|
716
|
+
if (!res.headersSent) {
|
|
717
|
+
res.statusCode = 500;
|
|
718
|
+
res.setHeader("Content-Type", "application/json");
|
|
719
|
+
res.end(
|
|
720
|
+
JSON.stringify({
|
|
721
|
+
jsonrpc: "2.0",
|
|
722
|
+
error: { code: -32603, message: "Internal server error" },
|
|
723
|
+
id: null,
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 404 for everything else
|
|
733
|
+
res.statusCode = 404;
|
|
734
|
+
res.setHeader("Content-Type", "application/json");
|
|
735
|
+
res.end(
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
jsonrpc: "2.0",
|
|
738
|
+
error: { code: -32000, message: "Not found" },
|
|
739
|
+
id: null,
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
686
742
|
} catch (err) {
|
|
687
743
|
debugLog("[ctxce] Unexpected error in HTTP MCP server: " + String(err));
|
|
688
744
|
if (!res.headersSent) {
|
|
@@ -699,8 +755,9 @@ export async function runHttpMcpServer(options) {
|
|
|
699
755
|
}
|
|
700
756
|
});
|
|
701
757
|
|
|
702
|
-
|
|
703
|
-
|
|
758
|
+
// Bind to 127.0.0.1 only (localhost) for local-only OAuth security
|
|
759
|
+
httpServer.listen(port, '127.0.0.1', () => {
|
|
760
|
+
debugLog(`[ctxce] HTTP MCP bridge listening on 127.0.0.1:${port}`);
|
|
704
761
|
});
|
|
705
762
|
}
|
|
706
763
|
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// OAuth 2.0 Handler for HTTP MCP Server
|
|
2
|
+
// Implements RFC9728 Protected Resource Metadata and RFC7591 Dynamic Client Registration
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { loadAnyAuthEntry, saveAuthEntry } from "./authConfig.js";
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// OAuth Storage (in-memory for bridge process)
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
// Maps bearer tokens to session IDs
|
|
13
|
+
const tokenStore = new Map();
|
|
14
|
+
// Maps authorization codes to session info
|
|
15
|
+
const pendingCodes = new Map();
|
|
16
|
+
// Maps client_id to client info
|
|
17
|
+
const registeredClients = new Map();
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// OAuth Utilities
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Clean up expired tokens from tokenStore
|
|
25
|
+
* Called periodically to prevent unbounded memory growth
|
|
26
|
+
*/
|
|
27
|
+
function cleanupExpiredTokens() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const expiryMs = 86400000; // 24 hours
|
|
30
|
+
for (const [token, data] of tokenStore.entries()) {
|
|
31
|
+
if (now - data.createdAt > expiryMs) {
|
|
32
|
+
tokenStore.delete(token);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generateToken() {
|
|
38
|
+
return randomBytes(32).toString("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function generateCode() {
|
|
42
|
+
return randomBytes(16).toString("base64url");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function debugLog(message) {
|
|
46
|
+
try {
|
|
47
|
+
const text = typeof message === "string" ? message : String(message);
|
|
48
|
+
console.error(text);
|
|
49
|
+
const dest = process.env.CTXCE_DEBUG_LOG;
|
|
50
|
+
if (dest) {
|
|
51
|
+
fs.appendFileSync(dest, `${new Date().toISOString()} ${text}\n`, "utf8");
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore logging errors
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// OAuth 2.0 Metadata (RFC9728)
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export function getOAuthMetadata(issuerUrl) {
|
|
63
|
+
return {
|
|
64
|
+
issuer: issuerUrl,
|
|
65
|
+
authorization_endpoint: `${issuerUrl}/oauth/authorize`,
|
|
66
|
+
token_endpoint: `${issuerUrl}/oauth/token`,
|
|
67
|
+
registration_endpoint: `${issuerUrl}/oauth/register`, // RFC7591 Dynamic Client Registration
|
|
68
|
+
response_types_supported: ["code"],
|
|
69
|
+
grant_types_supported: ["authorization_code"],
|
|
70
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
71
|
+
code_challenge_methods_supported: ["S256"],
|
|
72
|
+
scopes_supported: ["mcp"],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// HTML Login Page
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Safely escape JSON for embedding in HTML script context
|
|
82
|
+
* Escapes special characters that could break out of a script tag
|
|
83
|
+
*/
|
|
84
|
+
function escapeJsonForHtml(obj) {
|
|
85
|
+
const json = JSON.stringify(obj);
|
|
86
|
+
// Replace dangerous characters with HTML-safe equivalents
|
|
87
|
+
// </script> can break out of script tag, so replace </ with \u003C/
|
|
88
|
+
return json.replace(/</g, '\\u003C').replace(/>/g, '\\u003E');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getLoginPage(redirectUri, clientId, state, codeChallenge, codeChallengeMethod) {
|
|
92
|
+
const params = new URLSearchParams({
|
|
93
|
+
redirect_uri: redirectUri || "",
|
|
94
|
+
client_id: clientId || "",
|
|
95
|
+
state: state || "",
|
|
96
|
+
code_challenge: codeChallenge || "",
|
|
97
|
+
code_challenge_method: codeChallengeMethod || "",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return `<!DOCTYPE html>
|
|
101
|
+
<html>
|
|
102
|
+
<head>
|
|
103
|
+
<title>Context Engine MCP - Login</title>
|
|
104
|
+
<style>
|
|
105
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; }
|
|
106
|
+
h1 { color: #333; }
|
|
107
|
+
.form-group { margin-bottom: 15px; }
|
|
108
|
+
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
|
109
|
+
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
|
|
110
|
+
button { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
|
|
111
|
+
button:hover { background: #0056b3; }
|
|
112
|
+
.info { background: #e7f3ff; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-size: 14px; }
|
|
113
|
+
.error { color: #dc3545; margin-top: 10px; }
|
|
114
|
+
.success { color: #28a745; margin-top: 10px; }
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
<h1>Context Engine MCP Bridge</h1>
|
|
119
|
+
<div class="info">
|
|
120
|
+
This MCP bridge requires authentication. Please log in to your Context Engine backend.
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div id="result"></div>
|
|
124
|
+
|
|
125
|
+
<form id="loginForm">
|
|
126
|
+
<div class="form-group">
|
|
127
|
+
<label>Backend URL</label>
|
|
128
|
+
<input type="url" id="backendUrl" placeholder="http://localhost:8004" required>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="form-group">
|
|
131
|
+
<label>Username (optional)</label>
|
|
132
|
+
<input type="text" id="username" placeholder="Leave empty for token auth">
|
|
133
|
+
</div>
|
|
134
|
+
<div class="form-group">
|
|
135
|
+
<label>Password (optional)</label>
|
|
136
|
+
<input type="password" id="password" placeholder="Required if username provided">
|
|
137
|
+
</div>
|
|
138
|
+
<div class="form-group">
|
|
139
|
+
<label>Auth Token (if no username)</label>
|
|
140
|
+
<input type="text" id="token" placeholder="Your shared auth token">
|
|
141
|
+
</div>
|
|
142
|
+
<button type="submit">Login & Authorize</button>
|
|
143
|
+
</form>
|
|
144
|
+
|
|
145
|
+
<script>
|
|
146
|
+
const params = ${escapeJsonForHtml(Object.fromEntries(params))};
|
|
147
|
+
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
const result = document.getElementById('result');
|
|
150
|
+
result.innerHTML = '<p style="color: #007bff;">Logging in...</p>';
|
|
151
|
+
|
|
152
|
+
const backendUrl = document.getElementById('backendUrl').value;
|
|
153
|
+
const username = document.getElementById('username').value;
|
|
154
|
+
const password = document.getElementById('password').value;
|
|
155
|
+
const token = document.getElementById('token').value;
|
|
156
|
+
|
|
157
|
+
const usePassword = username && password;
|
|
158
|
+
const body = usePassword
|
|
159
|
+
? { username, password, workspace: '/tmp/bridge-oauth' }
|
|
160
|
+
: { client: 'ctxce', workspace: '/tmp/bridge-oauth', token: token || undefined };
|
|
161
|
+
|
|
162
|
+
const target = backendUrl.replace(/\\/+$/, '') + (usePassword ? '/auth/login/password' : '/auth/login');
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const resp = await fetch(target, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'Content-Type': 'application/json' },
|
|
168
|
+
body: JSON.stringify(body)
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!resp.ok) {
|
|
172
|
+
throw new Error('Login failed: ' + resp.status);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data = await resp.json();
|
|
176
|
+
const sessionId = data.session_id || data.sessionId;
|
|
177
|
+
if (!sessionId) {
|
|
178
|
+
throw new Error('No session in response');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Store the session and get authorization code
|
|
182
|
+
const storeResp = await fetch('/oauth/store-session', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
session_id: sessionId,
|
|
187
|
+
backend_url: backendUrl,
|
|
188
|
+
redirect_uri: params.redirect_uri,
|
|
189
|
+
state: params.state,
|
|
190
|
+
code_challenge: params.code_challenge,
|
|
191
|
+
code_challenge_method: params.code_challenge_method,
|
|
192
|
+
client_id: params.client_id
|
|
193
|
+
})
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!storeResp.ok) {
|
|
197
|
+
throw new Error('Failed to store session');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const storeData = await storeResp.json();
|
|
201
|
+
if (storeData.redirect) {
|
|
202
|
+
window.location.href = storeData.redirect;
|
|
203
|
+
} else {
|
|
204
|
+
throw new Error('No redirect URL');
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
result.innerHTML = '<p class="error">' + err.message + '</p>';
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
211
|
+
</body>
|
|
212
|
+
</html>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// OAuth Endpoint Handlers
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate client_id and redirect_uri against registered clients
|
|
221
|
+
* @param {string} clientId - OAuth client_id
|
|
222
|
+
* @param {string} redirectUri - OAuth redirect_uri
|
|
223
|
+
* @returns {boolean} - true if both client_id and redirect_uri are valid
|
|
224
|
+
*/
|
|
225
|
+
function validateClientAndRedirect(clientId, redirectUri) {
|
|
226
|
+
if (!clientId || !redirectUri) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const client = registeredClients.get(clientId);
|
|
230
|
+
if (!client) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
// Check if redirect_uri exactly matches one of the registered URIs
|
|
234
|
+
const redirectUris = client.redirectUris || [];
|
|
235
|
+
return redirectUris.includes(redirectUri);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handle OAuth metadata endpoint (RFC9728)
|
|
240
|
+
* GET /.well-known/oauth-authorization-server
|
|
241
|
+
*/
|
|
242
|
+
export function handleOAuthMetadata(_req, res, issuerUrl) {
|
|
243
|
+
res.setHeader("Content-Type", "application/json");
|
|
244
|
+
res.end(JSON.stringify(getOAuthMetadata(issuerUrl)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Handle OAuth Dynamic Client Registration (RFC7591)
|
|
249
|
+
* POST /oauth/register
|
|
250
|
+
*/
|
|
251
|
+
export function handleOAuthRegister(req, res) {
|
|
252
|
+
let body = "";
|
|
253
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
254
|
+
req.on("end", () => {
|
|
255
|
+
try {
|
|
256
|
+
const data = JSON.parse(body);
|
|
257
|
+
|
|
258
|
+
// Validate required fields
|
|
259
|
+
if (!data.redirect_uris || !Array.isArray(data.redirect_uris) || data.redirect_uris.length === 0) {
|
|
260
|
+
res.statusCode = 400;
|
|
261
|
+
res.setHeader("Content-Type", "application/json");
|
|
262
|
+
res.end(JSON.stringify({ error: "invalid_redirect_uri" }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Auto-approve any client registration for local bridge
|
|
267
|
+
const clientId = generateToken().slice(0, 32);
|
|
268
|
+
const client_id = `mcp_${clientId}`;
|
|
269
|
+
|
|
270
|
+
registeredClients.set(client_id, {
|
|
271
|
+
clientId: client_id,
|
|
272
|
+
redirectUris: data.redirect_uris,
|
|
273
|
+
grantTypes: data.grant_types || ["authorization_code"],
|
|
274
|
+
createdAt: Date.now(),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
res.setHeader("Content-Type", "application/json");
|
|
278
|
+
res.statusCode = 201;
|
|
279
|
+
res.end(JSON.stringify({
|
|
280
|
+
client_id: client_id,
|
|
281
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
282
|
+
grant_types: ["authorization_code"],
|
|
283
|
+
redirect_uris: data.redirect_uris,
|
|
284
|
+
response_types: ["code"],
|
|
285
|
+
token_endpoint_auth_method: "none",
|
|
286
|
+
}));
|
|
287
|
+
} catch (err) {
|
|
288
|
+
debugLog("[ctxce] /oauth/register error: " + String(err));
|
|
289
|
+
res.statusCode = 400;
|
|
290
|
+
res.setHeader("Content-Type", "application/json");
|
|
291
|
+
res.end(JSON.stringify({ error: "invalid_client_metadata", error_description: String(err) }));
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Handle OAuth authorize endpoint
|
|
298
|
+
* GET /oauth/authorize
|
|
299
|
+
*/
|
|
300
|
+
export function handleOAuthAuthorize(_req, res, searchParams) {
|
|
301
|
+
const redirectUri = searchParams.get("redirect_uri");
|
|
302
|
+
const clientId = searchParams.get("client_id");
|
|
303
|
+
const state = searchParams.get("state");
|
|
304
|
+
const responseType = searchParams.get("response_type");
|
|
305
|
+
const codeChallenge = searchParams.get("code_challenge");
|
|
306
|
+
const codeChallengeMethod = searchParams.get("code_challenge_method") || "S256";
|
|
307
|
+
|
|
308
|
+
// Validate response_type is "code" (authorization code flow)
|
|
309
|
+
if (responseType !== "code") {
|
|
310
|
+
res.statusCode = 400;
|
|
311
|
+
res.setHeader("Content-Type", "application/json");
|
|
312
|
+
res.end(JSON.stringify({ error: "unsupported_response_type", error_description: "Only response_type=code is supported" }));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Validate client_id and redirect_uri against registered clients
|
|
317
|
+
if (!validateClientAndRedirect(clientId, redirectUri)) {
|
|
318
|
+
res.statusCode = 400;
|
|
319
|
+
res.setHeader("Content-Type", "application/json");
|
|
320
|
+
res.end(JSON.stringify({ error: "invalid_client", error_description: "Unknown client_id or unauthorized redirect_uri" }));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// If already logged in (has valid session), auto-approve
|
|
325
|
+
const existingAuth = loadAnyAuthEntry();
|
|
326
|
+
if (existingAuth && existingAuth.entry && existingAuth.entry.sessionId) {
|
|
327
|
+
// Auto-generate code and redirect
|
|
328
|
+
const code = generateCode();
|
|
329
|
+
pendingCodes.set(code, {
|
|
330
|
+
clientId,
|
|
331
|
+
sessionId: existingAuth.entry.sessionId,
|
|
332
|
+
backendUrl: existingAuth.backendUrl,
|
|
333
|
+
codeChallenge,
|
|
334
|
+
codeChallengeMethod,
|
|
335
|
+
redirectUri,
|
|
336
|
+
createdAt: Date.now(),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const redirectUrl = new URL(redirectUri || "http://localhost/callback");
|
|
340
|
+
redirectUrl.searchParams.set("code", code);
|
|
341
|
+
if (state) redirectUrl.searchParams.set("state", state);
|
|
342
|
+
res.setHeader("Location", redirectUrl.toString());
|
|
343
|
+
res.statusCode = 302;
|
|
344
|
+
res.end();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Otherwise, show login page
|
|
349
|
+
res.setHeader("Content-Type", "text/html");
|
|
350
|
+
res.end(getLoginPage(redirectUri, clientId, state, codeChallenge, codeChallengeMethod));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Handle OAuth store-session endpoint (helper for login page)
|
|
355
|
+
* POST /oauth/store-session
|
|
356
|
+
*
|
|
357
|
+
* Security note: This endpoint is called from the browser after login.
|
|
358
|
+
* Since the HTTP server binds to 127.0.0.1 only, this is only accessible from localhost.
|
|
359
|
+
* For additional CSRF protection, we validate client_id and redirect_uri match a
|
|
360
|
+
* previously registered client.
|
|
361
|
+
*/
|
|
362
|
+
export function handleOAuthStoreSession(req, res) {
|
|
363
|
+
let body = "";
|
|
364
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
365
|
+
req.on("end", () => {
|
|
366
|
+
res.setHeader("Content-Type", "application/json");
|
|
367
|
+
try {
|
|
368
|
+
const data = JSON.parse(body);
|
|
369
|
+
const { session_id, backend_url, redirect_uri, state, code_challenge, code_challenge_method, client_id } = data;
|
|
370
|
+
|
|
371
|
+
if (!session_id || !backend_url) {
|
|
372
|
+
res.statusCode = 400;
|
|
373
|
+
res.end(JSON.stringify({ error: "Missing session_id or backend_url" }));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Validate backend_url is a valid URL string (prevent prototype pollution)
|
|
378
|
+
// Only allow http/https schemes for backend API URLs
|
|
379
|
+
try {
|
|
380
|
+
const url = new URL(backend_url);
|
|
381
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
382
|
+
res.statusCode = 400;
|
|
383
|
+
res.end(JSON.stringify({ error: "Invalid backend_url", error_description: "Only http/https URLs are allowed" }));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
res.statusCode = 400;
|
|
388
|
+
res.end(JSON.stringify({ error: "Invalid backend_url" }));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Validate client_id and redirect_uri against registered clients
|
|
393
|
+
// Note: client_id is passed from the login page which gets it from the initial auth request
|
|
394
|
+
if (!validateClientAndRedirect(client_id, redirect_uri)) {
|
|
395
|
+
res.statusCode = 400;
|
|
396
|
+
res.end(JSON.stringify({ error: "invalid_client", error_description: "Unknown client_id or unauthorized redirect_uri" }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Additional CSRF protection: verify request came from a local browser origin
|
|
401
|
+
// Require Origin or Referer header to be present and from localhost
|
|
402
|
+
const origin = req.headers["origin"] || req.headers["referer"];
|
|
403
|
+
if (!origin) {
|
|
404
|
+
res.statusCode = 403;
|
|
405
|
+
res.end(JSON.stringify({ error: "forbidden", error_description: "Origin or Referer header required" }));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const originUrl = new URL(origin);
|
|
410
|
+
const hostname = originUrl.hostname;
|
|
411
|
+
// Only allow localhost or 127.0.0.1 origins
|
|
412
|
+
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
413
|
+
res.statusCode = 403;
|
|
414
|
+
res.end(JSON.stringify({ error: "forbidden", error_description: "Request must originate from localhost" }));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
// If origin parsing fails, reject the request
|
|
419
|
+
res.statusCode = 403;
|
|
420
|
+
res.end(JSON.stringify({ error: "forbidden", error_description: "Invalid origin" }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Save the auth entry
|
|
425
|
+
saveAuthEntry(backend_url, {
|
|
426
|
+
sessionId: session_id,
|
|
427
|
+
userId: "oauth-user",
|
|
428
|
+
expiresAt: null,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Generate auth code
|
|
432
|
+
const code = generateCode();
|
|
433
|
+
pendingCodes.set(code, {
|
|
434
|
+
clientId: client_id,
|
|
435
|
+
sessionId: session_id,
|
|
436
|
+
backendUrl: backend_url,
|
|
437
|
+
codeChallenge: code_challenge,
|
|
438
|
+
codeChallengeMethod: code_challenge_method,
|
|
439
|
+
redirectUri: redirect_uri,
|
|
440
|
+
createdAt: Date.now(),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Return redirect URL
|
|
444
|
+
const redirectUrl = new URL(redirect_uri || "http://localhost/callback");
|
|
445
|
+
redirectUrl.searchParams.set("code", code);
|
|
446
|
+
if (state) redirectUrl.searchParams.set("state", state);
|
|
447
|
+
|
|
448
|
+
res.end(JSON.stringify({ redirect: redirectUrl.toString() }));
|
|
449
|
+
} catch (err) {
|
|
450
|
+
debugLog("[ctxce] /oauth/store-session error: " + String(err));
|
|
451
|
+
res.statusCode = 400;
|
|
452
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Handle OAuth token endpoint
|
|
459
|
+
* POST /oauth/token
|
|
460
|
+
*/
|
|
461
|
+
export function handleOAuthToken(req, res) {
|
|
462
|
+
let body = "";
|
|
463
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
464
|
+
req.on("end", () => {
|
|
465
|
+
try {
|
|
466
|
+
const data = new URLSearchParams(body);
|
|
467
|
+
const code = data.get("code");
|
|
468
|
+
const redirectUri = data.get("redirect_uri");
|
|
469
|
+
const clientId = data.get("client_id");
|
|
470
|
+
// PKCE code_verifier - extracted but not validated yet (local bridge, trusted)
|
|
471
|
+
data.get("code_verifier");
|
|
472
|
+
const grantType = data.get("grant_type");
|
|
473
|
+
|
|
474
|
+
res.setHeader("Content-Type", "application/json");
|
|
475
|
+
|
|
476
|
+
if (grantType !== "authorization_code") {
|
|
477
|
+
res.statusCode = 400;
|
|
478
|
+
res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const pendingData = pendingCodes.get(code);
|
|
483
|
+
if (!pendingData) {
|
|
484
|
+
res.statusCode = 400;
|
|
485
|
+
res.end(JSON.stringify({ error: "invalid_grant", error_description: "Invalid or expired code" }));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Check code age (10 minute expiry)
|
|
490
|
+
if (Date.now() - pendingData.createdAt > 600000) {
|
|
491
|
+
pendingCodes.delete(code);
|
|
492
|
+
res.statusCode = 400;
|
|
493
|
+
res.end(JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Validate client_id matches the one used in authorize request
|
|
498
|
+
// This prevents code leakage from being used by a different client
|
|
499
|
+
if (pendingData.clientId !== clientId) {
|
|
500
|
+
pendingCodes.delete(code);
|
|
501
|
+
res.statusCode = 400;
|
|
502
|
+
res.end(JSON.stringify({ error: "invalid_client", error_description: "client_id mismatch" }));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Validate redirect_uri matches the one used in authorize request
|
|
507
|
+
// This prevents code interception from being used on a different redirect URI
|
|
508
|
+
if (pendingData.redirectUri !== redirectUri) {
|
|
509
|
+
pendingCodes.delete(code);
|
|
510
|
+
res.statusCode = 400;
|
|
511
|
+
res.end(JSON.stringify({ error: "invalid_grant", error_description: "redirect_uri mismatch" }));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// TODO: Validate PKCE code_verifier against code_challenge
|
|
516
|
+
// For now, skip validation (local bridge, trusted)
|
|
517
|
+
|
|
518
|
+
// Clean up expired tokens periodically to prevent unbounded growth
|
|
519
|
+
cleanupExpiredTokens();
|
|
520
|
+
|
|
521
|
+
// Generate access token
|
|
522
|
+
const accessToken = generateToken();
|
|
523
|
+
tokenStore.set(accessToken, {
|
|
524
|
+
sessionId: pendingData.sessionId,
|
|
525
|
+
backendUrl: pendingData.backendUrl,
|
|
526
|
+
createdAt: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Clean up pending code
|
|
530
|
+
pendingCodes.delete(code);
|
|
531
|
+
|
|
532
|
+
res.setHeader("Content-Type", "application/json");
|
|
533
|
+
res.end(JSON.stringify({
|
|
534
|
+
access_token: accessToken,
|
|
535
|
+
token_type: "Bearer",
|
|
536
|
+
expires_in: 86400, // 24 hours
|
|
537
|
+
scope: "mcp",
|
|
538
|
+
}));
|
|
539
|
+
} catch (err) {
|
|
540
|
+
debugLog("[ctxce] /oauth/token error: " + String(err));
|
|
541
|
+
res.statusCode = 400;
|
|
542
|
+
res.setHeader("Content-Type", "application/json");
|
|
543
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Validate Bearer token and return session info
|
|
550
|
+
* @param {string} token - Bearer token
|
|
551
|
+
* @returns {{sessionId: string, backendUrl: string} | null}
|
|
552
|
+
*/
|
|
553
|
+
export function validateBearerToken(token) {
|
|
554
|
+
const tokenData = tokenStore.get(token);
|
|
555
|
+
if (!tokenData) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Check token age (24 hour expiry)
|
|
560
|
+
const tokenAge = Date.now() - tokenData.createdAt;
|
|
561
|
+
if (tokenAge > 86400000) {
|
|
562
|
+
tokenStore.delete(token);
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
sessionId: tokenData.sessionId,
|
|
568
|
+
backendUrl: tokenData.backendUrl,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Check if a given pathname is an OAuth endpoint
|
|
574
|
+
* @param {string} pathname - URL pathname
|
|
575
|
+
* @returns {boolean}
|
|
576
|
+
*/
|
|
577
|
+
export function isOAuthEndpoint(pathname) {
|
|
578
|
+
return (
|
|
579
|
+
pathname === "/.well-known/oauth-authorization-server" ||
|
|
580
|
+
pathname === "/oauth/register" ||
|
|
581
|
+
pathname === "/oauth/authorize" ||
|
|
582
|
+
pathname === "/oauth/store-session" ||
|
|
583
|
+
pathname === "/oauth/token"
|
|
584
|
+
);
|
|
585
|
+
}
|