@context-engine-bridge/context-engine-mcp-bridge 0.0.13 → 0.0.15
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/AGENTS.md +18 -0
- package/package.json +1 -1
- package/src/authConfig.js +1 -1
- package/src/mcpServer.js +175 -44
- package/src/oauthHandler.js +82 -13
- package/src/resultPathMapping.js +2 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- Parent: ../AGENTS.md -->
|
|
2
|
+
# MCP Bridge CLI (ctxce)
|
|
3
|
+
|
|
4
|
+
Node package that exposes indexer+memory as a single MCP server (stdio + HTTP). Optional OAuth/PKCE.
|
|
5
|
+
|
|
6
|
+
## WHERE TO LOOK
|
|
7
|
+
|
|
8
|
+
| Task | Location | Notes |
|
|
9
|
+
|------|----------|-------|
|
|
10
|
+
| CLI entry | `ctx-mcp-bridge/bin/ctxce.js` | wrapper -> src/cli |
|
|
11
|
+
| CLI logic | `ctx-mcp-bridge/src/cli.js` | command parsing |
|
|
12
|
+
| MCP proxy | `ctx-mcp-bridge/src/mcpServer.js` | forwards tools |
|
|
13
|
+
| OAuth/PKCE | `ctx-mcp-bridge/src/oauthHandler.js` | auth flow |
|
|
14
|
+
| Path mapping | `ctx-mcp-bridge/src/resultPathMapping.js` | local <-> remote |
|
|
15
|
+
|
|
16
|
+
## KNOWN TODO
|
|
17
|
+
|
|
18
|
+
- PKCE verifier validation is still TODO in `ctx-mcp-bridge/src/oauthHandler.js`.
|
package/package.json
CHANGED
package/src/authConfig.js
CHANGED
package/src/mcpServer.js
CHANGED
|
@@ -9,7 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
9
9
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
10
10
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
11
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
-
import { loadAnyAuthEntry, loadAuthEntry } from "./authConfig.js";
|
|
12
|
+
import { loadAnyAuthEntry, loadAuthEntry, readConfig, saveAuthEntry } from "./authConfig.js";
|
|
13
13
|
import { maybeRemapToolArgs, maybeRemapToolResult } from "./resultPathMapping.js";
|
|
14
14
|
import * as oauthHandler from "./oauthHandler.js";
|
|
15
15
|
|
|
@@ -63,11 +63,7 @@ async function listMemoryTools(client) {
|
|
|
63
63
|
return [];
|
|
64
64
|
}
|
|
65
65
|
try {
|
|
66
|
-
const remote = await
|
|
67
|
-
client.listTools(),
|
|
68
|
-
5000,
|
|
69
|
-
"memory tools/list",
|
|
70
|
-
);
|
|
66
|
+
const remote = await client.listTools();
|
|
71
67
|
return Array.isArray(remote?.tools) ? remote.tools.slice() : [];
|
|
72
68
|
} catch (err) {
|
|
73
69
|
debugLog("[ctxce] Error calling memory tools/list: " + String(err));
|
|
@@ -113,15 +109,15 @@ function getBridgeToolTimeoutMs() {
|
|
|
113
109
|
try {
|
|
114
110
|
const raw = process.env.CTXCE_TOOL_TIMEOUT_MSEC;
|
|
115
111
|
if (!raw) {
|
|
116
|
-
return
|
|
112
|
+
return 600000; // 10 minutes default for remote operations
|
|
117
113
|
}
|
|
118
114
|
const parsed = Number.parseInt(String(raw), 10);
|
|
119
115
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
120
|
-
return
|
|
116
|
+
return 600000;
|
|
121
117
|
}
|
|
122
118
|
return parsed;
|
|
123
119
|
} catch {
|
|
124
|
-
return
|
|
120
|
+
return 600000;
|
|
125
121
|
}
|
|
126
122
|
}
|
|
127
123
|
|
|
@@ -130,7 +126,7 @@ function selectClientForTool(name, indexerClient, memoryClient) {
|
|
|
130
126
|
return indexerClient;
|
|
131
127
|
}
|
|
132
128
|
const lowered = name.toLowerCase();
|
|
133
|
-
if (memoryClient && (lowered.startsWith("memory.") || lowered.startsWith("mcp_memory_")
|
|
129
|
+
if (memoryClient && (lowered.startsWith("memory.") || lowered.startsWith("mcp_memory_"))) {
|
|
134
130
|
return memoryClient;
|
|
135
131
|
}
|
|
136
132
|
return indexerClient;
|
|
@@ -155,11 +151,34 @@ function isSessionError(error) {
|
|
|
155
151
|
}
|
|
156
152
|
}
|
|
157
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Detect actual backend auth rejection (from mcp_auth.py ValidationError).
|
|
156
|
+
* These indicate the session is truly invalid on the backend, not just
|
|
157
|
+
* an MCP SDK transport issue that can be fixed by reinit.
|
|
158
|
+
*/
|
|
159
|
+
function isAuthRejectionError(error) {
|
|
160
|
+
try {
|
|
161
|
+
const msg =
|
|
162
|
+
(error && typeof error.message === "string" && error.message) ||
|
|
163
|
+
(typeof error === "string" ? error : String(error || ""));
|
|
164
|
+
if (!msg) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return (
|
|
168
|
+
msg.includes("Invalid or expired session") ||
|
|
169
|
+
msg.includes("Missing session for authorized operation") ||
|
|
170
|
+
msg.includes("Not authenticated")
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
158
177
|
function getBridgeRetryAttempts() {
|
|
159
178
|
try {
|
|
160
179
|
const raw = process.env.CTXCE_TOOL_RETRY_ATTEMPTS;
|
|
161
180
|
if (!raw) {
|
|
162
|
-
return
|
|
181
|
+
return 3; // 3 attempts for better reliability on remote
|
|
163
182
|
}
|
|
164
183
|
const parsed = Number.parseInt(String(raw), 10);
|
|
165
184
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
@@ -167,7 +186,7 @@ function getBridgeRetryAttempts() {
|
|
|
167
186
|
}
|
|
168
187
|
return parsed;
|
|
169
188
|
} catch {
|
|
170
|
-
return
|
|
189
|
+
return 3;
|
|
171
190
|
}
|
|
172
191
|
}
|
|
173
192
|
|
|
@@ -175,7 +194,7 @@ function getBridgeRetryDelayMs() {
|
|
|
175
194
|
try {
|
|
176
195
|
const raw = process.env.CTXCE_TOOL_RETRY_DELAY_MSEC;
|
|
177
196
|
if (!raw) {
|
|
178
|
-
return
|
|
197
|
+
return 1000; // 1 second delay between retries for remote
|
|
179
198
|
}
|
|
180
199
|
const parsed = Number.parseInt(String(raw), 10);
|
|
181
200
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
@@ -183,7 +202,7 @@ function getBridgeRetryDelayMs() {
|
|
|
183
202
|
}
|
|
184
203
|
return parsed;
|
|
185
204
|
} catch {
|
|
186
|
-
return
|
|
205
|
+
return 1000;
|
|
187
206
|
}
|
|
188
207
|
}
|
|
189
208
|
|
|
@@ -255,6 +274,15 @@ const ADMIN_SESSION_COOKIE_NAME = "ctxce_session";
|
|
|
255
274
|
const SLUGGED_REPO_RE = /.+-[0-9a-f]{16}(?:_old)?$/i;
|
|
256
275
|
const BRIDGE_STATE_TOKEN = (process.env.CTXCE_BRIDGE_STATE_TOKEN || "").trim();
|
|
257
276
|
|
|
277
|
+
function getHostname(candidate) {
|
|
278
|
+
try {
|
|
279
|
+
const url = new URL(candidate);
|
|
280
|
+
return url.hostname;
|
|
281
|
+
} catch {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
258
286
|
function normalizeBackendUrl(candidate) {
|
|
259
287
|
const trimmed = (candidate || "").trim();
|
|
260
288
|
if (!trimmed) {
|
|
@@ -271,11 +299,35 @@ function normalizeBackendUrl(candidate) {
|
|
|
271
299
|
return trimmed.replace(/\/+$/, "");
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
function resolveAuthBackendContext() {
|
|
302
|
+
function resolveAuthBackendContext(indexerUrl, memoryUrl) {
|
|
275
303
|
const envBackend = normalizeBackendUrl(process.env.CTXCE_AUTH_BACKEND_URL || "");
|
|
276
304
|
if (envBackend) {
|
|
277
305
|
return { backendUrl: envBackend, source: "CTXCE_AUTH_BACKEND_URL" };
|
|
278
306
|
}
|
|
307
|
+
|
|
308
|
+
// If no env override, try to find a saved session.
|
|
309
|
+
// We prefer one that matches the host of indexer or memory URL if they look like backend roots.
|
|
310
|
+
const targetHosts = new Set();
|
|
311
|
+
const ih = getHostname(indexerUrl);
|
|
312
|
+
if (ih) {
|
|
313
|
+
targetHosts.add(ih);
|
|
314
|
+
}
|
|
315
|
+
const mh = getHostname(memoryUrl);
|
|
316
|
+
if (mh) {
|
|
317
|
+
targetHosts.add(mh);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (targetHosts.size > 0) {
|
|
321
|
+
const all = readConfig();
|
|
322
|
+
const backends = Object.keys(all);
|
|
323
|
+
for (const backendUrl of backends) {
|
|
324
|
+
const bh = getHostname(backendUrl);
|
|
325
|
+
if (bh && targetHosts.has(bh)) {
|
|
326
|
+
return { backendUrl, source: "auth_entry_host_match" };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
279
331
|
try {
|
|
280
332
|
const any = loadAnyAuthEntry();
|
|
281
333
|
const stored = normalizeBackendUrl(any?.backendUrl || "");
|
|
@@ -288,32 +340,22 @@ function resolveAuthBackendContext() {
|
|
|
288
340
|
return { backendUrl: "", source: "" };
|
|
289
341
|
}
|
|
290
342
|
|
|
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
343
|
async function fetchBridgeCollectionState({
|
|
305
344
|
workspace,
|
|
306
345
|
collection,
|
|
307
346
|
sessionId,
|
|
308
347
|
repoName,
|
|
309
348
|
bridgeStateToken,
|
|
349
|
+
backendHint,
|
|
350
|
+
uploadServiceUrl,
|
|
310
351
|
}) {
|
|
311
352
|
try {
|
|
312
|
-
if (!
|
|
353
|
+
if (!uploadServiceUrl) {
|
|
313
354
|
debugLog("[ctxce] Skipping bridge/state fetch: no upload endpoint configured.");
|
|
314
355
|
return null;
|
|
315
356
|
}
|
|
316
|
-
const url = new URL("/bridge/state",
|
|
357
|
+
const url = new URL("/bridge/state", uploadServiceUrl);
|
|
358
|
+
// ...
|
|
317
359
|
if (collection && collection.trim()) {
|
|
318
360
|
url.searchParams.set("collection", collection.trim());
|
|
319
361
|
} else if (workspace && workspace.trim()) {
|
|
@@ -341,8 +383,18 @@ async function fetchBridgeCollectionState({
|
|
|
341
383
|
if (!resp.ok) {
|
|
342
384
|
if (resp.status === 401 || resp.status === 403) {
|
|
343
385
|
debugLog(
|
|
344
|
-
`[ctxce] /bridge/state responded ${resp.status}; missing or invalid token/session,
|
|
386
|
+
`[ctxce] /bridge/state responded ${resp.status}; missing or invalid token/session, marking local session as expired.`,
|
|
345
387
|
);
|
|
388
|
+
if (backendHint) {
|
|
389
|
+
try {
|
|
390
|
+
const entry = loadAuthEntry(backendHint);
|
|
391
|
+
if (entry) {
|
|
392
|
+
saveAuthEntry(backendHint, { ...entry, expiresAt: 1 });
|
|
393
|
+
}
|
|
394
|
+
} catch {
|
|
395
|
+
// ignore failures
|
|
396
|
+
}
|
|
397
|
+
}
|
|
346
398
|
return null;
|
|
347
399
|
}
|
|
348
400
|
throw new Error(`bridge/state responded ${resp.status}`);
|
|
@@ -389,9 +441,23 @@ async function createBridgeServer(options) {
|
|
|
389
441
|
// future this can be made user-aware (e.g. from auth), but for now we
|
|
390
442
|
// keep it deterministic per workspace to help the indexer reuse
|
|
391
443
|
// session-scoped defaults.
|
|
444
|
+
const {
|
|
445
|
+
backendUrl: authBackendUrl,
|
|
446
|
+
source: authBackendSource,
|
|
447
|
+
} = resolveAuthBackendContext(indexerUrl, memoryUrl);
|
|
448
|
+
|
|
449
|
+
const uploadServiceUrl = authBackendUrl;
|
|
450
|
+
const uploadAuthBackend = authBackendUrl;
|
|
451
|
+
|
|
452
|
+
if (uploadServiceUrl) {
|
|
453
|
+
debugLog(`[ctxce] Upload/auth backend resolved from ${authBackendSource}: ${uploadServiceUrl}`);
|
|
454
|
+
} else {
|
|
455
|
+
debugLog("[ctxce] No auth backend detected; bridge/state overrides disabled.");
|
|
456
|
+
}
|
|
457
|
+
|
|
392
458
|
const explicitSession = process.env.CTXCE_SESSION_ID || "";
|
|
393
459
|
const authBackendEnv = (process.env.CTXCE_AUTH_BACKEND_URL || "").trim();
|
|
394
|
-
let backendHint = authBackendEnv ||
|
|
460
|
+
let backendHint = authBackendEnv || uploadAuthBackend || "";
|
|
395
461
|
let sessionId = explicitSession;
|
|
396
462
|
|
|
397
463
|
function sessionFromEntry(entry) {
|
|
@@ -446,7 +512,7 @@ async function createBridgeServer(options) {
|
|
|
446
512
|
if (explicit) {
|
|
447
513
|
return explicit;
|
|
448
514
|
}
|
|
449
|
-
return findSavedSession([backendHint,
|
|
515
|
+
return findSavedSession([backendHint, uploadAuthBackend, authBackendEnv]);
|
|
450
516
|
}
|
|
451
517
|
|
|
452
518
|
if (!sessionId) {
|
|
@@ -473,6 +539,8 @@ async function createBridgeServer(options) {
|
|
|
473
539
|
sessionId,
|
|
474
540
|
repoName,
|
|
475
541
|
bridgeStateToken: BRIDGE_STATE_TOKEN,
|
|
542
|
+
backendHint,
|
|
543
|
+
uploadServiceUrl,
|
|
476
544
|
});
|
|
477
545
|
if (state) {
|
|
478
546
|
const serving = state.serving_collection || state.active_collection;
|
|
@@ -502,10 +570,23 @@ async function createBridgeServer(options) {
|
|
|
502
570
|
}
|
|
503
571
|
|
|
504
572
|
if (forceRecreate) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
573
|
+
debugLog("[ctxce] Reinitializing remote MCP clients after session error.");
|
|
574
|
+
|
|
575
|
+
if (indexerClient) {
|
|
576
|
+
try {
|
|
577
|
+
await indexerClient.close();
|
|
578
|
+
} catch {
|
|
579
|
+
// ignore close errors
|
|
580
|
+
}
|
|
581
|
+
indexerClient = null;
|
|
582
|
+
}
|
|
583
|
+
if (memoryClient) {
|
|
584
|
+
try {
|
|
585
|
+
await memoryClient.close();
|
|
586
|
+
} catch {
|
|
587
|
+
// ignore close errors
|
|
588
|
+
}
|
|
589
|
+
memoryClient = null;
|
|
509
590
|
}
|
|
510
591
|
}
|
|
511
592
|
|
|
@@ -592,11 +673,7 @@ async function createBridgeServer(options) {
|
|
|
592
673
|
if (!indexerClient) {
|
|
593
674
|
throw new Error("Indexer MCP client not initialized");
|
|
594
675
|
}
|
|
595
|
-
remote = await
|
|
596
|
-
indexerClient.listTools(),
|
|
597
|
-
10000,
|
|
598
|
-
"indexer tools/list",
|
|
599
|
-
);
|
|
676
|
+
remote = await indexerClient.listTools();
|
|
600
677
|
} catch (err) {
|
|
601
678
|
debugLog("[ctxce] Error calling remote tools/list: " + String(err));
|
|
602
679
|
const memoryToolsFallback = await listMemoryTools(memoryClient);
|
|
@@ -695,20 +772,38 @@ async function createBridgeServer(options) {
|
|
|
695
772
|
if (isSessionError(err) && !sessionRetried) {
|
|
696
773
|
debugLog(
|
|
697
774
|
"[ctxce] tools/call: detected remote MCP session error; reinitializing clients and retrying once: " +
|
|
698
|
-
|
|
775
|
+
String(err),
|
|
699
776
|
);
|
|
700
777
|
await initializeRemoteClients(true);
|
|
701
778
|
sessionRetried = true;
|
|
702
779
|
continue;
|
|
703
780
|
}
|
|
704
781
|
|
|
782
|
+
// Backend auth rejection (mcp_auth.py ValidationError) - expire local auth
|
|
783
|
+
if (isAuthRejectionError(err)) {
|
|
784
|
+
debugLog(
|
|
785
|
+
"[ctxce] tools/call: backend auth rejection; marking local session as expired: " +
|
|
786
|
+
String(err),
|
|
787
|
+
);
|
|
788
|
+
if (backendHint) {
|
|
789
|
+
try {
|
|
790
|
+
const entry = loadAuthEntry(backendHint);
|
|
791
|
+
if (entry) {
|
|
792
|
+
saveAuthEntry(backendHint, { ...entry, expiresAt: 1 });
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
// ignore failures
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
705
800
|
if (!isTransientToolError(err) || attempt === maxAttempts - 1) {
|
|
706
801
|
throw err;
|
|
707
802
|
}
|
|
708
803
|
|
|
709
804
|
debugLog(
|
|
710
805
|
`[ctxce] tools/call: transient error (attempt ${attempt + 1}/${maxAttempts}), retrying: ` +
|
|
711
|
-
|
|
806
|
+
String(err),
|
|
712
807
|
);
|
|
713
808
|
// Loop will retry
|
|
714
809
|
}
|
|
@@ -830,11 +925,28 @@ export async function runHttpMcpServer(options) {
|
|
|
830
925
|
return;
|
|
831
926
|
}
|
|
832
927
|
|
|
928
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
833
929
|
let body = "";
|
|
930
|
+
let bodyLimitExceeded = false;
|
|
834
931
|
req.on("data", (chunk) => {
|
|
932
|
+
if (bodyLimitExceeded) return;
|
|
835
933
|
body += chunk;
|
|
934
|
+
if (body.length > MAX_BODY_SIZE) {
|
|
935
|
+
bodyLimitExceeded = true;
|
|
936
|
+
req.destroy();
|
|
937
|
+
res.statusCode = 413;
|
|
938
|
+
res.setHeader("Content-Type", "application/json");
|
|
939
|
+
res.end(
|
|
940
|
+
JSON.stringify({
|
|
941
|
+
jsonrpc: "2.0",
|
|
942
|
+
error: { code: -32000, message: "Request body too large" },
|
|
943
|
+
id: null,
|
|
944
|
+
}),
|
|
945
|
+
);
|
|
946
|
+
}
|
|
836
947
|
});
|
|
837
948
|
req.on("end", async () => {
|
|
949
|
+
if (bodyLimitExceeded) return;
|
|
838
950
|
let parsed;
|
|
839
951
|
try {
|
|
840
952
|
parsed = body ? JSON.parse(body) : {};
|
|
@@ -902,6 +1014,25 @@ export async function runHttpMcpServer(options) {
|
|
|
902
1014
|
httpServer.listen(port, '127.0.0.1', () => {
|
|
903
1015
|
debugLog(`[ctxce] HTTP MCP bridge listening on 127.0.0.1:${port}`);
|
|
904
1016
|
});
|
|
1017
|
+
|
|
1018
|
+
let shuttingDown = false;
|
|
1019
|
+
const shutdown = (signal) => {
|
|
1020
|
+
if (shuttingDown) return;
|
|
1021
|
+
shuttingDown = true;
|
|
1022
|
+
debugLog(`[ctxce] Received ${signal}; closing HTTP server (waiting for in-flight requests).`);
|
|
1023
|
+
httpServer.close(() => {
|
|
1024
|
+
debugLog("[ctxce] HTTP server closed.");
|
|
1025
|
+
process.exit(0);
|
|
1026
|
+
});
|
|
1027
|
+
const SHUTDOWN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for long MCP calls
|
|
1028
|
+
setTimeout(() => {
|
|
1029
|
+
debugLog("[ctxce] Forcing exit after shutdown timeout.");
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}, SHUTDOWN_TIMEOUT_MS).unref();
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1035
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
905
1036
|
}
|
|
906
1037
|
|
|
907
1038
|
function loadConfig(startDir) {
|
package/src/oauthHandler.js
CHANGED
|
@@ -16,24 +16,97 @@ const pendingCodes = new Map();
|
|
|
16
16
|
// Maps client_id to client info
|
|
17
17
|
const registeredClients = new Map();
|
|
18
18
|
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Storage Limits and Cleanup Configuration
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const MAX_TOKEN_STORE_SIZE = 10000;
|
|
24
|
+
const MAX_PENDING_CODES_SIZE = 1000;
|
|
25
|
+
const MAX_REGISTERED_CLIENTS_SIZE = 1000;
|
|
26
|
+
const TOKEN_EXPIRY_MS = 86400000; // 24 hours
|
|
27
|
+
const CODE_EXPIRY_MS = 600000; // 10 minutes
|
|
28
|
+
const CLIENT_EXPIRY_MS = 7 * 86400000; // 7 days
|
|
29
|
+
const CLEANUP_INTERVAL_MS = 300000; // 5 minutes
|
|
30
|
+
|
|
31
|
+
// Cleanup interval reference (for cleanup on shutdown if needed)
|
|
32
|
+
let cleanupIntervalId = null;
|
|
33
|
+
|
|
19
34
|
// ============================================================================
|
|
20
35
|
// OAuth Utilities
|
|
21
36
|
// ============================================================================
|
|
22
37
|
|
|
23
|
-
/**
|
|
24
|
-
* Clean up expired tokens from tokenStore
|
|
25
|
-
* Called periodically to prevent unbounded memory growth
|
|
26
|
-
*/
|
|
27
38
|
function cleanupExpiredTokens() {
|
|
28
39
|
const now = Date.now();
|
|
29
|
-
const expiryMs = 86400000; // 24 hours
|
|
30
40
|
for (const [token, data] of tokenStore.entries()) {
|
|
31
|
-
if (now - data.createdAt >
|
|
41
|
+
if (now - data.createdAt > TOKEN_EXPIRY_MS) {
|
|
32
42
|
tokenStore.delete(token);
|
|
33
43
|
}
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
function cleanupExpiredCodes() {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
for (const [code, data] of pendingCodes.entries()) {
|
|
50
|
+
if (now - data.createdAt > CODE_EXPIRY_MS) {
|
|
51
|
+
pendingCodes.delete(code);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanupExpiredClients() {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [clientId, data] of registeredClients.entries()) {
|
|
59
|
+
if (now - data.createdAt > CLIENT_EXPIRY_MS) {
|
|
60
|
+
registeredClients.delete(clientId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function enforceStorageLimits() {
|
|
66
|
+
if (tokenStore.size > MAX_TOKEN_STORE_SIZE) {
|
|
67
|
+
const entries = [...tokenStore.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
68
|
+
const toRemove = entries.slice(0, tokenStore.size - MAX_TOKEN_STORE_SIZE);
|
|
69
|
+
for (const [key] of toRemove) {
|
|
70
|
+
tokenStore.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (pendingCodes.size > MAX_PENDING_CODES_SIZE) {
|
|
74
|
+
const entries = [...pendingCodes.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
75
|
+
const toRemove = entries.slice(0, pendingCodes.size - MAX_PENDING_CODES_SIZE);
|
|
76
|
+
for (const [key] of toRemove) {
|
|
77
|
+
pendingCodes.delete(key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (registeredClients.size > MAX_REGISTERED_CLIENTS_SIZE) {
|
|
81
|
+
const entries = [...registeredClients.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
82
|
+
const toRemove = entries.slice(0, registeredClients.size - MAX_REGISTERED_CLIENTS_SIZE);
|
|
83
|
+
for (const [key] of toRemove) {
|
|
84
|
+
registeredClients.delete(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runPeriodicCleanup() {
|
|
90
|
+
cleanupExpiredTokens();
|
|
91
|
+
cleanupExpiredCodes();
|
|
92
|
+
cleanupExpiredClients();
|
|
93
|
+
enforceStorageLimits();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function startCleanupInterval() {
|
|
97
|
+
if (!cleanupIntervalId) {
|
|
98
|
+
cleanupIntervalId = setInterval(runPeriodicCleanup, CLEANUP_INTERVAL_MS);
|
|
99
|
+
cleanupIntervalId.unref?.();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function stopCleanupInterval() {
|
|
104
|
+
if (cleanupIntervalId) {
|
|
105
|
+
clearInterval(cleanupIntervalId);
|
|
106
|
+
cleanupIntervalId = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
37
110
|
function generateToken() {
|
|
38
111
|
return randomBytes(32).toString("hex");
|
|
39
112
|
}
|
|
@@ -545,20 +618,14 @@ export function handleOAuthToken(req, res) {
|
|
|
545
618
|
});
|
|
546
619
|
}
|
|
547
620
|
|
|
548
|
-
/**
|
|
549
|
-
* Validate Bearer token and return session info
|
|
550
|
-
* @param {string} token - Bearer token
|
|
551
|
-
* @returns {{sessionId: string, backendUrl: string} | null}
|
|
552
|
-
*/
|
|
553
621
|
export function validateBearerToken(token) {
|
|
554
622
|
const tokenData = tokenStore.get(token);
|
|
555
623
|
if (!tokenData) {
|
|
556
624
|
return null;
|
|
557
625
|
}
|
|
558
626
|
|
|
559
|
-
// Check token age (24 hour expiry)
|
|
560
627
|
const tokenAge = Date.now() - tokenData.createdAt;
|
|
561
|
-
if (tokenAge >
|
|
628
|
+
if (tokenAge > TOKEN_EXPIRY_MS) {
|
|
562
629
|
tokenStore.delete(token);
|
|
563
630
|
return null;
|
|
564
631
|
}
|
|
@@ -583,3 +650,5 @@ export function isOAuthEndpoint(pathname) {
|
|
|
583
650
|
pathname === "/oauth/token"
|
|
584
651
|
);
|
|
585
652
|
}
|
|
653
|
+
|
|
654
|
+
startCleanupInterval();
|