fix: Close MCP OAuth route tenant gaps

This commit is contained in:
Danny Avila 2026-05-29 09:04:31 -07:00
parent 9b9c2c8646
commit 483b9b05d6
5 changed files with 235 additions and 28 deletions

View file

@ -1,5 +1,5 @@
const mongoose = require('mongoose');
const { logger, webSearchKeys } = require('@librechat/data-schemas');
const { logger, getTenantId, webSearchKeys } = require('@librechat/data-schemas');
const {
getNewS3URL,
needsRefresh,
@ -407,11 +407,24 @@ const clearStoredMCPOAuthState = async (userId, serverName) => {
try {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
const results = await Promise.allSettled([
flowManager.deleteFlow(flowId, 'mcp_get_tokens'),
flowManager.deleteFlow(flowId, 'mcp_oauth'),
]);
const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName);
const tenantId = getTenantId();
const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId);
const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId);
const flowDeletes = [
[tokenFlowId, 'mcp_get_tokens'],
[oauthFlowId, 'mcp_oauth'],
[baseFlowId, 'mcp_get_tokens'],
[baseFlowId, 'mcp_oauth'],
].filter(
([flowId, type], index, deletes) =>
deletes.findIndex(([candidateId, candidateType]) => {
return candidateId === flowId && candidateType === type;
}) === index,
);
const results = await Promise.allSettled(
flowDeletes.map(([flowId, type]) => flowManager.deleteFlow(flowId, type)),
);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn(

View file

@ -10,6 +10,7 @@ const mockGetMCPServersRegistry = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
getTenantId: jest.fn(),
webSearchKeys: [],
}));
@ -22,7 +23,14 @@ jest.mock('librechat-data-provider', () => ({
jest.mock('@librechat/api', () => ({
MCPOAuthHandler: {
generateFlowId: jest.fn(() => 'user-1:test-server'),
generateFlowId: jest.fn((userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
}),
generateTokenFlowId: jest.fn((userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
}),
revokeOAuthToken: jest.fn(),
},
MCPTokenStorage: {
@ -78,7 +86,7 @@ jest.mock('~/cache', () => ({
getLogStores: (...args) => mockGetLogStores(...args),
}));
const { logger } = require('@librechat/data-schemas');
const { logger, getTenantId } = require('@librechat/data-schemas');
const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api');
const { updateUserPluginsController } = require('~/server/controllers/UserController');
@ -138,6 +146,7 @@ function setupMCPMocks() {
beforeEach(() => {
jest.clearAllMocks();
getTenantId.mockReturnValue(undefined);
});
describe('updateUserPluginsController MCP OAuth cleanup', () => {
@ -231,6 +240,27 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => {
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
});
it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => {
const { flowManager } = setupMCPMocks();
getTenantId.mockReturnValue('tenant-a');
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
'tenant:tenant-a:user-1:test-server',
'mcp_get_tokens',
);
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
'tenant:tenant-a:user-1:test-server',
'mcp_oauth',
);
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
});
it('clears stored OAuth token state when server config is missing', async () => {
const { flowManager, registry } = setupMCPMocks();
registry.getServerConfig.mockResolvedValue(undefined);

View file

@ -13,9 +13,11 @@ const mockDeleteTokens = jest.fn();
const mockLoggerInfo = jest.fn();
const mockLoggerWarn = jest.fn();
const mockLoggerError = jest.fn();
const mockGetTenantId = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
getTenantId: (...args) => mockGetTenantId(...args),
webSearchKeys: [],
}));
@ -23,7 +25,14 @@ jest.mock('@librechat/api', () => {
return {
MCPOAuthHandler: {
revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
generateFlowId: (userId, serverName) => `${userId}:${serverName}`,
generateFlowId: (userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
},
generateTokenFlowId: (userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
},
},
MCPTokenStorage: {
getTokens: (...args) => mockGetTokens(...args),
@ -151,6 +160,7 @@ function setupOAuthServerFound() {
describe('maybeUninstallOAuthMCP', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetTenantId.mockReturnValue(undefined);
});
test('is a no-op when pluginKey is not an MCP key', async () => {
@ -205,6 +215,20 @@ describe('maybeUninstallOAuthMCP', () => {
);
});
test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
setupOAuthServerFound();
mockGetTenantId.mockReturnValue('tenant-a');
mockGetClientInfoAndMetadata.mockResolvedValue(null);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
});
test('revokes both tokens and runs cleanup on happy path', async () => {
setupOAuthServerFound();
mockGetTokens.mockResolvedValue({

View file

@ -458,6 +458,26 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
expect(mockFlowManager.failFlow).not.toHaveBeenCalled();
});
it('should redirect instead of hanging when OAuth error flow ID is malformed', async () => {
const mockFlowManager = {
failFlow: jest.fn(),
};
getLogStores.mockReturnValueOnce({});
require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager);
MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce('malformed-flow-id');
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
error: 'invalid_client',
state: 'opaque-state',
});
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
expect(mockFlowManager.failFlow).not.toHaveBeenCalled();
});
});
it('should redirect to error page when code is missing', async () => {
@ -800,6 +820,74 @@ describe('MCP Routes', () => {
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
});
it('should complete pending token flow waiters after storing callback tokens', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockImplementation((id, type) => {
if (type === 'mcp_get_tokens' && id === 'tenant:tenant-a:test-user-id:test-server') {
return Promise.resolve({
type: 'mcp_get_tokens',
status: 'PENDING',
});
}
return Promise.resolve({ status: 'PENDING' });
}),
completeFlow: jest.fn().mockResolvedValue(true),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: {},
clientInfo: {},
codeVerifier: 'test-verifier',
tenantId: 'tenant-a',
};
const mockTokens = {
access_token: 'fresh-access-token',
refresh_token: 'fresh-refresh-token',
};
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
require('~/config').getOAuthReconnectionManager.mockReturnValue({
clearReconnection: jest.fn(),
});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
}),
});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
const flowId = 'test-user-id:test-server';
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
state: flowId,
});
expect(response.status).toBe(302);
expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
'tenant:tenant-a:test-user-id:test-server',
'mcp_get_tokens',
mockTokens,
);
expect(mockFlowManager.deleteFlow).not.toHaveBeenCalledWith(
'tenant:tenant-a:test-user-id:test-server',
'mcp_get_tokens',
);
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
});
it('should use oauthHeaders from flow state when present', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
@ -1211,6 +1299,7 @@ describe('MCP Routes', () => {
});
it('should return tokens for a tenant-prefixed flow owned by the user', async () => {
const { getTenantId } = require('@librechat/data-schemas');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'COMPLETED',
@ -1220,6 +1309,7 @@ describe('MCP Routes', () => {
}),
};
getTenantId.mockReturnValue('tenant-a');
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@ -1239,6 +1329,18 @@ describe('MCP Routes', () => {
);
});
it('should reject tenant-prefixed token flow access from another tenant', async () => {
const { getTenantId } = require('@librechat/data-schemas');
getTenantId.mockReturnValue('tenant-b');
const response = await request(app).get(
'/api/mcp/oauth/tokens/tenant:tenant-a:test-user-id:test-server',
);
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Access denied' });
});
it('should return 401 when user is not authenticated', async () => {
const unauthApp = express();
unauthApp.use(express.json());
@ -1332,6 +1434,7 @@ describe('MCP Routes', () => {
});
it('should return flow status for a tenant-prefixed flow owned by the user', async () => {
const { getTenantId } = require('@librechat/data-schemas');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'PENDING',
@ -1339,6 +1442,7 @@ describe('MCP Routes', () => {
}),
};
getTenantId.mockReturnValue('tenant-a');
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@ -1359,6 +1463,18 @@ describe('MCP Routes', () => {
);
});
it('should reject tenant-prefixed status access from another tenant', async () => {
const { getTenantId } = require('@librechat/data-schemas');
getTenantId.mockReturnValue('tenant-b');
const response = await request(app).get(
'/api/mcp/oauth/status/tenant:tenant-a:test-user-id:test-server',
);
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Access denied' });
});
it('should return 403 when flowId does not match authenticated user', async () => {
const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server');

View file

@ -55,11 +55,24 @@ const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
const getOAuthFlowId = (userId, serverName) =>
MCPOAuthHandler.generateFlowId(userId, serverName, getTenantId());
const getFlowUserId = (flowId) => MCPOAuthHandler.parseFlowId(flowId)?.userId;
const canAccessOAuthFlow = (flowId, userId) => {
const flowUserId = getFlowUserId(flowId);
return flowUserId === userId || flowUserId === 'system';
const parsed = MCPOAuthHandler.parseFlowId(flowId);
if (!parsed) {
return false;
}
if (parsed.tenantId && parsed.tenantId !== getTenantId()) {
return false;
}
return parsed.userId === userId || parsed.userId === 'system';
};
const clearGetTokensFlow = async ({ flowManager, flowId, tokens }) => {
const state = await flowManager.getFlowState(flowId, 'mcp_get_tokens');
if (state?.type === 'mcp_get_tokens' && state.status === 'PENDING') {
await flowManager.completeFlow(flowId, 'mcp_get_tokens', tokens);
return;
}
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
};
const checkMCPUsePermissions = generateCheckAccess({
@ -173,18 +186,21 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const flowManager = getFlowStateManager(flowsCache);
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
if (flowId) {
const flowUserId = getFlowUserId(flowId);
if (!flowUserId) {
return;
}
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', {
const parsed = MCPOAuthHandler.parseFlowId(flowId);
if (!parsed) {
logger.warn('[MCP OAuth] Invalid flow ID format for OAuth error callback', {
flowId,
error: oauthError,
});
} else {
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
const hasSession = !hasCsrf && validateOAuthSession(req, parsed.userId);
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) {
@ -216,14 +232,14 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
}
logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
const flowUserId = getFlowUserId(flowId);
if (!flowUserId) {
const parsedFlowId = MCPOAuthHandler.parseFlowId(flowId);
if (!parsedFlowId) {
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
const hasSession = !hasCsrf && validateOAuthSession(req, parsedFlowId.userId);
let hasActiveFlow = false;
if (!hasCsrf && !hasSession) {
const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
@ -345,9 +361,17 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
serverName,
flowState.tenantId,
);
await flowManager.deleteFlow(tokenFlowId, 'mcp_get_tokens');
await clearGetTokensFlow({
flowManager,
flowId: tokenFlowId,
tokens,
});
if (tokenFlowId !== flowId) {
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
await clearGetTokensFlow({
flowManager,
flowId,
tokens,
});
}
} catch (error) {
logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);