@codemieai/code 0.0.54 → 0.0.55

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.
Files changed (53) hide show
  1. package/README.md +6 -0
  2. package/bin/codemie-mcp-proxy.js +91 -0
  3. package/dist/agents/core/BaseAgentAdapter.d.ts.map +1 -1
  4. package/dist/agents/core/BaseAgentAdapter.js +3 -0
  5. package/dist/agents/core/BaseAgentAdapter.js.map +1 -1
  6. package/dist/cli/commands/mcp/index.d.ts +3 -0
  7. package/dist/cli/commands/mcp/index.d.ts.map +1 -0
  8. package/dist/cli/commands/mcp/index.js +103 -0
  9. package/dist/cli/commands/mcp/index.js.map +1 -0
  10. package/dist/cli/commands/mcp-proxy.d.ts +13 -0
  11. package/dist/cli/commands/mcp-proxy.d.ts.map +1 -0
  12. package/dist/cli/commands/mcp-proxy.js +53 -0
  13. package/dist/cli/commands/mcp-proxy.js.map +1 -0
  14. package/dist/cli/index.js +4 -0
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/mcp/auth/callback-server.d.ts +22 -0
  17. package/dist/mcp/auth/callback-server.d.ts.map +1 -0
  18. package/dist/mcp/auth/callback-server.js +87 -0
  19. package/dist/mcp/auth/callback-server.js.map +1 -0
  20. package/dist/mcp/auth/mcp-oauth-provider.d.ts +49 -0
  21. package/dist/mcp/auth/mcp-oauth-provider.d.ts.map +1 -0
  22. package/dist/mcp/auth/mcp-oauth-provider.js +156 -0
  23. package/dist/mcp/auth/mcp-oauth-provider.js.map +1 -0
  24. package/dist/mcp/constants.d.ts +5 -0
  25. package/dist/mcp/constants.d.ts.map +1 -0
  26. package/dist/mcp/constants.js +7 -0
  27. package/dist/mcp/constants.js.map +1 -0
  28. package/dist/mcp/proxy-logger.d.ts +7 -0
  29. package/dist/mcp/proxy-logger.d.ts.map +1 -0
  30. package/dist/mcp/proxy-logger.js +32 -0
  31. package/dist/mcp/proxy-logger.js.map +1 -0
  32. package/dist/mcp/stdio-http-bridge.d.ts +63 -0
  33. package/dist/mcp/stdio-http-bridge.d.ts.map +1 -0
  34. package/dist/mcp/stdio-http-bridge.js +307 -0
  35. package/dist/mcp/stdio-http-bridge.js.map +1 -0
  36. package/dist/providers/plugins/sso/proxy/plugins/index.d.ts +2 -1
  37. package/dist/providers/plugins/sso/proxy/plugins/index.d.ts.map +1 -1
  38. package/dist/providers/plugins/sso/proxy/plugins/index.js +3 -1
  39. package/dist/providers/plugins/sso/proxy/plugins/index.js.map +1 -1
  40. package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.d.ts +34 -0
  41. package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.d.ts.map +1 -0
  42. package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.js +1200 -0
  43. package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.js.map +1 -0
  44. package/dist/providers/plugins/sso/proxy/plugins/types.d.ts +18 -1
  45. package/dist/providers/plugins/sso/proxy/plugins/types.d.ts.map +1 -1
  46. package/dist/providers/plugins/sso/proxy/sso.proxy.d.ts.map +1 -1
  47. package/dist/providers/plugins/sso/proxy/sso.proxy.js +32 -2
  48. package/dist/providers/plugins/sso/proxy/sso.proxy.js.map +1 -1
  49. package/dist/utils/exec.d.ts +1 -0
  50. package/dist/utils/exec.d.ts.map +1 -1
  51. package/dist/utils/exec.js +13 -5
  52. package/dist/utils/exec.js.map +1 -1
  53. package/package.json +5 -2
@@ -0,0 +1,1200 @@
1
+ /**
2
+ * MCP Authorization Proxy Plugin
3
+ * Priority: 3 (runs before endpoint blocker, auth, and all other plugins)
4
+ *
5
+ * Proxies the MCP OAuth authorization flow so that:
6
+ * 1. All auth traffic is routed through the CodeMie proxy
7
+ * 2. `client_name` is replaced with MCP_CLIENT_NAME env var (default "CodeMie CLI") in dynamic client registration
8
+ *
9
+ * URL scheme:
10
+ * - /mcp_auth?original=<url> → Initial MCP connection
11
+ * - /mcp_relay/<root_b64>/<relay_b64>/<path> → Relayed requests (per-flow scoped)
12
+ *
13
+ * The root_b64 segment carries the root MCP server origin for per-flow isolation.
14
+ * The relay_b64 segment identifies the actual target origin (may differ from root
15
+ * when the auth server is on a separate host).
16
+ *
17
+ * Response URL rewriting replaces external URLs with proxy relay URLs so that
18
+ * the MCP client (Claude Code CLI) routes all subsequent requests through the proxy.
19
+ *
20
+ * Security:
21
+ * - SSRF protection: private/loopback origins are rejected (hostname + DNS resolution)
22
+ * - Per-flow origin scoping: discovered origins are tagged with their root MCP server
23
+ * origin and relay requests validate the root-relay association
24
+ * - Buffering is restricted to auth metadata responses; post-auth MCP traffic streams through
25
+ */
26
+ import { URL } from 'url';
27
+ import { lookup } from 'dns/promises';
28
+ import { gunzip, inflate, brotliDecompress } from 'zlib';
29
+ import { promisify } from 'util';
30
+ import { logger } from '../../../../../utils/logger.js';
31
+ import { getMcpClientName } from '../../../../../mcp/constants.js';
32
+ const gunzipAsync = promisify(gunzip);
33
+ const inflateAsync = promisify(inflate);
34
+ const brotliDecompressAsync = promisify(brotliDecompress);
35
+ // ─── URL Utilities ───────────────────────────────────────────────────────────
36
+ /** Base64url encode (RFC 4648 §5): URL-safe, no padding */
37
+ function base64urlEncode(str) {
38
+ return Buffer.from(str, 'utf-8')
39
+ .toString('base64')
40
+ .replace(/\+/g, '-')
41
+ .replace(/\//g, '_')
42
+ .replace(/=+$/, '');
43
+ }
44
+ /** Base64url decode */
45
+ function base64urlDecode(encoded) {
46
+ // Restore standard base64
47
+ let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
48
+ // Re-add padding
49
+ while (base64.length % 4 !== 0) {
50
+ base64 += '=';
51
+ }
52
+ return Buffer.from(base64, 'base64').toString('utf-8');
53
+ }
54
+ /** Extract origin (scheme + host + port) from a URL string */
55
+ function getOrigin(urlStr) {
56
+ try {
57
+ const u = new URL(urlStr);
58
+ return u.origin; // e.g. "https://example.com" or "https://example.com:8443"
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ }
64
+ /** Check if a string looks like an absolute HTTP(S) URL */
65
+ function isAbsoluteUrl(str) {
66
+ return str.startsWith('http://') || str.startsWith('https://');
67
+ }
68
+ // JSON field names whose values are token audience identifiers, NOT URLs to access.
69
+ // These must not be rewritten by the generic URL rewriter.
70
+ // Note: 'resource' is handled separately in rewriteJsonValue — it gets special
71
+ // bidirectional rewriting (to proxy URL in responses, back to original in requests).
72
+ const SKIP_REWRITE_FIELDS = new Set([
73
+ 'aud', 'audience', 'redirect_uri', 'redirect_uris',
74
+ 'issuer', // OIDC issuer — rewriting breaks token issuer validation
75
+ ]);
76
+ // Auth server metadata fields whose URLs are browser-facing and must NOT be rewritten.
77
+ // The browser must navigate directly to the real auth server for login flows because:
78
+ // - Cookies/sessions are domain-scoped (won't work through localhost proxy)
79
+ // - SAML/OIDC federation redirects require the real auth server domain
80
+ // - The auth server's HTML/JS pages reference its own origin
81
+ // Programmatic endpoints (token_endpoint, registration_endpoint) ARE rewritten.
82
+ const BROWSER_FACING_FIELDS = new Set([
83
+ 'authorization_endpoint',
84
+ 'end_session_endpoint',
85
+ ]);
86
+ // Max response body size for buffered MCP auth responses (1MB).
87
+ // Auth metadata payloads are typically 1-10KB. This prevents OOM from
88
+ // malicious or misconfigured upstreams.
89
+ const MAX_RESPONSE_SIZE = 1024 * 1024;
90
+ // Max number of discovered origins to prevent Set explosion from malicious responses.
91
+ // A normal MCP auth flow discovers 2-3 origins (MCP server + auth server).
92
+ const MAX_KNOWN_ORIGINS = 50;
93
+ // Max number of distinct MCP server origins accepted via /mcp_auth.
94
+ // Bounds the SSRF surface: the proxy is localhost-only but this prevents
95
+ // unbounded use as a generic forwarder. A typical setup has 1-3 MCP servers.
96
+ const MAX_MCP_SERVER_ORIGINS = 10;
97
+ // TTL for discovered origins in milliseconds (30 minutes).
98
+ // Bounds the window during which cross-flow origin leakage can occur.
99
+ // Refreshed on each access, so active flows keep their origins alive.
100
+ const ORIGIN_TTL_MS = 30 * 60 * 1000;
101
+ /**
102
+ * Check if a URL origin points to a private, loopback, or link-local network.
103
+ * Prevents SSRF through malicious auth server metadata that advertises internal hosts.
104
+ */
105
+ function isPrivateOrLoopbackOrigin(origin) {
106
+ try {
107
+ const url = new URL(origin);
108
+ const hostname = url.hostname.toLowerCase();
109
+ // Loopback
110
+ if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')
111
+ return true;
112
+ // IPv4 ranges
113
+ const parts = hostname.split('.');
114
+ if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
115
+ const [a, b] = parts.map(Number);
116
+ if (a === 127)
117
+ return true; // 127.0.0.0/8 loopback
118
+ if (a === 10)
119
+ return true; // 10.0.0.0/8 private
120
+ if (a === 172 && b >= 16 && b <= 31)
121
+ return true; // 172.16.0.0/12 private
122
+ if (a === 192 && b === 168)
123
+ return true; // 192.168.0.0/16 private
124
+ if (a === 169 && b === 254)
125
+ return true; // 169.254.0.0/16 link-local
126
+ if (a === 0)
127
+ return true; // 0.0.0.0/8
128
+ }
129
+ // IPv6 (may be bracketed in URL hostnames)
130
+ const ipv6 = hostname.replace(/^\[|\]$/g, '');
131
+ if (ipv6.startsWith('fc') || ipv6.startsWith('fd'))
132
+ return true; // fc00::/7 ULA
133
+ if (ipv6.startsWith('fe80'))
134
+ return true; // fe80::/10 link-local
135
+ return false;
136
+ }
137
+ catch {
138
+ return true; // Can't parse — reject to be safe
139
+ }
140
+ }
141
+ /**
142
+ * Check if a resolved IP address is in a private, loopback, or link-local range.
143
+ * Used for DNS resolution SSRF validation (catches DNS rebinding attacks where
144
+ * a public hostname resolves to an internal IP).
145
+ */
146
+ function isPrivateOrLoopbackIP(ip) {
147
+ // Handle IPv4-mapped IPv6 (::ffff:x.x.x.x)
148
+ const ipv4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
149
+ if (ipv4Mapped) {
150
+ return isPrivateOrLoopbackIP(ipv4Mapped[1]);
151
+ }
152
+ // IPv4
153
+ const parts = ip.split('.');
154
+ if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
155
+ const [a, b] = parts.map(Number);
156
+ if (a === 127)
157
+ return true; // 127.0.0.0/8 loopback
158
+ if (a === 10)
159
+ return true; // 10.0.0.0/8 private
160
+ if (a === 172 && b >= 16 && b <= 31)
161
+ return true; // 172.16.0.0/12 private
162
+ if (a === 192 && b === 168)
163
+ return true; // 192.168.0.0/16 private
164
+ if (a === 169 && b === 254)
165
+ return true; // 169.254.0.0/16 link-local
166
+ if (a === 0)
167
+ return true; // 0.0.0.0/8
168
+ }
169
+ // IPv6
170
+ const normalized = ip.toLowerCase();
171
+ if (normalized === '::1' || normalized === '::')
172
+ return true;
173
+ if (normalized.startsWith('fc') || normalized.startsWith('fd'))
174
+ return true; // ULA
175
+ if (normalized.startsWith('fe80'))
176
+ return true; // Link-local
177
+ return false;
178
+ }
179
+ /**
180
+ * Resolve a hostname via DNS and check if it points to a private/loopback IP.
181
+ * Defense-in-depth against DNS rebinding SSRF attacks.
182
+ *
183
+ * Note: There is an inherent TOCTOU window between this check and the actual
184
+ * HTTP connection (the hostname could re-resolve differently). This is mitigated
185
+ * by OS-level DNS caching and the short interval between check and connect.
186
+ */
187
+ async function resolvesToPrivateIP(hostname) {
188
+ // Skip DNS resolution for IP literals — already checked by isPrivateOrLoopbackOrigin
189
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
190
+ return false;
191
+ }
192
+ try {
193
+ const { address } = await lookup(hostname);
194
+ return isPrivateOrLoopbackIP(address);
195
+ }
196
+ catch {
197
+ // DNS resolution failed — let the HTTP client handle the error naturally
198
+ return false;
199
+ }
200
+ }
201
+ /**
202
+ * Normalize a URL to origin + pathname (lowercased) for endpoint comparison.
203
+ * Strips query params so that `https://auth/register?foo=1` matches `https://auth/register`.
204
+ */
205
+ function normalizeEndpointUrl(url) {
206
+ try {
207
+ const parsed = new URL(url);
208
+ return (parsed.origin + parsed.pathname).toLowerCase();
209
+ }
210
+ catch {
211
+ return url.toLowerCase();
212
+ }
213
+ }
214
+ // Query parameter names that may contain sensitive auth data and must be masked in logs.
215
+ const SENSITIVE_QUERY_PARAMS = new Set([
216
+ 'code', 'state', 'token', 'access_token', 'refresh_token',
217
+ 'id_token', 'session_state', 'client_secret',
218
+ ]);
219
+ /**
220
+ * Mask sensitive query parameter values in a URL for safe logging.
221
+ * Handles nested URLs: if a parameter value itself contains a URL with
222
+ * sensitive params (e.g., original=https://idp/callback?code=abc&state=xyz),
223
+ * those nested values are also masked.
224
+ */
225
+ function sanitizeUrlForLog(url) {
226
+ const queryStart = url.indexOf('?');
227
+ if (queryStart === -1)
228
+ return url;
229
+ const basePath = url.slice(0, queryStart);
230
+ const queryString = url.slice(queryStart + 1);
231
+ const sanitizedParams = queryString.split('&').map(param => {
232
+ const eqIdx = param.indexOf('=');
233
+ if (eqIdx === -1)
234
+ return param;
235
+ const key = param.slice(0, eqIdx).toLowerCase();
236
+ if (SENSITIVE_QUERY_PARAMS.has(key)) {
237
+ return `${param.slice(0, eqIdx)}=***`;
238
+ }
239
+ // Recursively sanitize nested URLs in parameter values
240
+ const value = param.slice(eqIdx + 1);
241
+ if (isAbsoluteUrl(value) || isAbsoluteUrl(decodeURIComponentSafe(value))) {
242
+ const sanitizedValue = sanitizeUrlForLog(decodeURIComponentSafe(value));
243
+ return `${param.slice(0, eqIdx)}=${sanitizedValue}`;
244
+ }
245
+ return param;
246
+ });
247
+ return `${basePath}?${sanitizedParams.join('&')}`;
248
+ }
249
+ /** Safe decodeURIComponent that returns the input on failure */
250
+ function decodeURIComponentSafe(str) {
251
+ try {
252
+ return decodeURIComponent(str);
253
+ }
254
+ catch {
255
+ return str;
256
+ }
257
+ }
258
+ // ─── Plugin ──────────────────────────────────────────────────────────────────
259
+ export class MCPAuthPlugin {
260
+ id = '@codemie/proxy-mcp-auth';
261
+ name = 'MCP Auth Proxy';
262
+ version = '1.0.0';
263
+ priority = 3; // Before endpoint blocker (5) and auth (10)
264
+ async createInterceptor(context) {
265
+ return new MCPAuthInterceptor(context);
266
+ }
267
+ }
268
+ // ─── Interceptor ─────────────────────────────────────────────────────────────
269
+ class MCPAuthInterceptor {
270
+ pluginContext;
271
+ name = 'mcp-auth';
272
+ /** Proxy's own base URL, set on first MCP auth request */
273
+ proxyBaseUrl = null;
274
+ /**
275
+ * Known external origins discovered from auth metadata responses.
276
+ * Map from origin → entry with TTL and per-flow root origin scoping.
277
+ * Origins are ONLY added through validated auth flow responses (WWW-Authenticate,
278
+ * auth server metadata, Location redirects) — never from caller-supplied URLs.
279
+ * Private/loopback origins are rejected to prevent SSRF.
280
+ *
281
+ * Per-flow scoping: each discovered origin is tagged with the root MCP server
282
+ * origin it was discovered from. Relay requests validate that the relay origin
283
+ * was discovered from the root origin carried in the URL. This prevents
284
+ * cross-flow origin leakage (flow A's discovered origins are not usable by flow B).
285
+ */
286
+ knownOrigins = new Map();
287
+ /**
288
+ * Normalized URLs (origin+pathname) of discovered registration endpoints.
289
+ * Used to match client_name replacement targets beyond the /register path heuristic.
290
+ */
291
+ discoveredRegistrationEndpoints = new Set();
292
+ /**
293
+ * Normalized URLs (origin+pathname) of all discovered auth endpoints
294
+ * (token, registration, authorization, jwks, etc.).
295
+ * Used for buffering decisions in isAuthMetadataResponse beyond path heuristics.
296
+ */
297
+ discoveredAuthEndpoints = new Set();
298
+ /**
299
+ * Distinct MCP server origins accessed via /mcp_auth.
300
+ * Bounded by MAX_MCP_SERVER_ORIGINS to prevent unbounded use as a generic forwarder.
301
+ */
302
+ mcpServerOrigins = new Set();
303
+ /**
304
+ * Mapping from original MCP server URLs to their proxy /mcp_auth URLs.
305
+ * Used for bidirectional 'resource' field rewriting:
306
+ * - Response: resource "https://real-server/path" → "http://localhost:PORT/mcp_auth?original=https://real-server/path"
307
+ * - Request: reverse mapping before forwarding to auth server
308
+ * This is needed because the MCP SDK validates that the resource metadata's
309
+ * 'resource' field matches the URL the client originally connected to.
310
+ */
311
+ mcpUrlMapping = new Map();
312
+ constructor(pluginContext) {
313
+ this.pluginContext = pluginContext;
314
+ }
315
+ async onProxyStop() {
316
+ this.proxyBaseUrl = null;
317
+ this.knownOrigins.clear();
318
+ this.mcpServerOrigins.clear();
319
+ this.mcpUrlMapping.clear();
320
+ this.discoveredRegistrationEndpoints.clear();
321
+ this.discoveredAuthEndpoints.clear();
322
+ }
323
+ /**
324
+ * Check if an origin is known, not expired, and (optionally) associated with a
325
+ * specific root MCP server origin. Refreshes TTL on access.
326
+ */
327
+ isKnownOrigin(origin, rootOrigin) {
328
+ const entry = this.knownOrigins.get(origin);
329
+ if (!entry)
330
+ return false;
331
+ if (Date.now() - entry.timestamp > ORIGIN_TTL_MS) {
332
+ this.knownOrigins.delete(origin);
333
+ logger.debug(`[${this.name}] Origin expired and removed: ${origin}`);
334
+ return false;
335
+ }
336
+ // Per-flow validation: if rootOrigin specified, verify association
337
+ if (rootOrigin && !entry.rootOrigins.has(rootOrigin)) {
338
+ logger.debug(`[${this.name}] Origin ${origin} not associated with root ${rootOrigin}`);
339
+ return false;
340
+ }
341
+ entry.timestamp = Date.now(); // Refresh on access
342
+ return true;
343
+ }
344
+ /**
345
+ * Add a discovered origin with private-network validation, TTL, and root tagging.
346
+ * If the origin already exists, adds the rootOrigin to its set and refreshes TTL.
347
+ */
348
+ addKnownOrigin(origin, rootOrigin) {
349
+ const existing = this.knownOrigins.get(origin);
350
+ if (existing) {
351
+ existing.timestamp = Date.now();
352
+ existing.rootOrigins.add(rootOrigin);
353
+ return true;
354
+ }
355
+ if (isPrivateOrLoopbackOrigin(origin)) {
356
+ logger.debug(`[${this.name}] Rejected private/loopback origin: ${origin}`);
357
+ return false;
358
+ }
359
+ // Sweep expired entries before checking capacity so stale origins
360
+ // don't prevent legitimate new origins from being added.
361
+ if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) {
362
+ this.sweepExpiredOrigins();
363
+ }
364
+ if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
365
+ return false;
366
+ this.knownOrigins.set(origin, {
367
+ timestamp: Date.now(),
368
+ rootOrigins: new Set([rootOrigin]),
369
+ });
370
+ return true;
371
+ }
372
+ /** Remove all expired origins from the map. */
373
+ sweepExpiredOrigins() {
374
+ const now = Date.now();
375
+ for (const [origin, entry] of this.knownOrigins) {
376
+ if (now - entry.timestamp > ORIGIN_TTL_MS) {
377
+ this.knownOrigins.delete(origin);
378
+ logger.debug(`[${this.name}] Swept expired origin: ${origin}`);
379
+ }
380
+ }
381
+ }
382
+ /**
383
+ * Handle MCP auth requests directly, bypassing normal proxy flow.
384
+ * Returns true if the request was handled (path matched /mcp_auth or /mcp_relay).
385
+ *
386
+ * Why this bypasses the standard pipeline:
387
+ * MCP auth traffic routes to MCP/auth servers (not LLM APIs), so the standard
388
+ * proxy plugins (endpoint blocker, auth injection, request sanitizer) do not apply.
389
+ * This plugin implements its own SSRF protection, origin validation, and logging.
390
+ */
391
+ async handleRequest(context, _req, res, httpClient) {
392
+ const url = context.url; // e.g. "/mcp_auth?original=..." or "/mcp_relay/root/relay/path"
393
+ // Route 1: Initial MCP connection (exact path boundary: /mcp_auth or /mcp_auth?...)
394
+ if (url === '/mcp_auth' || url.startsWith('/mcp_auth?')) {
395
+ const safeUrl = sanitizeUrlForLog(url);
396
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
397
+ const startTime = Date.now();
398
+ await this.handleMCPAuth(context, res, httpClient);
399
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
400
+ return true;
401
+ }
402
+ // Route 2: Relayed request to external host
403
+ if (url.startsWith('/mcp_relay/')) {
404
+ const safeUrl = sanitizeUrlForLog(url);
405
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
406
+ const startTime = Date.now();
407
+ await this.handleMCPRelay(context, res, httpClient);
408
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
409
+ return true;
410
+ }
411
+ // Route 3: RFC 8414 well-known URLs constructed over mcp_relay paths.
412
+ // OAuth SDKs construct well-known URLs by inserting .well-known at the URL root:
413
+ // /.well-known/<type>/mcp_relay/<root>/<relay>/<issuer_path>
414
+ // Rewrite to relay form so handleMCPRelay processes it:
415
+ // /mcp_relay/<root>/<relay>/.well-known/<type>/<issuer_path>
416
+ if (url.startsWith('/.well-known/') && url.includes('/mcp_relay/')) {
417
+ const mcpRelayIdx = url.indexOf('/mcp_relay/');
418
+ const wellKnownPart = url.slice(0, mcpRelayIdx); // e.g. "/.well-known/oauth-authorization-server"
419
+ const relaySegment = url.slice(mcpRelayIdx + '/mcp_relay/'.length); // <root>/<relay>/<issuer_path>[?query]
420
+ // Extract root and relay segments, then reconstruct
421
+ const firstSlash = relaySegment.indexOf('/');
422
+ if (firstSlash !== -1) {
423
+ const rootEnc = relaySegment.slice(0, firstSlash);
424
+ const afterRoot = relaySegment.slice(firstSlash + 1);
425
+ const secondSlash = afterRoot.indexOf('/');
426
+ const secondQuery = afterRoot.indexOf('?');
427
+ const secondSep = secondSlash === -1 ? secondQuery
428
+ : secondQuery === -1 ? secondSlash
429
+ : Math.min(secondSlash, secondQuery);
430
+ const relayEnc = secondSep === -1 ? afterRoot : afterRoot.slice(0, secondSep);
431
+ const issuerRest = secondSep === -1 ? '' : afterRoot.slice(secondSep); // e.g. "/keycloak_prod/..."
432
+ // Reconstruct: /mcp_relay/<root>/<relay>/.well-known/<type>/<issuer_path>
433
+ const rewrittenUrl = `/mcp_relay/${rootEnc}/${relayEnc}${wellKnownPart}${issuerRest}`;
434
+ logger.debug(`[${this.name}] RFC 8414 well-known rewrite: ${url} → ${rewrittenUrl}`);
435
+ context.url = rewrittenUrl;
436
+ const safeUrl = sanitizeUrlForLog(rewrittenUrl);
437
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
438
+ const startTime = Date.now();
439
+ await this.handleMCPRelay(context, res, httpClient);
440
+ logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
441
+ return true;
442
+ }
443
+ }
444
+ // Not an MCP auth request — let normal proxy flow handle it
445
+ return false;
446
+ }
447
+ // ─── Route Handlers ──────────────────────────────────────────────────────
448
+ /**
449
+ * Handle /mcp_auth?original=<url>
450
+ * Extracts the real MCP server URL and forwards the request.
451
+ */
452
+ async handleMCPAuth(context, res, httpClient) {
453
+ // Set proxy base URL on first request
454
+ if (!this.proxyBaseUrl) {
455
+ const proxyPort = this.pluginContext.config.port;
456
+ this.proxyBaseUrl = `http://localhost:${proxyPort}`;
457
+ }
458
+ // Extract original URL from query string
459
+ const originalUrl = this.extractOriginalUrl(context.url);
460
+ logger.debug(`[${this.name}] Extracted originalUrl: ${originalUrl}`);
461
+ if (!originalUrl) {
462
+ this.sendError(res, 400, 'Missing or invalid "original" query parameter');
463
+ return;
464
+ }
465
+ // SSRF check: reject private/loopback targets in the original URL.
466
+ const origin = getOrigin(originalUrl);
467
+ logger.debug(`[${this.name}] Original origin: ${origin}`);
468
+ if (isPrivateOrLoopbackOrigin(originalUrl)) {
469
+ this.sendError(res, 403, 'SSRF blocked: original URL points to private/loopback network');
470
+ return;
471
+ }
472
+ // Track and cap distinct MCP server origins to prevent use as a generic forwarder.
473
+ // The /mcp_auth path must forward to arbitrary user-configured URLs (by design),
474
+ // but we bound the number of distinct origins to limit SSRF surface.
475
+ if (origin && !this.mcpServerOrigins.has(origin)) {
476
+ if (this.mcpServerOrigins.size >= MAX_MCP_SERVER_ORIGINS) {
477
+ this.sendError(res, 403, `MCP server origin limit reached (${MAX_MCP_SERVER_ORIGINS}). Cannot forward to new origins.`);
478
+ return;
479
+ }
480
+ this.mcpServerOrigins.add(origin);
481
+ logger.debug(`[${this.name}] Registered MCP server origin: ${origin} (${this.mcpServerOrigins.size}/${MAX_MCP_SERVER_ORIGINS})`);
482
+ }
483
+ // The root origin is the MCP server's origin — used for per-flow origin scoping
484
+ const rootOrigin = origin || '';
485
+ // Store mapping for bidirectional 'resource' field rewriting.
486
+ // The MCP SDK validates resource metadata's 'resource' field against the connected URL.
487
+ const proxyUrl = `${this.proxyBaseUrl}/mcp_auth?original=${originalUrl}`;
488
+ this.mcpUrlMapping.set(originalUrl, proxyUrl);
489
+ logger.debug(`[${this.name}] Stored resource mapping: "${originalUrl}" → "${proxyUrl}"`);
490
+ logger.debug(`[${this.name}] Initial MCP auth request → ${originalUrl} [rootOrigin=${rootOrigin}]`);
491
+ await this.forwardAndRewrite(context, res, httpClient, originalUrl, rootOrigin);
492
+ }
493
+ /**
494
+ * Handle /mcp_relay/<root_b64>/<relay_b64>/<path>
495
+ * Decodes both origins, validates the root-relay association, and forwards.
496
+ *
497
+ * URL scheme: /mcp_relay/<encoded_root_origin>/<encoded_relay_origin>/<rest_of_path>
498
+ * The root origin identifies which MCP flow this relay belongs to.
499
+ * The relay origin is the actual target host (may differ from root for auth servers).
500
+ */
501
+ async handleMCPRelay(context, res, httpClient) {
502
+ // Parse: /mcp_relay/<encoded_root>/<encoded_relay>/<rest>
503
+ const withoutPrefix = context.url.slice('/mcp_relay/'.length);
504
+ // Find first slash — separates encoded_root from encoded_relay
505
+ const firstSlash = withoutPrefix.indexOf('/');
506
+ if (firstSlash === -1) {
507
+ this.sendError(res, 400, 'Invalid /mcp_relay path: missing relay origin segment');
508
+ return;
509
+ }
510
+ const encodedRoot = withoutPrefix.slice(0, firstSlash);
511
+ const afterRoot = withoutPrefix.slice(firstSlash + 1);
512
+ // Find second separator (/ or ?) — separates encoded_relay from path
513
+ const secondSlash = afterRoot.indexOf('/');
514
+ const queryIdx = afterRoot.indexOf('?');
515
+ const secondSep = secondSlash === -1 ? queryIdx
516
+ : queryIdx === -1 ? secondSlash
517
+ : Math.min(secondSlash, queryIdx);
518
+ let encodedRelay;
519
+ let pathAndQuery;
520
+ if (secondSep === -1) {
521
+ encodedRelay = afterRoot;
522
+ pathAndQuery = '/';
523
+ }
524
+ else if (afterRoot[secondSep] === '?') {
525
+ // Query directly on root: /mcp_relay/<root>/<relay>?x=1 → origin + /?x=1
526
+ encodedRelay = afterRoot.slice(0, secondSep);
527
+ pathAndQuery = '/' + afterRoot.slice(secondSep); // → /?x=1
528
+ }
529
+ else {
530
+ encodedRelay = afterRoot.slice(0, secondSep);
531
+ pathAndQuery = afterRoot.slice(secondSep); // includes leading /
532
+ }
533
+ let decodedRoot;
534
+ let decodedRelay;
535
+ try {
536
+ decodedRoot = base64urlDecode(encodedRoot);
537
+ decodedRelay = base64urlDecode(encodedRelay);
538
+ }
539
+ catch {
540
+ this.sendError(res, 400, 'Invalid encoded origin in /mcp_relay path');
541
+ return;
542
+ }
543
+ logger.debug(`[${this.name}] Relay parsed: root="${decodedRoot}" relay="${decodedRelay}" pathAndQuery="${pathAndQuery}"`);
544
+ if (!isAbsoluteUrl(decodedRoot) || !isAbsoluteUrl(decodedRelay)) {
545
+ this.sendError(res, 400, 'Decoded origin is not a valid URL');
546
+ return;
547
+ }
548
+ // Auto-register origins from relay URLs if not already known.
549
+ // The MCP SDK may cache auth state (resource metadata URLs) across sessions.
550
+ // When a new session starts, knownOrigins is empty but the SDK reuses cached
551
+ // /mcp_relay/... URLs from a previous session. The URLs were generated by our
552
+ // own proxy, so the encoded origins are trustworthy (still SSRF-checked).
553
+ if (!this.knownOrigins.has(decodedRoot)) {
554
+ if (!isPrivateOrLoopbackOrigin(decodedRoot)) {
555
+ this.addKnownOrigin(decodedRoot, decodedRoot);
556
+ this.mcpServerOrigins.add(decodedRoot);
557
+ logger.debug(`[${this.name}] Auto-registered root origin from cached relay URL: ${decodedRoot}`);
558
+ // Reconstruct the mcpUrlMapping for resource field rewriting.
559
+ // We don't have the full original URL, but the proxy base URL is set.
560
+ if (!this.proxyBaseUrl) {
561
+ const proxyPort = this.pluginContext.config.port;
562
+ this.proxyBaseUrl = `http://localhost:${proxyPort}`;
563
+ }
564
+ }
565
+ }
566
+ if (decodedRoot !== decodedRelay && !this.knownOrigins.has(decodedRelay)) {
567
+ if (!isPrivateOrLoopbackOrigin(decodedRelay)) {
568
+ this.addKnownOrigin(decodedRelay, decodedRoot);
569
+ logger.debug(`[${this.name}] Auto-registered relay origin from cached relay URL: ${decodedRelay}`);
570
+ }
571
+ }
572
+ // Per-flow validation: the relay origin must have been discovered from this root flow
573
+ if (!this.isKnownOrigin(decodedRelay, decodedRoot)) {
574
+ logger.debug(`[${this.name}] Origin check failed: relay="${decodedRelay}" root="${decodedRoot}" knownOrigins=${JSON.stringify([...this.knownOrigins.entries()].map(([k, v]) => ({ origin: k, roots: [...v.rootOrigins] })))}`);
575
+ this.sendError(res, 403, 'Origin not allowed — not discovered through this MCP auth flow');
576
+ return;
577
+ }
578
+ const targetUrl = decodedRelay + pathAndQuery;
579
+ logger.debug(`[${this.name}] MCP relay request [root=${decodedRoot}] → ${targetUrl}`);
580
+ await this.forwardAndRewrite(context, res, httpClient, targetUrl, decodedRoot);
581
+ }
582
+ // ─── Core: Forward + Rewrite ─────────────────────────────────────────────
583
+ /**
584
+ * Forward a request to the target URL.
585
+ * - Auth metadata responses (401, .well-known, /register, /token) are buffered for URL rewriting.
586
+ * - Everything else (authenticated MCP traffic, SSE, binary) is streamed through.
587
+ */
588
+ async forwardAndRewrite(context, res, httpClient, targetUrl, rootOrigin) {
589
+ // Modify request body if needed (client_name replacement)
590
+ let requestBody = context.requestBody;
591
+ const headers = { ...context.headers };
592
+ // Strip accept-encoding so upstream returns uncompressed JSON responses.
593
+ // Needed because the buffer path parses JSON for URL rewriting. We can't know
594
+ // which path (buffer vs stream) until after the response arrives, so strip early.
595
+ delete headers['accept-encoding'];
596
+ logger.debug(`[${this.name}] forwardAndRewrite: ${context.method} ${targetUrl} [rootOrigin=${rootOrigin}]`);
597
+ // Only rewrite client_name for POST to /register (OAuth dynamic client registration).
598
+ // Other JSON payloads must not be mutated.
599
+ const isRegEndpoint = this.isRegistrationEndpoint(targetUrl);
600
+ if (requestBody && context.method === 'POST'
601
+ && headers['content-type']?.includes('application/json')
602
+ && isRegEndpoint) {
603
+ logger.debug(`[${this.name}] Rewriting client_name in registration request`);
604
+ requestBody = this.rewriteRequestBody(requestBody, headers);
605
+ }
606
+ // Reverse-rewrite 'resource' parameter: proxy /mcp_auth URL → original URL.
607
+ if (requestBody && context.method === 'POST') {
608
+ const bodyBefore = requestBody;
609
+ requestBody = this.reverseRewriteResourceInBody(requestBody, headers);
610
+ if (requestBody !== bodyBefore) {
611
+ logger.debug(`[${this.name}] Reverse-rewrote resource in request body`);
612
+ }
613
+ }
614
+ // DNS resolution SSRF check
615
+ const parsedTarget = new URL(targetUrl);
616
+ // Also reverse-rewrite 'resource' in URL query params (authorization endpoint GET)
617
+ this.reverseRewriteResourceParam(parsedTarget);
618
+ logger.debug(`[${this.name}] DNS check for hostname: ${parsedTarget.hostname}`);
619
+ if (await resolvesToPrivateIP(parsedTarget.hostname)) {
620
+ this.sendError(res, 403, 'SSRF blocked: target hostname resolves to private/loopback address');
621
+ return;
622
+ }
623
+ // Forward to target
624
+ const upstreamResponse = await httpClient.forward(parsedTarget, {
625
+ method: context.method,
626
+ headers,
627
+ body: requestBody || undefined
628
+ });
629
+ const statusCode = upstreamResponse.statusCode || 200;
630
+ const contentType = upstreamResponse.headers['content-type'] || '';
631
+ const isJson = contentType.includes('application/json') || contentType.includes('text/json');
632
+ const isAuthMeta = this.isAuthMetadataResponse(targetUrl, statusCode);
633
+ const needsBodyRewriting = isJson && isAuthMeta;
634
+ logger.debug(`[${this.name}] Upstream response: status=${statusCode} contentType="${contentType}" isJson=${isJson} isAuthMeta=${isAuthMeta} needsBodyRewriting=${needsBodyRewriting}`);
635
+ logger.debug(`[${this.name}] Response headers: ${JSON.stringify(upstreamResponse.headers)}`);
636
+ if (needsBodyRewriting) {
637
+ await this.bufferAndRewrite(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin);
638
+ }
639
+ else {
640
+ await this.streamThrough(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin);
641
+ }
642
+ }
643
+ /**
644
+ * Buffer response, rewrite URLs in body and headers, send to client.
645
+ * Used for auth metadata responses (401, JSON) that need URL rewriting.
646
+ */
647
+ async bufferAndRewrite(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin) {
648
+ // Buffer response body (with size limit to prevent OOM)
649
+ const chunks = [];
650
+ let totalSize = 0;
651
+ for await (const chunk of upstreamResponse) {
652
+ totalSize += chunk.length;
653
+ if (totalSize > MAX_RESPONSE_SIZE) {
654
+ upstreamResponse.destroy();
655
+ this.sendError(res, 502, 'Upstream response too large for MCP auth relay');
656
+ return;
657
+ }
658
+ chunks.push(Buffer.from(chunk));
659
+ }
660
+ let responseBody = Buffer.concat(chunks);
661
+ // Decompress if upstream returned compressed content despite stripped accept-encoding.
662
+ // Uses async decompression to avoid blocking the event loop.
663
+ const contentEncoding = (upstreamResponse.headers['content-encoding'] || '').toLowerCase();
664
+ if (contentEncoding) {
665
+ try {
666
+ let decompressed;
667
+ if (contentEncoding === 'gzip' || contentEncoding === 'x-gzip') {
668
+ decompressed = await gunzipAsync(responseBody);
669
+ }
670
+ else if (contentEncoding === 'deflate') {
671
+ decompressed = await inflateAsync(responseBody);
672
+ }
673
+ else if (contentEncoding === 'br') {
674
+ decompressed = await brotliDecompressAsync(responseBody);
675
+ }
676
+ if (decompressed) {
677
+ // Check decompressed size to prevent decompression bomb attacks
678
+ if (decompressed.length > MAX_RESPONSE_SIZE) {
679
+ this.sendError(res, 502, 'Decompressed upstream response too large for MCP auth relay');
680
+ return;
681
+ }
682
+ responseBody = decompressed;
683
+ // Remove content-encoding since we've decompressed
684
+ delete upstreamResponse.headers['content-encoding'];
685
+ }
686
+ }
687
+ catch (err) {
688
+ logger.debug(`[${this.name}] Failed to decompress ${contentEncoding} response, passing through: ${err}`);
689
+ }
690
+ }
691
+ // Rewrite URLs in response body (JSON only)
692
+ const contentType = upstreamResponse.headers['content-type'] || '';
693
+ if (contentType.includes('application/json') || contentType.includes('text/json')) {
694
+ const bodyBefore = responseBody.toString('utf-8');
695
+ logger.debug(`[${this.name}] Response body BEFORE rewrite (${bodyBefore.length} chars): ${bodyBefore.slice(0, 2000)}`);
696
+ responseBody = this.rewriteResponseBody(responseBody, rootOrigin);
697
+ const bodyAfter = responseBody.toString('utf-8');
698
+ logger.debug(`[${this.name}] Response body AFTER rewrite (${bodyAfter.length} chars): ${bodyAfter.slice(0, 2000)}`);
699
+ }
700
+ // Rewrite URLs in response headers
701
+ const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin);
702
+ logger.debug(`[${this.name}] Rewritten response headers: ${JSON.stringify(responseHeaders)}`);
703
+ // Send to client
704
+ res.statusCode = statusCode;
705
+ for (const [key, value] of Object.entries(responseHeaders)) {
706
+ if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
707
+ res.setHeader(key, value);
708
+ }
709
+ }
710
+ // Update content-length to match rewritten body
711
+ res.setHeader('content-length', String(responseBody.length));
712
+ res.end(responseBody);
713
+ logger.debug(`[${this.name}] Buffered response sent: ${statusCode}, ${responseBody.length} bytes`);
714
+ }
715
+ /**
716
+ * Stream response through without buffering.
717
+ * Used for authenticated MCP traffic (SSE, binary, large responses).
718
+ * Only headers are rewritten (Location redirects); body is passed through as-is.
719
+ */
720
+ async streamThrough(_context, res, upstreamResponse, targetUrl, statusCode, rootOrigin) {
721
+ // Rewrite URLs in response headers only (no body rewriting)
722
+ logger.debug(`[${this.name}] streamThrough: status=${statusCode} targetUrl=${targetUrl}`);
723
+ const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin);
724
+ // Set status and headers
725
+ res.statusCode = statusCode;
726
+ for (const [key, value] of Object.entries(responseHeaders)) {
727
+ if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
728
+ res.setHeader(key, value);
729
+ }
730
+ }
731
+ // Stream body directly to client, honoring backpressure and aborting on disconnect
732
+ let bytesSent = 0;
733
+ let downstreamClosed = false;
734
+ // Resolve any pending drain wait when the client disconnects
735
+ let onClose = null;
736
+ res.on('close', () => {
737
+ if (!res.writableFinished) {
738
+ downstreamClosed = true;
739
+ upstreamResponse.destroy();
740
+ // Unblock any pending drain await so the handler doesn't hang
741
+ onClose?.();
742
+ }
743
+ });
744
+ for await (const chunk of upstreamResponse) {
745
+ if (downstreamClosed)
746
+ break;
747
+ const canContinue = res.write(chunk);
748
+ bytesSent += chunk.length;
749
+ // Honor backpressure: wait for drain OR close (whichever fires first)
750
+ if (!canContinue && !downstreamClosed) {
751
+ await new Promise(resolve => {
752
+ onClose = resolve;
753
+ res.once('drain', () => { onClose = null; resolve(); });
754
+ });
755
+ }
756
+ }
757
+ if (!downstreamClosed) {
758
+ res.end();
759
+ }
760
+ logger.debug(`[${this.name}] Streamed response: ${statusCode}, ${bytesSent} bytes`);
761
+ }
762
+ // ─── Request Body Modification ───────────────────────────────────────────
763
+ /**
764
+ * Replace `client_name` in JSON request bodies.
765
+ * Uses MCP_CLIENT_NAME env var, defaults to "CodeMie CLI".
766
+ * This targets the OAuth dynamic client registration (POST /register).
767
+ */
768
+ rewriteRequestBody(body, headers) {
769
+ const clientName = getMcpClientName();
770
+ try {
771
+ const parsed = JSON.parse(body.toString('utf-8'));
772
+ if (typeof parsed === 'object' && parsed !== null && 'client_name' in parsed) {
773
+ parsed.client_name = clientName;
774
+ const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8');
775
+ headers['content-length'] = String(newBody.length);
776
+ logger.debug(`[${this.name}] Replaced client_name with "${clientName}"`);
777
+ return newBody;
778
+ }
779
+ }
780
+ catch {
781
+ // Not valid JSON — pass through unchanged
782
+ }
783
+ return body;
784
+ }
785
+ // ─── Response Body URL Rewriting ─────────────────────────────────────────
786
+ /**
787
+ * Rewrite external URLs in a JSON response body to proxy relay URLs.
788
+ * Discovers new origins from response content (e.g., authorization_servers).
789
+ */
790
+ rewriteResponseBody(body, rootOrigin) {
791
+ if (!this.proxyBaseUrl)
792
+ return body;
793
+ try {
794
+ const bodyStr = body.toString('utf-8');
795
+ const parsed = JSON.parse(bodyStr);
796
+ // First pass: discover new origins from known fields
797
+ this.discoverOrigins(parsed, rootOrigin);
798
+ // Second pass: rewrite URLs
799
+ const rewritten = this.rewriteJsonValue(parsed, null, rootOrigin);
800
+ return Buffer.from(JSON.stringify(rewritten), 'utf-8');
801
+ }
802
+ catch {
803
+ // Not valid JSON — return unchanged
804
+ return body;
805
+ }
806
+ }
807
+ /**
808
+ * Discover new origins from well-known JSON fields.
809
+ * Specifically targets `authorization_servers` in protected resource metadata.
810
+ */
811
+ discoverOrigins(obj, rootOrigin) {
812
+ if (typeof obj !== 'object' || obj === null)
813
+ return;
814
+ if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
815
+ return;
816
+ if (Array.isArray(obj)) {
817
+ for (const item of obj) {
818
+ this.discoverOrigins(item, rootOrigin);
819
+ }
820
+ return;
821
+ }
822
+ const record = obj;
823
+ // authorization_servers: ["https://auth.example.com/realms/r1"]
824
+ if (Array.isArray(record.authorization_servers)) {
825
+ for (const server of record.authorization_servers) {
826
+ if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
827
+ break;
828
+ if (typeof server === 'string' && isAbsoluteUrl(server)) {
829
+ const origin = getOrigin(server);
830
+ if (origin && this.addKnownOrigin(origin, rootOrigin)) {
831
+ logger.debug(`[${this.name}] Discovered auth server origin: ${origin} [root=${rootOrigin}]`);
832
+ }
833
+ }
834
+ }
835
+ }
836
+ // Auth endpoint fields that may be on a different origin than the auth server.
837
+ // e.g., token_endpoint on a CDN or registration_endpoint on a separate service.
838
+ const endpointFields = [
839
+ 'token_endpoint', 'registration_endpoint', 'authorization_endpoint',
840
+ 'jwks_uri', 'introspection_endpoint', 'revocation_endpoint',
841
+ 'userinfo_endpoint', 'end_session_endpoint',
842
+ 'device_authorization_endpoint', 'pushed_authorization_request_endpoint',
843
+ 'backchannel_authentication_endpoint', 'registration_client_uri',
844
+ ];
845
+ for (const field of endpointFields) {
846
+ if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
847
+ break;
848
+ const value = record[field];
849
+ if (typeof value === 'string' && isAbsoluteUrl(value)) {
850
+ // Track origin for relay allowlisting
851
+ const origin = getOrigin(value);
852
+ if (origin && this.addKnownOrigin(origin, rootOrigin)) {
853
+ logger.debug(`[${this.name}] Discovered origin from ${field}: ${origin} [root=${rootOrigin}]`);
854
+ }
855
+ // Track normalized endpoint URL for buffering and registration detection
856
+ const normalized = normalizeEndpointUrl(value);
857
+ this.discoveredAuthEndpoints.add(normalized);
858
+ if (field === 'registration_endpoint' || field === 'registration_client_uri') {
859
+ this.discoveredRegistrationEndpoints.add(normalized);
860
+ logger.debug(`[${this.name}] Discovered registration endpoint: ${normalized}`);
861
+ }
862
+ }
863
+ }
864
+ // Recurse into nested objects
865
+ for (const value of Object.values(record)) {
866
+ if (typeof value === 'object' && value !== null) {
867
+ this.discoverOrigins(value, rootOrigin);
868
+ }
869
+ }
870
+ }
871
+ /**
872
+ * Recursively rewrite URL strings in a JSON value.
873
+ * Skips fields in SKIP_REWRITE_FIELDS (token audience identifiers).
874
+ */
875
+ rewriteJsonValue(value, parentKey, rootOrigin) {
876
+ if (typeof value === 'string') {
877
+ // Special handling: 'resource' is a token audience identifier that the MCP SDK
878
+ // also validates against the connected URL. Map known MCP server URLs to their
879
+ // proxy /mcp_auth URL; leave others unchanged (never rewrite as /mcp_relay).
880
+ if (parentKey === 'resource') {
881
+ if (isAbsoluteUrl(value)) {
882
+ let mapped = this.mcpUrlMapping.get(value);
883
+ // When the SDK uses cached relay URLs, mcpUrlMapping is empty because
884
+ // handleMCPAuth never ran. Reconstruct the mapping on-the-fly: if the
885
+ // resource value's origin is a known MCP server origin (auto-registered
886
+ // from the cached relay URL), build the proxy URL from it.
887
+ if (!mapped && this.proxyBaseUrl) {
888
+ const valOrigin = getOrigin(value);
889
+ if (valOrigin && this.mcpServerOrigins.has(valOrigin)) {
890
+ mapped = `${this.proxyBaseUrl}/mcp_auth?original=${value}`;
891
+ this.mcpUrlMapping.set(value, mapped);
892
+ logger.debug(`[${this.name}] resource field: reconstructed mapping for cached session: "${value}" → "${mapped}"`);
893
+ }
894
+ }
895
+ logger.debug(`[${this.name}] resource field: value="${value}" mapped=${mapped ? `"${mapped}"` : 'null (no mapping, keeping as-is)'} mappingKeys=[${[...this.mcpUrlMapping.keys()].join(', ')}]`);
896
+ return mapped || value;
897
+ }
898
+ return value;
899
+ }
900
+ // Skip rewriting for token identifiers and browser-facing endpoints
901
+ if (parentKey && (SKIP_REWRITE_FIELDS.has(parentKey) || BROWSER_FACING_FIELDS.has(parentKey))) {
902
+ return value;
903
+ }
904
+ if (isAbsoluteUrl(value)) {
905
+ return this.rewriteUrl(value, rootOrigin);
906
+ }
907
+ return value;
908
+ }
909
+ if (Array.isArray(value)) {
910
+ return value.map(item => this.rewriteJsonValue(item, parentKey, rootOrigin));
911
+ }
912
+ if (typeof value === 'object' && value !== null) {
913
+ const result = {};
914
+ for (const [key, val] of Object.entries(value)) {
915
+ result[key] = this.rewriteJsonValue(val, key, rootOrigin);
916
+ }
917
+ return result;
918
+ }
919
+ return value;
920
+ }
921
+ /**
922
+ * Rewrite an external URL to a proxy relay URL.
923
+ * Uses the two-segment scheme: /mcp_relay/<root_b64>/<relay_b64>/<path>
924
+ * The root segment enables per-flow origin validation in the relay handler.
925
+ * Unknown origins are passed through unchanged.
926
+ */
927
+ rewriteUrl(urlStr, rootOrigin) {
928
+ if (!this.proxyBaseUrl)
929
+ return urlStr;
930
+ const origin = getOrigin(urlStr);
931
+ if (!origin)
932
+ return urlStr;
933
+ // Actively block private/loopback URLs — prevent the client from ever receiving
934
+ // internal-network URLs that a malicious auth server might inject.
935
+ if (isPrivateOrLoopbackOrigin(origin)) {
936
+ logger.debug(`[${this.name}] Blocked private/loopback URL in response: ${origin}`);
937
+ return 'urn:codemie:blocked:private-network';
938
+ }
939
+ if (!this.isKnownOrigin(origin, rootOrigin)) {
940
+ logger.debug(`[${this.name}] rewriteUrl: origin "${origin}" not known for root "${rootOrigin}" — passing through`);
941
+ return urlStr; // Unknown external origin — don't rewrite
942
+ }
943
+ // Extract path + query + fragment after the origin
944
+ const pathAndRest = urlStr.slice(origin.length); // e.g. "/path?query=1"
945
+ const encodedRoot = base64urlEncode(rootOrigin);
946
+ const encodedRelay = base64urlEncode(origin);
947
+ const rewritten = `${this.proxyBaseUrl}/mcp_relay/${encodedRoot}/${encodedRelay}${pathAndRest}`;
948
+ logger.debug(`[${this.name}] rewriteUrl: "${urlStr}" → "${rewritten}"`);
949
+ return rewritten;
950
+ }
951
+ // ─── Response Header Rewriting ───────────────────────────────────────────
952
+ /**
953
+ * Rewrite URLs found in response headers.
954
+ * Targets: WWW-Authenticate (resource_metadata), Location (absolute and relative)
955
+ */
956
+ rewriteResponseHeaders(headers, upstreamUrl, rootOrigin) {
957
+ const result = { ...headers };
958
+ // Rewrite WWW-Authenticate header (resource_metadata="<url>")
959
+ // Handle both single string and string[] (Node.js may expose either form)
960
+ const wwwAuth = result['www-authenticate'];
961
+ if (typeof wwwAuth === 'string') {
962
+ result['www-authenticate'] = this.rewriteWWWAuthenticate(wwwAuth, rootOrigin);
963
+ }
964
+ else if (Array.isArray(wwwAuth)) {
965
+ result['www-authenticate'] = wwwAuth.map(v => this.rewriteWWWAuthenticate(v, rootOrigin));
966
+ }
967
+ // Rewrite Location header (redirects — both absolute and relative)
968
+ const location = result['location'];
969
+ if (typeof location === 'string') {
970
+ let absoluteLocation = location;
971
+ // Resolve relative redirects against the upstream URL's origin
972
+ if (!isAbsoluteUrl(location)) {
973
+ const upstreamOrigin = getOrigin(upstreamUrl);
974
+ if (upstreamOrigin) {
975
+ try {
976
+ absoluteLocation = new URL(location, upstreamUrl).href;
977
+ }
978
+ catch {
979
+ // Can't resolve — leave as-is
980
+ }
981
+ }
982
+ }
983
+ if (isAbsoluteUrl(absoluteLocation)) {
984
+ const locOrigin = getOrigin(absoluteLocation);
985
+ if (locOrigin) {
986
+ this.addKnownOrigin(locOrigin, rootOrigin);
987
+ }
988
+ result['location'] = this.rewriteUrl(absoluteLocation, rootOrigin);
989
+ }
990
+ }
991
+ return result;
992
+ }
993
+ /**
994
+ * Rewrite URLs inside a WWW-Authenticate header value.
995
+ * Targets: resource_metadata="<url>"
996
+ */
997
+ rewriteWWWAuthenticate(header, rootOrigin) {
998
+ logger.debug(`[${this.name}] WWW-Authenticate header BEFORE rewrite: ${header}`);
999
+ // Match resource_metadata="<url>"
1000
+ const rewritten = header.replace(/resource_metadata="([^"]+)"/g, (_match, url) => {
1001
+ if (isAbsoluteUrl(url)) {
1002
+ // Discover and register the origin (with SSRF validation and TTL)
1003
+ const origin = getOrigin(url);
1004
+ if (origin) {
1005
+ this.addKnownOrigin(origin, rootOrigin);
1006
+ }
1007
+ const rewritten = this.rewriteUrl(url, rootOrigin);
1008
+ return `resource_metadata="${rewritten}"`;
1009
+ }
1010
+ return _match;
1011
+ });
1012
+ logger.debug(`[${this.name}] WWW-Authenticate header AFTER rewrite: ${rewritten}`);
1013
+ return rewritten;
1014
+ }
1015
+ // ─── Helpers ─────────────────────────────────────────────────────────────
1016
+ /**
1017
+ * Extract original URL from a proxy /mcp_auth URL.
1018
+ * Returns null if the URL is not a proxy URL.
1019
+ */
1020
+ extractOriginalFromProxyUrl(url) {
1021
+ if (!this.proxyBaseUrl)
1022
+ return null;
1023
+ const mcpAuthPrefix = this.proxyBaseUrl + '/mcp_auth';
1024
+ if (!url.startsWith(mcpAuthPrefix))
1025
+ return null;
1026
+ const requestPath = url.slice(this.proxyBaseUrl.length);
1027
+ return this.extractOriginalUrl(requestPath);
1028
+ }
1029
+ /**
1030
+ * Reverse-rewrite 'resource' query parameter in a URL.
1031
+ * Converts proxy /mcp_auth URL back to the original MCP server URL.
1032
+ */
1033
+ reverseRewriteResourceParam(url) {
1034
+ const resource = url.searchParams.get('resource');
1035
+ if (resource) {
1036
+ const original = this.extractOriginalFromProxyUrl(resource);
1037
+ if (original) {
1038
+ url.searchParams.set('resource', original);
1039
+ logger.debug(`[${this.name}] Reverse-rewrote resource query param to original URL`);
1040
+ }
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Reverse-rewrite 'resource' field in request body (JSON or form-encoded).
1045
+ * Converts proxy /mcp_auth URL back to the original MCP server URL.
1046
+ */
1047
+ reverseRewriteResourceInBody(body, headers) {
1048
+ const contentType = headers['content-type'] || '';
1049
+ // JSON body (some OAuth implementations accept JSON)
1050
+ if (contentType.includes('application/json')) {
1051
+ try {
1052
+ const parsed = JSON.parse(body.toString('utf-8'));
1053
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.resource === 'string') {
1054
+ const original = this.extractOriginalFromProxyUrl(parsed.resource);
1055
+ if (original) {
1056
+ parsed.resource = original;
1057
+ const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8');
1058
+ headers['content-length'] = String(newBody.length);
1059
+ logger.debug(`[${this.name}] Reverse-rewrote resource in JSON body to original URL`);
1060
+ return newBody;
1061
+ }
1062
+ }
1063
+ }
1064
+ catch { /* not valid JSON */ }
1065
+ }
1066
+ // Form-encoded body (standard OAuth token requests)
1067
+ if (contentType.includes('application/x-www-form-urlencoded')) {
1068
+ try {
1069
+ const bodyStr = body.toString('utf-8');
1070
+ const params = new URLSearchParams(bodyStr);
1071
+ const resource = params.get('resource');
1072
+ if (resource) {
1073
+ const original = this.extractOriginalFromProxyUrl(resource);
1074
+ if (original) {
1075
+ params.set('resource', original);
1076
+ const newBody = Buffer.from(params.toString(), 'utf-8');
1077
+ headers['content-length'] = String(newBody.length);
1078
+ logger.debug(`[${this.name}] Reverse-rewrote resource in form body to original URL`);
1079
+ return newBody;
1080
+ }
1081
+ }
1082
+ }
1083
+ catch { /* not valid form data */ }
1084
+ }
1085
+ return body;
1086
+ }
1087
+ /**
1088
+ * Check if the target URL is an OAuth dynamic client registration endpoint.
1089
+ * First checks against endpoints discovered from auth server metadata
1090
+ * (registration_endpoint, registration_client_uri), then falls back to
1091
+ * the /register path suffix heuristic for pre-metadata requests.
1092
+ */
1093
+ isRegistrationEndpoint(targetUrl) {
1094
+ // Check against dynamically discovered registration endpoints
1095
+ const normalized = normalizeEndpointUrl(targetUrl);
1096
+ if (this.discoveredRegistrationEndpoints.has(normalized)) {
1097
+ return true;
1098
+ }
1099
+ // Fallback: path suffix heuristic for before metadata is discovered
1100
+ try {
1101
+ return new URL(targetUrl).pathname.toLowerCase().endsWith('/register');
1102
+ }
1103
+ catch {
1104
+ return false;
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Check if a response is auth metadata that needs body URL rewriting.
1109
+ * First checks against endpoints discovered from auth server metadata, then
1110
+ * falls back to path heuristics (401, .well-known/, /register, /token, /authorize).
1111
+ * Discovered endpoints cover non-standard paths that the heuristics would miss.
1112
+ */
1113
+ isAuthMetadataResponse(targetUrl, statusCode) {
1114
+ if (statusCode === 401)
1115
+ return true;
1116
+ // Check against dynamically discovered auth endpoints
1117
+ const normalized = normalizeEndpointUrl(targetUrl);
1118
+ if (this.discoveredAuthEndpoints.has(normalized)) {
1119
+ return true;
1120
+ }
1121
+ // Fallback: path heuristics for well-known patterns (before metadata is discovered)
1122
+ try {
1123
+ const path = new URL(targetUrl).pathname.toLowerCase();
1124
+ return path.includes('/.well-known/')
1125
+ || path.endsWith('/register')
1126
+ || path.endsWith('/token')
1127
+ || path.endsWith('/authorize');
1128
+ }
1129
+ catch {
1130
+ return false;
1131
+ }
1132
+ }
1133
+ /**
1134
+ * Extract the original URL from /mcp_auth?original=<url>
1135
+ * Handles both URL-encoded and raw (unencoded) values.
1136
+ *
1137
+ * Order: raw extraction FIRST (preserves unencoded nested query parameters like
1138
+ * ?original=https://host/p?aud=x&target=https://o/mcp), then URLSearchParams as
1139
+ * fallback for properly percent-encoded values. URLSearchParams must NOT run first
1140
+ * because it silently truncates unencoded nested URLs at the first `&`.
1141
+ */
1142
+ extractOriginalUrl(requestUrl) {
1143
+ let candidate = null;
1144
+ // 1. Raw extraction — takes everything after "original=" to preserve unencoded
1145
+ // nested query parameters. Boundary check ensures we match a real top-level
1146
+ // param (at start or preceded by &), not a substring inside another value.
1147
+ // Contract: when using raw (unencoded) URLs, `original=` must be the last param.
1148
+ const queryStart = requestUrl.indexOf('?');
1149
+ if (queryStart !== -1) {
1150
+ const queryString = requestUrl.slice(queryStart + 1);
1151
+ const prefix = 'original=';
1152
+ const idx = queryString.indexOf(prefix);
1153
+ if (idx !== -1 && (idx === 0 || queryString[idx - 1] === '&')) {
1154
+ const rawValue = queryString.slice(idx + prefix.length);
1155
+ // Only use raw extraction for unencoded URLs (starts with http:// or https://).
1156
+ // Encoded values (https%3A...) fall through to URLSearchParams which correctly
1157
+ // separates top-level query params (e.g., ?original=https%3A...&trace=1).
1158
+ if (isAbsoluteUrl(rawValue)) {
1159
+ candidate = rawValue;
1160
+ }
1161
+ }
1162
+ }
1163
+ // 2. URLSearchParams fallback — handles properly percent-encoded values where
1164
+ // raw extraction didn't find an absolute URL (e.g., double-encoded values).
1165
+ if (!candidate) {
1166
+ try {
1167
+ const parsed = new URL(requestUrl, 'http://localhost');
1168
+ const original = parsed.searchParams.get('original');
1169
+ if (original && isAbsoluteUrl(original)) {
1170
+ candidate = original;
1171
+ }
1172
+ }
1173
+ catch {
1174
+ // Not a valid URL
1175
+ }
1176
+ }
1177
+ // 3. Validate the candidate is fully parseable as a URL.
1178
+ // Catches inputs like "http://%zz" that pass isAbsoluteUrl but fail new URL().
1179
+ if (candidate) {
1180
+ try {
1181
+ new URL(candidate);
1182
+ return candidate;
1183
+ }
1184
+ catch {
1185
+ return null; // Triggers clean 400 "Missing or invalid" response
1186
+ }
1187
+ }
1188
+ return null;
1189
+ }
1190
+ /** Send a JSON error response */
1191
+ sendError(res, statusCode, message) {
1192
+ res.statusCode = statusCode;
1193
+ res.setHeader('Content-Type', 'application/json');
1194
+ res.end(JSON.stringify({
1195
+ error: { code: 'MCP_AUTH_ERROR', message }
1196
+ }));
1197
+ logger.debug(`[${this.name}] Error ${statusCode}: ${message}`);
1198
+ }
1199
+ }
1200
+ //# sourceMappingURL=mcp-auth.plugin.js.map