mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
* 🪟 feat: Add allowedAddresses Exemption List For SSRF-Guarded Targets LibreChat already blocks SSRF-prone targets (private IPs, loopback, link-local, .internal/.local TLDs) at every server-side fetch site that consumes user-controllable URLs — custom-endpoint baseURLs, MCP servers, OpenAPI Actions, and OAuth endpoints. The only existing escape hatch is `allowedDomains`, but that flips the field into a strict whitelist: adding `127.0.0.1` to permit a self-hosted Ollama also blocks every public destination that isn't in the list. Introduce `allowedAddresses` as the orthogonal primitive: a private- IP-space exemption list. When a hostname or its resolved IP appears in the list, the SSRF block is bypassed for that target. Public destinations remain reachable. Operators can now run self-hosted LLMs / MCP servers / Action endpoints on private addresses without weakening the default-deny posture for everything else. Schema additions in `packages/data-provider/src/config.ts`: - `endpoints.allowedAddresses` (new — gates `validateEndpointURL`) - `mcpSettings.allowedAddresses` (parallel to `allowedDomains`) - `actions.allowedAddresses` (parallel to `allowedDomains`) Core changes in `packages/api/src/auth/`: - New `isAddressAllowed(hostnameOrIP, allowedAddresses)` — pure, case-insensitive, bracket-stripped literal match. - Threaded the list through `isSSRFTarget`, `resolveHostnameSSRF`, `isDomainAllowedCore`, `isActionDomainAllowed`, `isMCPDomainAllowed`, `isOAuthUrlAllowed`, and `validateEndpointURL`. - Extended `createSSRFSafeAgents` and `createSSRFSafeUndiciConnect` to accept the list, building an SSRF-safe DNS lookup that exempts matching hostnames/IPs at TCP connect time (TOCTOU-safe). Wiring: - Custom and OpenAI endpoint initialize sites pass `endpoints.allowedAddresses` to `validateEndpointURL`. - `MCPServersRegistry` stores `allowedAddresses` and exposes it via `getAllowedAddresses()`. The factory, connection class, manager, `UserConnectionManager`, and `ConnectionsRepository` all thread it through to the SSRF utilities. - `MCPOAuthHandler.initiateOAuthFlow`, `refreshOAuthTokens`, and `validateOAuthUrl` accept the list and consult it on every URL validation along the OAuth chain. - `ToolService`, `ActionService`, and the assistants/agents action routes pass `actions.allowedAddresses` to `isActionDomainAllowed` and to `createSSRFSafeAgents` for runtime action calls. - `initializeMCPs.js` reads `mcpSettings.allowedAddresses` from the app config and forwards it to the registry constructor. Documentation: - `librechat.example.yaml` shows the new field next to each existing `allowedDomains` block, with a note clarifying that `allowedAddresses` is an exemption list (not a whitelist). Tests: - Unit tests for `isAddressAllowed` covering literal IPs, hostnames, IPv6 brackets, case insensitivity, and partial-match rejection. - Exemption tests for every entry point: `isSSRFTarget`, `resolveHostnameSSRF`, `validateEndpointURL`, `isActionDomainAllowed`, `isMCPDomainAllowed`, `isOAuthUrlAllowed`. - Existing tests updated to reflect the new optional parameter. Default behavior is unchanged: omitted = empty list = no exemptions. * 🩹 fix: Plumb allowedAddresses Through AppConfig endpoints Type The initial PR added `endpoints.allowedAddresses` to the data-provider config schema and consumed it in the endpoint initialize sites, but the runtime `AppConfig.endpoints` shape in `@librechat/data-schemas` was a hand-maintained subset that didn't include the new field — so `tsc` rejected `appConfig.endpoints.allowedAddresses`. Add the field to `AppConfig['endpoints']` in `packages/data-schemas/src/types/app.ts` and forward it from the loaded config in `packages/data-schemas/src/app/endpoints.ts` so the runtime config carries the value. Update `initializeMCPs.spec.js` to expect the third positional argument (`allowedAddresses`) on the `createMCPServersRegistry` call. * 🩹 fix: Enforce allowedDomains Before allowedAddresses In isOAuthUrlAllowed The initial implementation checked the address exemption first, so a URL whose hostname appeared in `allowedAddresses` would return true even when the admin had configured `allowedDomains` as a strict bound on OAuth endpoints. A malicious MCP server could advertise OAuth metadata, token, or revocation URLs at any address the admin had permitted for an unrelated reason (a self-hosted LLM at `127.0.0.1`, for example) and pass validation, expanding SSRF reach beyond the configured domain whitelist. Reorder: when `allowedDomains` is set, treat it as authoritative — return true only if the URL matches a domain entry, otherwise fall through to false. The address exemption only applies when no `allowedDomains` is configured (mirrors how the downstream SSRF check in `validateOAuthUrl` consults `allowedAddresses`). Add a regression test asserting that an `allowedAddresses` entry does not broaden a configured `allowedDomains` list. Reported by chatgpt-codex-connector on PR #12933. * 🩹 fix: Forward allowedAddresses To Remaining OAuth Callers Two `MCPOAuthHandler` callers still used the pre-feature signatures and were silently dropping the new `allowedAddresses` argument: - `api/server/routes/mcp.js` invoked `initiateOAuthFlow` with the old 5-argument shape, so OAuth flows initiated through the route handler ignored the registry's `getAllowedAddresses()` and would reject any metadata/authorization/token URL on a permitted private host. - `api/server/controllers/UserController.js#maybeUninstallOAuthMCP` invoked `revokeOAuthToken` without the address exemption, so uninstalling an OAuth-backed MCP server on a permitted private host would fail at the revocation step even though the rest of the MCP connection path now permits it. Both sites now read `allowedAddresses` from the registry alongside `allowedDomains` and forward it. Reported by Copilot on PR #12933. * 🩹 fix: Update Test Mocks And Assertions For OAuth allowedAddresses The previous commit started passing `allowedAddresses` to `MCPOAuthHandler.initiateOAuthFlow` from `api/server/routes/mcp.js` and to `MCPOAuthHandler.revokeOAuthToken` from `api/server/controllers/UserController.js`, but the corresponding test files mocked the registry without `getAllowedAddresses` (causing `TypeError`s) and asserted the old positional shape on `toHaveBeenCalledWith`. Update the mocks and assertions to match the new arity: - `api/server/routes/__tests__/mcp.spec.js`: add `getAllowedDomains`/`getAllowedAddresses` to the registry mock and expect the additional positional args on `initiateOAuthFlow`. - `api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js`: add a `getAllowedAddresses` mock alongside the existing `getAllowedDomains` and seed it in `setupOAuthServerFound`. - `api/server/controllers/__tests__/UserController.mcpOAuth.spec.js`: add `getAllowedAddresses` to the registry mock and expect the trailing `null` arg on the three `revokeOAuthToken` assertions. * 🛡️ fix: Address Comprehensive Review — Scope allowedAddresses To Private IP Space Major findings from the comprehensive PR review (severity → fix): **CRITICAL — `validateOAuthUrl` SSRF fallback bypass.** When `allowedDomains` is configured and a URL fails the whitelist, the SSRF fallback in `validateOAuthUrl` was still passing `allowedAddresses` to `isSSRFTarget` / `resolveHostnameSSRF`, letting a malicious MCP server advertise OAuth endpoints at any address the admin had permitted for an unrelated reason. Suppress `allowedAddresses` in the fallback when `allowedDomains` is active — the address exemption is opt-in for the no-whitelist mode only. **MAJOR — WebSocket transport SSRF check ignored exemptions.** The `constructTransport` WebSocket branch called `resolveHostnameSSRF(wsHostname)` without `this.allowedAddresses`, so a permitted private MCP server would pass `isMCPDomainAllowed` but be blocked at transport creation. Forward the exemption. **Scope `allowedAddresses` to private IP space only (operator directive).** The exemption list is for permitting private/internal targets; it must not be a back-door to broaden trust to public destinations. - Schema (`packages/data-provider/src/config.ts`): new `allowedAddressesSchema` rejects URLs (`://`), paths/CIDR (`/`), whitespace, and public IPv4/IPv6 literals at config-load time. Wired into `endpoints`, `mcpSettings`, and `actions`. - Runtime (`packages/api/src/auth/domain.ts`): `isAddressAllowed` now drops public-IP candidates and public-IP entries on the match path — defense in depth so a misconfigured runtime list never grants exemption. - Hot path (`packages/api/src/auth/agent.ts`): `buildSSRFSafeLookup` pre-normalizes the list into a `Set<string>` once at construction and applies the same scoping filter, so the connect-time DNS lookup is an O(1) Set membership check instead of a full re-iterate-and-normalize on every outbound request. **Test coverage for the connect-time and OAuth-fallback paths.** - `agent.spec.ts`: new describe block exercising `buildSSRFSafeLookup` and `createSSRFSafe*` with `allowedAddresses` — hostname-literal exemption, resolved-IP exemption, public-IP scoping, URL/CIDR/whitespace rejection, and the default no-list block. - `handler.allowedAddresses.test.ts` (new): integration tests for `validateOAuthUrl` — covers both the no-domains-set "permit private" path and the strict-bound regression where `allowedAddresses` must NOT bypass `allowedDomains`. **Documentation & cleanup.** - `connection.ts` redirect SSRF check: explicit comment that `allowedAddresses` is intentionally NOT consulted for redirect targets (server-controlled, must not inherit the admin's exemption). - `MCPConnectionFactory.test.ts`: replaced an `eslint-disable` with a proper `import { getTenantId } from '@librechat/data-schemas'`. The disable was added to make a pre-existing `require()` quiet — the cleaner fix is to use the existing top-level import. Updated `MCPConnectionSSRF.test.ts` WebSocket SSRF assertions to match the new two-argument call shape (`hostname, allowedAddresses`). * 🩹 fix: Require Absolute URL Before allowedAddresses Trust Bypass In isOAuthUrlAllowed `parseDomainSpec` is lenient — it silently prepends `https://` to schemeless inputs so it can match patterns like bare `example.com`. That leniency leaked into `isOAuthUrlAllowed`'s new `allowedAddresses` short-circuit: a value like `10.0.0.5/oauth` (no scheme) would parse successfully via the prepended default, hit the address-exemption path, return `true`, and skip `validateOAuthUrl`'s strict `new URL(url)` parse-or-throw — only to fail later in OAuth discovery with a less clear runtime error. Add a strict `new URL(url)` gate at the top of `isOAuthUrlAllowed`. Schemeless inputs now fall through to `validateOAuthUrl`'s explicit "Invalid OAuth <field>" rejection. Tests added in both `auth/domain.spec.ts` (unit) and the OAuth handler integration spec (end-to-end). Reported by chatgpt-codex-connector (P2) on PR #12933. * 🛡️ fix: Address Follow-Up Comprehensive Review — Schema Tests, Shared Normalization, host:port Auditing the second comprehensive review: **F1 MAJOR — schema validation untested.** `allowedAddressesSchema` had zero coverage, so a regression in the three refinement stages or the three wiring locations (`endpoints` / `mcpSettings` / `actions`) would silently let invalid entries reach the runtime. Added a dedicated `describe('allowedAddressesSchema')` block in `config.spec.ts` covering: valid private IPs (v4 + v6, including the previously-missed 192.0.0.0/24 range), accepted hostnames, all rejection categories (URLs, CIDR, paths, whitespace tabs/newlines, host:port, public IP literals), and full `configSchema.parse()` integration at each of the three nesting points. **F2 MINOR — `isPrivateIPv4Literal` divergence.** The schema reimpl in `packages/data-provider` was discarding the `c` octet, so the `192.0.0.0/24` (RFC 5736 IETF protocol assignments) range that the authoritative `isPrivateIPv4` accepts was being rejected with a misleading "public IP" error. Destructure `c` and add the missing range check; covered by the new schema tests. **F3 MINOR — DRY violation across `domain.ts` and `agent.ts`.** Both files had independent normalization implementations with a subtle whitespace-check divergence (`/\s/` vs `.includes(' ')`). Extracted the shared logic into a new `packages/api/src/auth/allowedAddresses.ts` module that both consumers import: - `normalizeAddressEntry(entry)` — single-entry shape check - `looksLikeHostPort(entry)` — host:port detector (used by F4) - `normalizeAllowedAddressesSet(list)` — pre-normalized Set for the connect-time hot path - `isAddressInAllowedSet(candidate, set)` — membership check that enforces private-IP scoping on the candidate Both `isAddressAllowed` (preflight) and `buildSSRFSafeLookup` (connect) now go through the same primitives; the whitespace divergence is gone. To break the import cycle (`allowedAddresses` needs `isPrivateIP`, `domain` previously owned it), extracted IP private-range detection into a leaf `auth/ip.ts` module. `domain.ts` re-exports `isPrivateIP` for backward compatibility with existing call sites. **F4 MINOR — `host:port` silently misclassified.** Entries like `localhost:8080` previously slipped through the URL/path guard, were mis-detected as IPv6, failed `isPrivateIP`, and were silently dropped with a misleading "public IP" schema error. Added an explicit `looksLikeHostPort` check with a clear error: "allowedAddresses entries must not include a port — list the bare hostname or IP only." Bare `::1`, `[::1]`, and other valid IPv6 literals are intentionally not matched (regex distinguishes by colon count and the bracketed `[ipv6]:port` form). **F5 MINOR — hostname-trust documentation gap.** Hostname entries short-circuit `resolveHostnameSSRF` before any DNS lookup — that's a deliberate design (admin trusts the name) but it means the exemption follows whatever the name resolves to at runtime. Added an explicit note in `librechat.example.yaml` for both `mcpSettings.allowedAddresses` and `endpoints.allowedAddresses`: "a hostname entry trusts whatever IP that name resolves to. Only list hostnames whose DNS you control. Prefer literal IPs when you can." **F6** (8 positional params) is flagged for follow-up; refactor to an options object is a breaking-API change deferred to a separate PR. **F7** (redirect/WebSocket asymmetry, NIT, conf 40) — skipping; the existing inline comment is sufficient. * 🧹 chore: Address Follow-Up NITs — Import Order And Mirror-Function Naming Three NITs from the latest comprehensive review: **NIT #1 (conf 85) — local import order.** AGENTS.md requires local imports sorted longest-to-shortest. Both `domain.ts` and `agent.ts` had `./ip` (shorter) before `./allowedAddresses` (longer). Swapped. **NIT #2 (conf 60) — missing cross-reference.** The schema-side `isHostPortShape` in `packages/data-provider/src/config.ts` had no note pointing at the canonical runtime mirror. Added a JSDoc paragraph explaining the mirror relationship and why a local copy exists (the data-provider package can't import from `@librechat/api` without creating a circular dependency). **NIT #3 (conf 50) — naming inconsistency.** Renamed `isHostPortShape` → `looksLikeHostPort` so the schema mirror matches the runtime helper exactly. Kept as a separate function (not a shared import) for the same circular-dependency reason; the matching name makes it obvious they should stay in lockstep.
859 lines
27 KiB
JavaScript
859 lines
27 KiB
JavaScript
const { Router } = require('express');
|
|
const { logger, getTenantId, tenantStorage } = require('@librechat/data-schemas');
|
|
const {
|
|
CacheKeys,
|
|
Constants,
|
|
PermissionBits,
|
|
PermissionTypes,
|
|
Permissions,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
getBasePath,
|
|
createSafeUser,
|
|
MCPOAuthHandler,
|
|
MCPTokenStorage,
|
|
setOAuthSession,
|
|
PENDING_STALE_MS,
|
|
getUserMCPAuthMap,
|
|
validateOAuthCsrf,
|
|
OAUTH_CSRF_COOKIE,
|
|
setOAuthCsrfCookie,
|
|
generateCheckAccess,
|
|
validateOAuthSession,
|
|
OAUTH_SESSION_COOKIE,
|
|
} = require('@librechat/api');
|
|
const {
|
|
createMCPServerController,
|
|
updateMCPServerController,
|
|
deleteMCPServerController,
|
|
getMCPServersList,
|
|
getMCPServerById,
|
|
getMCPTools,
|
|
} = require('~/server/controllers/mcp');
|
|
const {
|
|
getOAuthReconnectionManager,
|
|
getMCPServersRegistry,
|
|
getFlowStateManager,
|
|
getMCPManager,
|
|
} = require('~/config');
|
|
const {
|
|
getServerConnectionStatus,
|
|
resolveConfigServers,
|
|
getMCPSetupData,
|
|
} = require('~/server/services/MCP');
|
|
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
|
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
|
const { getLogStores } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const router = Router();
|
|
|
|
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
|
|
|
|
const checkMCPUsePermissions = generateCheckAccess({
|
|
permissionType: PermissionTypes.MCP_SERVERS,
|
|
permissions: [Permissions.USE],
|
|
getRoleByName: db.getRoleByName,
|
|
});
|
|
|
|
const checkMCPCreate = generateCheckAccess({
|
|
permissionType: PermissionTypes.MCP_SERVERS,
|
|
permissions: [Permissions.USE, Permissions.CREATE],
|
|
getRoleByName: db.getRoleByName,
|
|
});
|
|
|
|
/**
|
|
* Get all MCP tools available to the user
|
|
* Returns only MCP tools, completely decoupled from regular LibreChat tools
|
|
*/
|
|
router.get('/tools', requireJwtAuth, async (req, res) => {
|
|
return getMCPTools(req, res);
|
|
});
|
|
|
|
/**
|
|
* Initiate OAuth flow
|
|
* This endpoint is called when the user clicks the auth link in the UI
|
|
*/
|
|
router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async (req, res) => {
|
|
try {
|
|
const { serverName } = req.params;
|
|
const { userId, flowId } = req.query;
|
|
const user = req.user;
|
|
|
|
// Verify the userId matches the authenticated user
|
|
if (userId !== user.id) {
|
|
return res.status(403).json({ error: 'User mismatch' });
|
|
}
|
|
|
|
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
|
|
/** Flow state to retrieve OAuth config */
|
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
if (!flowState) {
|
|
logger.error('[MCP OAuth] Flow state not found', { flowId });
|
|
return res.status(404).json({ error: 'Flow not found' });
|
|
}
|
|
|
|
const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
|
|
if (!serverUrl || !oauthConfig) {
|
|
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
|
|
return res.status(400).json({ error: 'Invalid flow state' });
|
|
}
|
|
|
|
const configServers = await resolveConfigServers(req);
|
|
const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
|
|
const registry = getMCPServersRegistry();
|
|
const allowedDomains = registry.getAllowedDomains();
|
|
const allowedAddresses = registry.getAllowedAddresses();
|
|
const {
|
|
authorizationUrl,
|
|
flowId: oauthFlowId,
|
|
flowMetadata,
|
|
} = await MCPOAuthHandler.initiateOAuthFlow(
|
|
serverName,
|
|
serverUrl,
|
|
userId,
|
|
oauthHeaders,
|
|
oauthConfig,
|
|
allowedDomains,
|
|
undefined,
|
|
allowedAddresses,
|
|
);
|
|
|
|
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
|
|
|
|
await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
|
|
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
|
|
res.redirect(authorizationUrl);
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
|
|
res.status(500).json({ error: 'Failed to initiate OAuth' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* OAuth callback handler
|
|
* This handles the OAuth callback after the user has authorized the application
|
|
*/
|
|
router.get('/:serverName/oauth/callback', async (req, res) => {
|
|
const basePath = getBasePath();
|
|
try {
|
|
const { serverName } = req.params;
|
|
const { code, state, error: oauthError } = req.query;
|
|
|
|
logger.debug('[MCP OAuth] Callback received', {
|
|
serverName,
|
|
code: code ? 'present' : 'missing',
|
|
state,
|
|
error: oauthError,
|
|
});
|
|
|
|
if (oauthError) {
|
|
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
|
|
// Gate failFlow behind callback validation to prevent DoS via leaked state
|
|
if (state && typeof state === 'string') {
|
|
try {
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
|
|
if (flowId) {
|
|
const flowParts = flowId.split(':');
|
|
const [flowUserId] = flowParts;
|
|
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
|
|
if (hasCsrf || hasSession) {
|
|
await flowManager.failFlow(flowId, 'mcp_oauth', String(oauthError));
|
|
logger.debug('[MCP OAuth] Marked flow as FAILED with OAuth error', {
|
|
flowId,
|
|
error: oauthError,
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.debug('[MCP OAuth] Could not mark flow as failed', err);
|
|
}
|
|
}
|
|
return res.redirect(
|
|
`${basePath}/oauth/error?error=${encodeURIComponent(String(oauthError))}`,
|
|
);
|
|
}
|
|
|
|
if (!code || typeof code !== 'string') {
|
|
logger.error('[MCP OAuth] Missing or invalid code');
|
|
return res.redirect(`${basePath}/oauth/error?error=missing_code`);
|
|
}
|
|
|
|
if (!state || typeof state !== 'string') {
|
|
logger.error('[MCP OAuth] Missing or invalid state');
|
|
return res.redirect(`${basePath}/oauth/error?error=missing_state`);
|
|
}
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
|
|
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
|
|
if (!flowId) {
|
|
logger.error('[MCP OAuth] Could not resolve state to flow ID', { state });
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
|
|
|
|
const flowParts = flowId.split(':');
|
|
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) {
|
|
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
const [flowUserId] = flowParts;
|
|
|
|
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
|
|
let hasActiveFlow = false;
|
|
if (!hasCsrf && !hasSession) {
|
|
const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
|
|
hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
|
|
if (hasActiveFlow) {
|
|
logger.debug(
|
|
'[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
|
|
{
|
|
flowId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!hasCsrf && !hasSession && !hasActiveFlow) {
|
|
logger.error(
|
|
'[MCP OAuth] CSRF validation failed: no valid CSRF cookie, session cookie, or active flow',
|
|
{
|
|
flowId,
|
|
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
|
|
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
|
|
},
|
|
);
|
|
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
|
|
}
|
|
|
|
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
|
|
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
|
|
|
|
if (!flowState) {
|
|
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
logger.debug('[MCP OAuth] Flow state details', {
|
|
serverName: flowState.serverName,
|
|
userId: flowState.userId,
|
|
hasMetadata: !!flowState.metadata,
|
|
hasClientInfo: !!flowState.clientInfo,
|
|
hasCodeVerifier: !!flowState.codeVerifier,
|
|
});
|
|
|
|
/** Check if this flow has already been completed (idempotency protection) */
|
|
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
if (currentFlowState?.status === 'COMPLETED') {
|
|
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
|
|
flowId,
|
|
serverName,
|
|
});
|
|
return res.redirect(`${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`);
|
|
}
|
|
|
|
logger.debug('[MCP OAuth] Completing OAuth flow');
|
|
if (!flowState.oauthHeaders) {
|
|
logger.warn(
|
|
'[MCP OAuth] oauthHeaders absent from flow state — config-source server oauth_headers will be empty',
|
|
{ serverName, flowId },
|
|
);
|
|
}
|
|
/**
|
|
* Restore tenant context for the callback body. The callback is a cross-origin
|
|
* redirect from the OAuth provider, so SameSite=Strict cookies (including the
|
|
* JWT) are not sent. The tenantId was stored in the flow metadata at initiation
|
|
* time when the user was authenticated.
|
|
*/
|
|
const runWithTenant = async (fn) => {
|
|
const flowTenantId = flowState.tenantId;
|
|
if (flowTenantId && !getTenantId()) {
|
|
return tenantStorage.run({ tenantId: flowTenantId }, fn);
|
|
}
|
|
return fn();
|
|
};
|
|
|
|
await runWithTenant(async () => {
|
|
const oauthHeaders =
|
|
flowState.oauthHeaders ?? (await getOAuthHeaders(serverName, flowState.userId));
|
|
const tokens = await MCPOAuthHandler.completeOAuthFlow(
|
|
flowId,
|
|
code,
|
|
flowManager,
|
|
oauthHeaders,
|
|
);
|
|
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
|
|
|
/** Persist tokens immediately so reconnection uses fresh credentials */
|
|
if (flowState?.userId && tokens) {
|
|
try {
|
|
await MCPTokenStorage.storeTokens({
|
|
userId: flowState.userId,
|
|
serverName,
|
|
tokens,
|
|
createToken: db.createToken,
|
|
updateToken: db.updateToken,
|
|
findToken: db.findToken,
|
|
clientInfo: flowState.clientInfo,
|
|
metadata: flowState.metadata,
|
|
});
|
|
logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', {
|
|
serverName,
|
|
userId: flowState.userId,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] Failed to store OAuth tokens after callback', error);
|
|
throw error;
|
|
}
|
|
|
|
/**
|
|
* Clear any cached `mcp_get_tokens` flow result so subsequent lookups
|
|
* re-fetch the freshly stored credentials instead of returning stale nulls.
|
|
*/
|
|
if (typeof flowManager?.deleteFlow === 'function') {
|
|
try {
|
|
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
|
|
} catch (error) {
|
|
logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const mcpManager = getMCPManager(flowState.userId);
|
|
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
|
|
|
|
if (flowState.userId !== 'system') {
|
|
const user = { id: flowState.userId };
|
|
|
|
const userConnection = await mcpManager.getUserConnection({
|
|
user,
|
|
serverName,
|
|
flowManager,
|
|
tokenMethods: {
|
|
findToken: db.findToken,
|
|
updateToken: db.updateToken,
|
|
createToken: db.createToken,
|
|
deleteTokens: db.deleteTokens,
|
|
},
|
|
});
|
|
|
|
logger.info(
|
|
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
|
);
|
|
|
|
const oauthReconnectionManager = getOAuthReconnectionManager();
|
|
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
|
|
|
|
const tools = await userConnection.fetchTools();
|
|
await updateMCPServerTools({
|
|
userId: flowState.userId,
|
|
serverName,
|
|
tools,
|
|
});
|
|
} else {
|
|
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
/** ID of the flow that the tool/connection is waiting for */
|
|
const toolFlowId = flowState.metadata?.toolFlowId;
|
|
if (toolFlowId) {
|
|
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
|
|
const completed = await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
|
|
if (!completed) {
|
|
logger.warn(
|
|
'[MCP OAuth] Tool flow state not found during completion — waiter will time out',
|
|
{ toolFlowId },
|
|
);
|
|
}
|
|
}
|
|
}); /* end runWithTenant */
|
|
|
|
/** Redirect to success page with flowId and serverName */
|
|
const redirectUrl = `${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`;
|
|
res.redirect(redirectUrl);
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] OAuth callback error', error);
|
|
res.redirect(`${basePath}/oauth/error?error=callback_failed`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get OAuth tokens for a completed flow
|
|
* This is primarily for user-level OAuth flows
|
|
*/
|
|
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
|
|
try {
|
|
const { flowId } = req.params;
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
|
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
if (!flowState) {
|
|
return res.status(404).json({ error: 'Flow not found' });
|
|
}
|
|
|
|
if (flowState.status !== 'COMPLETED') {
|
|
return res.status(400).json({ error: 'Flow not completed' });
|
|
}
|
|
|
|
res.json({ tokens: flowState.result });
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] Failed to get tokens', error);
|
|
res.status(500).json({ error: 'Failed to get tokens' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Set CSRF binding cookie for OAuth flows initiated outside of HTTP request/response
|
|
* (e.g. during chat via SSE). The frontend should call this before opening the OAuth URL
|
|
* so the callback can verify the browser matches the flow initiator.
|
|
*/
|
|
router.post('/:serverName/oauth/bind', requireJwtAuth, setOAuthSession, async (req, res) => {
|
|
try {
|
|
const { serverName } = req.params;
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
|
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] Failed to set CSRF binding cookie', error);
|
|
res.status(500).json({ error: 'Failed to bind OAuth flow' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Check OAuth flow status
|
|
* This endpoint can be used to poll the status of an OAuth flow
|
|
*/
|
|
router.get('/oauth/status/:flowId', requireJwtAuth, async (req, res) => {
|
|
try {
|
|
const { flowId } = req.params;
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
|
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
if (!flowState) {
|
|
return res.status(404).json({ error: 'Flow not found' });
|
|
}
|
|
|
|
res.json({
|
|
status: flowState.status,
|
|
completed: flowState.status === 'COMPLETED',
|
|
failed: flowState.status === 'FAILED',
|
|
error: flowState.error,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth] Failed to get flow status', error);
|
|
res.status(500).json({ error: 'Failed to get flow status' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Cancel OAuth flow
|
|
* This endpoint cancels a pending OAuth flow
|
|
*/
|
|
router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
|
|
try {
|
|
const { serverName } = req.params;
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
|
|
|
if (!flowState) {
|
|
logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
|
|
return res.json({
|
|
success: true,
|
|
message: 'No active OAuth flow to cancel',
|
|
});
|
|
}
|
|
|
|
await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
|
|
|
|
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `OAuth flow for ${serverName} cancelled successfully`,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
|
|
res.status(500).json({ error: 'Failed to cancel OAuth flow' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Reinitialize MCP server
|
|
* This endpoint allows reinitializing a specific MCP server
|
|
*/
|
|
router.post(
|
|
'/:serverName/reinitialize',
|
|
requireJwtAuth,
|
|
checkMCPUsePermissions,
|
|
setOAuthSession,
|
|
async (req, res) => {
|
|
try {
|
|
const { serverName } = req.params;
|
|
const user = createSafeUser(req.user);
|
|
|
|
if (!user.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
|
|
|
const mcpManager = getMCPManager();
|
|
const configServers = await resolveConfigServers(req);
|
|
const serverConfig = await getMCPServersRegistry().getServerConfig(
|
|
serverName,
|
|
user.id,
|
|
configServers,
|
|
);
|
|
if (!serverConfig) {
|
|
return res.status(404).json({
|
|
error: `MCP server '${serverName}' not found in configuration`,
|
|
});
|
|
}
|
|
|
|
await mcpManager.disconnectUserConnection(user.id, serverName);
|
|
logger.info(
|
|
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
|
|
);
|
|
|
|
/** @type {Record<string, Record<string, string>> | undefined} */
|
|
let userMCPAuthMap;
|
|
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
|
userMCPAuthMap = await getUserMCPAuthMap({
|
|
userId: user.id,
|
|
servers: [serverName],
|
|
findPluginAuthsByKeys: db.findPluginAuthsByKeys,
|
|
});
|
|
}
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
configServers,
|
|
userMCPAuthMap,
|
|
});
|
|
|
|
if (!result) {
|
|
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
|
}
|
|
|
|
const { success, message, oauthRequired, oauthUrl } = result;
|
|
|
|
if (oauthRequired) {
|
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
|
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
}
|
|
|
|
res.json({
|
|
success,
|
|
message,
|
|
oauthUrl,
|
|
serverName,
|
|
oauthRequired,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[MCP Reinitialize] Unexpected error', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Get connection status for all MCP servers
|
|
* This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
|
|
*/
|
|
router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
|
|
user.id,
|
|
{ role: user.role, tenantId: getTenantId() },
|
|
);
|
|
const connectionStatus = {};
|
|
|
|
for (const [serverName, config] of Object.entries(mcpConfig)) {
|
|
try {
|
|
connectionStatus[serverName] = await getServerConnectionStatus(
|
|
user.id,
|
|
serverName,
|
|
config,
|
|
appConnections,
|
|
userConnections,
|
|
oauthServers,
|
|
);
|
|
} catch (error) {
|
|
const message = `Failed to get status for server "${serverName}"`;
|
|
logger.error(`[MCP Connection Status] ${message},`, error);
|
|
connectionStatus[serverName] = {
|
|
connectionState: 'error',
|
|
requiresOAuth: oauthServers.has(serverName),
|
|
error: message,
|
|
};
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
connectionStatus,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[MCP Connection Status] Failed to get connection status', error);
|
|
res.status(500).json({ error: 'Failed to get connection status' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get connection status for a single MCP server
|
|
* This endpoint returns the connection status for a specific server for a given user
|
|
*/
|
|
router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
const { serverName } = req.params;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
|
|
user.id,
|
|
{ role: user.role, tenantId: getTenantId() },
|
|
);
|
|
|
|
if (!mcpConfig[serverName]) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: `MCP server '${serverName}' not found in configuration` });
|
|
}
|
|
|
|
const serverStatus = await getServerConnectionStatus(
|
|
user.id,
|
|
serverName,
|
|
mcpConfig[serverName],
|
|
appConnections,
|
|
userConnections,
|
|
oauthServers,
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
serverName,
|
|
connectionStatus: serverStatus.connectionState,
|
|
requiresOAuth: serverStatus.requiresOAuth,
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
|
|
error,
|
|
);
|
|
res.status(500).json({ error: 'Failed to get connection status' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Check which authentication values exist for a specific MCP server
|
|
* This endpoint returns only boolean flags indicating if values are set, not the actual values
|
|
*/
|
|
router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
|
|
try {
|
|
const { serverName } = req.params;
|
|
const user = req.user;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
|
|
const configServers = await resolveConfigServers(req);
|
|
const serverConfig = await getMCPServersRegistry().getServerConfig(
|
|
serverName,
|
|
user.id,
|
|
configServers,
|
|
);
|
|
if (!serverConfig) {
|
|
return res.status(404).json({
|
|
error: `MCP server '${serverName}' not found in configuration`,
|
|
});
|
|
}
|
|
|
|
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
|
|
const authValueFlags = {};
|
|
|
|
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
|
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
|
try {
|
|
const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
|
|
authValueFlags[varName] = !!(value && value.length > 0);
|
|
} catch (err) {
|
|
logger.error(
|
|
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
|
|
err,
|
|
);
|
|
authValueFlags[varName] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
serverName,
|
|
authValueFlags,
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
|
|
error,
|
|
);
|
|
res.status(500).json({ error: 'Failed to check auth value flags' });
|
|
}
|
|
});
|
|
|
|
async function getOAuthHeaders(serverName, userId, configServers) {
|
|
const serverConfig = await getMCPServersRegistry().getServerConfig(
|
|
serverName,
|
|
userId,
|
|
configServers,
|
|
);
|
|
return serverConfig?.oauth_headers ?? {};
|
|
}
|
|
|
|
/**
|
|
MCP Server CRUD Routes (User-Managed MCP Servers)
|
|
*/
|
|
|
|
/**
|
|
* Get list of accessible MCP servers
|
|
* @route GET /api/mcp/servers
|
|
* @param {Object} req.query - Query parameters for pagination and search
|
|
* @param {number} [req.query.limit] - Number of results per page
|
|
* @param {string} [req.query.after] - Pagination cursor
|
|
* @param {string} [req.query.search] - Search query for title/description
|
|
* @returns {MCPServerListResponse} 200 - Success response - application/json
|
|
*/
|
|
router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList);
|
|
|
|
/**
|
|
* Create a new MCP server
|
|
* @route POST /api/mcp/servers
|
|
* @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
|
|
* @returns {MCPServer} 201 - Success response - application/json
|
|
*/
|
|
router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerController);
|
|
|
|
/**
|
|
* Get single MCP server by ID
|
|
* @route GET /api/mcp/servers/:serverName
|
|
* @param {string} req.params.serverName - MCP server identifier.
|
|
* @returns {MCPServer} 200 - Success response - application/json
|
|
*/
|
|
router.get(
|
|
'/servers/:serverName',
|
|
requireJwtAuth,
|
|
checkMCPUsePermissions,
|
|
canAccessMCPServerResource({
|
|
requiredPermission: PermissionBits.VIEW,
|
|
resourceIdParam: 'serverName',
|
|
}),
|
|
getMCPServerById,
|
|
);
|
|
|
|
/**
|
|
* Update MCP server
|
|
* @route PATCH /api/mcp/servers/:serverName
|
|
* @param {string} req.params.serverName - MCP server identifier.
|
|
* @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
|
|
* @returns {MCPServer} 200 - Success response - application/json
|
|
*/
|
|
router.patch(
|
|
'/servers/:serverName',
|
|
requireJwtAuth,
|
|
checkMCPCreate,
|
|
canAccessMCPServerResource({
|
|
requiredPermission: PermissionBits.EDIT,
|
|
resourceIdParam: 'serverName',
|
|
}),
|
|
updateMCPServerController,
|
|
);
|
|
|
|
/**
|
|
* Delete MCP server
|
|
* @route DELETE /api/mcp/servers/:serverName
|
|
* @param {string} req.params.serverName - MCP server identifier.
|
|
* @returns {Object} 200 - Success response - application/json
|
|
*/
|
|
router.delete(
|
|
'/servers/:serverName',
|
|
requireJwtAuth,
|
|
checkMCPCreate,
|
|
canAccessMCPServerResource({
|
|
requiredPermission: PermissionBits.DELETE,
|
|
resourceIdParam: 'serverName',
|
|
}),
|
|
deleteMCPServerController,
|
|
);
|
|
|
|
module.exports = router;
|