@context-engine-bridge/context-engine-mcp-bridge 0.0.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
5
5
  "bin": {
6
6
  "ctxce": "bin/ctxce.js",
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
- if (!req.url || !req.url.startsWith("/mcp")) {
622
- res.statusCode = 404;
623
- res.setHeader("Content-Type", "application/json");
624
- res.end(
625
- JSON.stringify({
626
- jsonrpc: "2.0",
627
- error: { code: -32000, message: "Not found" },
628
- id: null,
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
- if (req.method !== "POST") {
635
- res.statusCode = 405;
636
- res.setHeader("Content-Type", "application/json");
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
- let body = "";
648
- req.on("data", (chunk) => {
649
- body += chunk;
650
- });
651
- req.on("end", async () => {
652
- let parsed;
653
- try {
654
- parsed = body ? JSON.parse(body) : {};
655
- } catch (err) {
656
- debugLog("[ctxce] Failed to parse HTTP MCP request body: " + String(err));
657
- res.statusCode = 400;
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: -32700, message: "Invalid JSON" },
683
+ error: { code: -32000, message: "Method not allowed" },
663
684
  id: null,
664
685
  }),
665
686
  );
666
687
  return;
667
688
  }
668
689
 
669
- try {
670
- await transport.handleRequest(req, res, parsed);
671
- } catch (err) {
672
- debugLog("[ctxce] Error handling HTTP MCP request: " + String(err));
673
- if (!res.headersSent) {
674
- res.statusCode = 500;
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: -32603, message: "Internal server error" },
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
- httpServer.listen(port, () => {
703
- debugLog(`[ctxce] HTTP MCP bridge listening on port ${port}`);
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
+ }