@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 +1 -1
- package/src/mcpServer.js +297 -76
- 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 {
|
|
@@ -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
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return explicit;
|
|
397
|
+
function sessionFromEntry(entry) {
|
|
398
|
+
if (!entry || typeof entry.sessionId !== "string" || !entry.sessionId) {
|
|
399
|
+
return "";
|
|
294
400
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
428
|
+
// ignore lookup failures
|
|
313
429
|
}
|
|
314
430
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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: -
|
|
826
|
+
error: { code: -32000, message: "Method not allowed" },
|
|
663
827
|
id: null,
|
|
664
828
|
}),
|
|
665
829
|
);
|
|
666
830
|
return;
|
|
667
831
|
}
|
|
668
832
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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: -
|
|
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
|
-
|
|
703
|
-
|
|
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
|
+
}
|