@context-engine-bridge/context-engine-mcp-bridge 0.0.10 → 0.0.12

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.12",
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 {
@@ -250,6 +251,111 @@ function isTransientToolError(error) {
250
251
  // Acts as a low-level proxy for tools, forwarding tools/list and tools/call
251
252
  // to the remote qdrant-indexer MCP server while adding a local `ping` tool.
252
253
 
254
+ const ADMIN_SESSION_COOKIE_NAME = "ctxce_session";
255
+ const SLUGGED_REPO_RE = /.+-[0-9a-f]{16}(?:_old)?$/i;
256
+ const BRIDGE_STATE_TOKEN = (process.env.CTXCE_BRIDGE_STATE_TOKEN || "").trim();
257
+
258
+ function normalizeBackendUrl(candidate) {
259
+ const trimmed = (candidate || "").trim();
260
+ if (!trimmed) {
261
+ return "";
262
+ }
263
+ try {
264
+ const parsed = new URL(trimmed);
265
+ if (parsed.protocol && parsed.host) {
266
+ return `${parsed.protocol}//${parsed.host}`;
267
+ }
268
+ } catch {
269
+ // ignore parse failures
270
+ }
271
+ return trimmed.replace(/\/+$/, "");
272
+ }
273
+
274
+ function resolveAuthBackendContext() {
275
+ const envBackend = normalizeBackendUrl(process.env.CTXCE_AUTH_BACKEND_URL || "");
276
+ if (envBackend) {
277
+ return { backendUrl: envBackend, source: "CTXCE_AUTH_BACKEND_URL" };
278
+ }
279
+ try {
280
+ const any = loadAnyAuthEntry();
281
+ const stored = normalizeBackendUrl(any?.backendUrl || "");
282
+ if (stored) {
283
+ return { backendUrl: stored, source: "auth_entry" };
284
+ }
285
+ } catch {
286
+ // ignore auth config read failures
287
+ }
288
+ return { backendUrl: "", source: "" };
289
+ }
290
+
291
+ const {
292
+ backendUrl: AUTH_BACKEND_URL,
293
+ source: AUTH_BACKEND_SOURCE,
294
+ } = resolveAuthBackendContext();
295
+ const UPLOAD_SERVICE_URL = AUTH_BACKEND_URL;
296
+ const UPLOAD_AUTH_BACKEND = AUTH_BACKEND_URL;
297
+
298
+ if (UPLOAD_SERVICE_URL) {
299
+ debugLog(`[ctxce] Upload/auth backend resolved from ${AUTH_BACKEND_SOURCE}: ${UPLOAD_SERVICE_URL}`);
300
+ } else {
301
+ debugLog("[ctxce] No auth backend detected; bridge/state overrides disabled.");
302
+ }
303
+
304
+ async function fetchBridgeCollectionState({
305
+ workspace,
306
+ collection,
307
+ sessionId,
308
+ repoName,
309
+ bridgeStateToken,
310
+ }) {
311
+ try {
312
+ if (!UPLOAD_SERVICE_URL) {
313
+ debugLog("[ctxce] Skipping bridge/state fetch: no upload endpoint configured.");
314
+ return null;
315
+ }
316
+ const url = new URL("/bridge/state", UPLOAD_SERVICE_URL);
317
+ if (collection && collection.trim()) {
318
+ url.searchParams.set("collection", collection.trim());
319
+ } else if (workspace && workspace.trim()) {
320
+ url.searchParams.set("workspace", workspace.trim());
321
+ }
322
+ if (repoName && repoName.trim()) {
323
+ url.searchParams.set("repo_name", repoName.trim());
324
+ }
325
+
326
+ const headers = {
327
+ Accept: "application/json",
328
+ };
329
+ if (bridgeStateToken && bridgeStateToken.trim()) {
330
+ headers["X-Bridge-State-Token"] = bridgeStateToken.trim();
331
+ }
332
+ if (sessionId) {
333
+ headers.Cookie = `${ADMIN_SESSION_COOKIE_NAME}=${sessionId}`;
334
+ }
335
+
336
+ debugLog(`[ctxce] Fetching bridge/state from ${url.toString()} (repo=${repoName || "<none>"}).`);
337
+ const resp = await fetch(url, {
338
+ method: "GET",
339
+ headers,
340
+ });
341
+ if (!resp.ok) {
342
+ if (resp.status === 401 || resp.status === 403) {
343
+ debugLog(
344
+ `[ctxce] /bridge/state responded ${resp.status}; missing or invalid token/session, falling back to ctx_config defaults.`,
345
+ );
346
+ return null;
347
+ }
348
+ throw new Error(`bridge/state responded ${resp.status}`);
349
+ }
350
+ debugLog(`[ctxce] bridge/state responded ${resp.status}`);
351
+ const data = await resp.json();
352
+ return data && typeof data === "object" ? data : null;
353
+ } catch (err) {
354
+ debugLog("[ctxce] Failed to fetch /bridge/state: " + String(err));
355
+ return null;
356
+ }
357
+ }
358
+
253
359
  async function createBridgeServer(options) {
254
360
  const workspace = options.workspace || process.cwd();
255
361
  const indexerUrl = options.indexerUrl;
@@ -284,53 +390,65 @@ async function createBridgeServer(options) {
284
390
  // keep it deterministic per workspace to help the indexer reuse
285
391
  // session-scoped defaults.
286
392
  const explicitSession = process.env.CTXCE_SESSION_ID || "";
287
- const authBackendUrl = process.env.CTXCE_AUTH_BACKEND_URL || "";
393
+ const authBackendEnv = (process.env.CTXCE_AUTH_BACKEND_URL || "").trim();
394
+ let backendHint = authBackendEnv || UPLOAD_AUTH_BACKEND || "";
288
395
  let sessionId = explicitSession;
289
396
 
290
- function resolveSessionId() {
291
- const explicit = process.env.CTXCE_SESSION_ID || "";
292
- if (explicit) {
293
- return explicit;
397
+ function sessionFromEntry(entry) {
398
+ if (!entry || typeof entry.sessionId !== "string" || !entry.sessionId) {
399
+ return "";
294
400
  }
295
- let backendToUse = authBackendUrl;
296
- let entry = null;
297
- if (backendToUse) {
298
- try {
299
- entry = loadAuthEntry(backendToUse);
300
- } catch {
301
- entry = null;
302
- }
401
+ const expiresAt = entry.expiresAt;
402
+ if (
403
+ typeof expiresAt === "number" &&
404
+ Number.isFinite(expiresAt) &&
405
+ expiresAt > 0 &&
406
+ expiresAt < Math.floor(Date.now() / 1000)
407
+ ) {
408
+ debugLog("[ctxce] Stored auth session appears expired; please run `ctxce auth login` again.");
409
+ return "";
303
410
  }
304
- if (!entry) {
411
+ return entry.sessionId;
412
+ }
413
+
414
+ function findSavedSession(backends) {
415
+ for (const backend of backends) {
416
+ const trimmed = (backend || "").trim();
417
+ if (!trimmed) {
418
+ continue;
419
+ }
305
420
  try {
306
- const any = loadAnyAuthEntry();
307
- if (any && any.entry) {
308
- backendToUse = any.backendUrl;
309
- entry = any.entry;
421
+ const entry = loadAuthEntry(trimmed);
422
+ const session = sessionFromEntry(entry);
423
+ if (session) {
424
+ backendHint = trimmed;
425
+ return session;
310
426
  }
311
427
  } catch {
312
- entry = null;
428
+ // ignore lookup failures
313
429
  }
314
430
  }
315
- if (entry) {
316
- let expired = false;
317
- const rawExpires = entry.expiresAt;
318
- if (typeof rawExpires === "number" && Number.isFinite(rawExpires) && rawExpires > 0) {
319
- const nowSecs = Math.floor(Date.now() / 1000);
320
- if (rawExpires < nowSecs) {
321
- expired = true;
322
- }
323
- }
324
- if (!expired && typeof entry.sessionId === "string" && entry.sessionId) {
325
- return entry.sessionId;
326
- }
327
- if (expired) {
328
- debugLog("[ctxce] Stored auth session appears expired; please run `ctxce auth login` again.");
431
+ try {
432
+ const any = loadAnyAuthEntry();
433
+ const session = any ? sessionFromEntry(any.entry) : "";
434
+ if (session && any?.backendUrl) {
435
+ backendHint = any.backendUrl;
436
+ return session;
329
437
  }
438
+ } catch {
439
+ // ignore lookup failures
330
440
  }
331
441
  return "";
332
442
  }
333
443
 
444
+ function resolveSessionId() {
445
+ const explicit = (process.env.CTXCE_SESSION_ID || "").trim();
446
+ if (explicit) {
447
+ return explicit;
448
+ }
449
+ return findSavedSession([backendHint, UPLOAD_AUTH_BACKEND, authBackendEnv]);
450
+ }
451
+
334
452
  if (!sessionId) {
335
453
  sessionId = resolveSessionId();
336
454
  }
@@ -345,6 +463,32 @@ async function createBridgeServer(options) {
345
463
  if (defaultCollection) {
346
464
  defaultsPayload.collection = defaultCollection;
347
465
  }
466
+
467
+ const repoName = detectRepoName(workspace, config);
468
+
469
+ try {
470
+ const state = await fetchBridgeCollectionState({
471
+ workspace,
472
+ collection: defaultCollection,
473
+ sessionId,
474
+ repoName,
475
+ bridgeStateToken: BRIDGE_STATE_TOKEN,
476
+ });
477
+ if (state) {
478
+ const serving = state.serving_collection || state.active_collection;
479
+ if (serving) {
480
+ defaultsPayload.collection = serving;
481
+ if (!defaultCollection || defaultCollection !== serving) {
482
+ debugLog(
483
+ `[ctxce] Using serving collection from /bridge/state: ${serving}`,
484
+ );
485
+ }
486
+ }
487
+ }
488
+ } catch (err) {
489
+ debugLog("[ctxce] bridge/state lookup failed: " + String(err));
490
+ }
491
+
348
492
  if (defaultMode) {
349
493
  defaultsPayload.mode = defaultMode;
350
494
  }
@@ -616,73 +760,128 @@ export async function runHttpMcpServer(options) {
616
760
 
617
761
  await server.connect(transport);
618
762
 
763
+ // Build issuer URL for OAuth
764
+ // Note: Local-only bridge uses 127.0.0.1. For remote access, this would need to be configurable.
765
+ const issuerUrl = `http://127.0.0.1:${port}`;
766
+
619
767
  const httpServer = createServer((req, res) => {
620
768
  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
- );
769
+ const url = req.url || "/";
770
+
771
+ // Parse URL for query params
772
+ const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
773
+
774
+ // ================================================================
775
+ // OAuth 2.0 Endpoints (RFC9728 Protected Resource Metadata + RFC7591)
776
+ // ================================================================
777
+
778
+ // OAuth metadata endpoint (RFC9728)
779
+ if (parsedUrl.pathname === "/.well-known/oauth-authorization-server") {
780
+ oauthHandler.handleOAuthMetadata(req, res, issuerUrl);
631
781
  return;
632
782
  }
633
783
 
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
- );
784
+ // OAuth Dynamic Client Registration endpoint (RFC7591)
785
+ if (parsedUrl.pathname === "/oauth/register" && req.method === "POST") {
786
+ oauthHandler.handleOAuthRegister(req, res);
644
787
  return;
645
788
  }
646
789
 
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;
790
+ // OAuth authorize endpoint
791
+ if (parsedUrl.pathname === "/oauth/authorize") {
792
+ oauthHandler.handleOAuthAuthorize(req, res, parsedUrl.searchParams);
793
+ return;
794
+ }
795
+
796
+ // Store session endpoint (helper for login page)
797
+ if (parsedUrl.pathname === "/oauth/store-session" && req.method === "POST") {
798
+ oauthHandler.handleOAuthStoreSession(req, res);
799
+ return;
800
+ }
801
+
802
+ // OAuth token endpoint
803
+ if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
804
+ oauthHandler.handleOAuthToken(req, res);
805
+ return;
806
+ }
807
+
808
+ // ================================================================
809
+ // MCP Endpoint
810
+ // ================================================================
811
+
812
+ // Check Bearer token for MCP endpoint (accept /mcp and /mcp/ for compatibility)
813
+ if (parsedUrl.pathname === "/mcp" || parsedUrl.pathname === "/mcp/") {
814
+ const authHeader = req.headers["authorization"] || "";
815
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
816
+
817
+ // TODO: Validate token and inject session
818
+ // For now, allow unauthenticated (backward compatible)
819
+
820
+ if (req.method !== "POST") {
821
+ res.statusCode = 405;
658
822
  res.setHeader("Content-Type", "application/json");
659
823
  res.end(
660
824
  JSON.stringify({
661
825
  jsonrpc: "2.0",
662
- error: { code: -32700, message: "Invalid JSON" },
826
+ error: { code: -32000, message: "Method not allowed" },
663
827
  id: null,
664
828
  }),
665
829
  );
666
830
  return;
667
831
  }
668
832
 
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;
833
+ let body = "";
834
+ req.on("data", (chunk) => {
835
+ body += chunk;
836
+ });
837
+ req.on("end", async () => {
838
+ let parsed;
839
+ try {
840
+ parsed = body ? JSON.parse(body) : {};
841
+ } catch (err) {
842
+ debugLog("[ctxce] Failed to parse HTTP MCP request body: " + String(err));
843
+ res.statusCode = 400;
675
844
  res.setHeader("Content-Type", "application/json");
676
845
  res.end(
677
846
  JSON.stringify({
678
847
  jsonrpc: "2.0",
679
- error: { code: -32603, message: "Internal server error" },
848
+ error: { code: -32700, message: "Invalid JSON" },
680
849
  id: null,
681
850
  }),
682
851
  );
852
+ return;
683
853
  }
684
- }
685
- });
854
+
855
+ try {
856
+ await transport.handleRequest(req, res, parsed);
857
+ } catch (err) {
858
+ debugLog("[ctxce] Error handling HTTP MCP request: " + String(err));
859
+ if (!res.headersSent) {
860
+ res.statusCode = 500;
861
+ res.setHeader("Content-Type", "application/json");
862
+ res.end(
863
+ JSON.stringify({
864
+ jsonrpc: "2.0",
865
+ error: { code: -32603, message: "Internal server error" },
866
+ id: null,
867
+ }),
868
+ );
869
+ }
870
+ }
871
+ });
872
+ return;
873
+ }
874
+
875
+ // 404 for everything else
876
+ res.statusCode = 404;
877
+ res.setHeader("Content-Type", "application/json");
878
+ res.end(
879
+ JSON.stringify({
880
+ jsonrpc: "2.0",
881
+ error: { code: -32000, message: "Not found" },
882
+ id: null,
883
+ }),
884
+ );
686
885
  } catch (err) {
687
886
  debugLog("[ctxce] Unexpected error in HTTP MCP server: " + String(err));
688
887
  if (!res.headersSent) {
@@ -699,8 +898,9 @@ export async function runHttpMcpServer(options) {
699
898
  }
700
899
  });
701
900
 
702
- httpServer.listen(port, () => {
703
- debugLog(`[ctxce] HTTP MCP bridge listening on port ${port}`);
901
+ // Bind to 127.0.0.1 only (localhost) for local-only OAuth security
902
+ httpServer.listen(port, '127.0.0.1', () => {
903
+ debugLog(`[ctxce] HTTP MCP bridge listening on 127.0.0.1:${port}`);
704
904
  });
705
905
  }
706
906
 
@@ -748,3 +948,24 @@ function detectGitBranch(workspace) {
748
948
  }
749
949
  }
750
950
 
951
+ function detectRepoName(workspace, config) {
952
+ const envRepo =
953
+ (process.env.CURRENT_REPO && process.env.CURRENT_REPO.trim()) ||
954
+ (process.env.REPO_NAME && process.env.REPO_NAME.trim());
955
+ if (envRepo) {
956
+ return envRepo;
957
+ }
958
+
959
+ if (config) {
960
+ const cfgRepo =
961
+ (typeof config.repo_name === "string" && config.repo_name.trim()) ||
962
+ (typeof config.default_repo === "string" && config.default_repo.trim());
963
+ if (cfgRepo) {
964
+ return cfgRepo;
965
+ }
966
+ }
967
+
968
+ const leaf = workspace ? path.basename(workspace) : "";
969
+ return leaf && SLUGGED_REPO_RE.test(leaf) ? leaf : null;
970
+ }
971
+
@@ -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
+ }