Skip to content

Commit c5efd82

Browse files
gaurav0107fuuuzzy
authored andcommitted
🧹 fix: Graceful MCP OAuth Revoke Cleanup When Tokens Are Missing (LibreChat-AI#12825)
* fix: graceful MCP OAuth revoke cleanup when tokens are missing (LibreChat-AI#12754) `maybeUninstallOAuthMCP` in `api/server/controllers/UserController.js` aborts before the DB-delete and flow-state cleanup steps whenever `MCPTokenStorage.getTokens` throws `ReauthenticationRequiredError` — which is exactly what happens when a user clicks "Revoke" on an MCP server whose backend is already dead and whose refresh token is gone. The resulting error is both surfaced to the log as a red line and, more importantly, leaks the DB token row and OAuth flow state. Wrap the token retrieval in try/catch following the same best-effort pattern already used for the two `revokeOAuthToken` calls. On `ReauthenticationRequiredError`, skip revocation silently (info log) and continue to the cleanup steps. On any other unexpected error, log a warning and continue — cleanup must always run. Exported `maybeUninstallOAuthMCP` for direct unit testing and added `api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js` with 8 cases: early-return guards (non-MCP key, non-OAuth server, missing client info), happy path (both tokens revoked + cleanup), both failure-to-retrieve paths (ReauthenticationRequiredError and arbitrary error — cleanup still runs in both), single-token path, and revocation-call failures (cleanup still runs). Fixes LibreChat-AI#12754. * test: use instanceof check against real ReauthenticationRequiredError Follow-up to the previous commit on this branch. Two changes: 1. `UserController.maybeUninstallOAuthMCP` now checks `error instanceof ReauthenticationRequiredError` using the real class imported from `@librechat/api`, instead of comparing `error?.name === 'ReauthenticationRequiredError'`. The name-string check matched any unrelated error that happened to have the same `.name`; the `instanceof` check is a proper identity test. 2. The accompanying spec's jest mock for `@librechat/api` now exposes a `ReauthenticationRequiredError` class, and the test imports it from that mock so the `instanceof` comparison in the production code holds during the test. Without this, the two "skips revocation ... still runs cleanup" tests threw `TypeError: Right-hand side of 'instanceof' is not an object` because the mock left the class undefined. All 8 tests in the spec pass.
1 parent 4668297 commit c5efd82

2 files changed

Lines changed: 309 additions & 6 deletions

File tree

‎api/server/controllers/UserController.js‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
MCPTokenStorage,
88
normalizeHttpError,
99
extractWebSearchEnvVars,
10+
ReauthenticationRequiredError,
1011
} = require('@librechat/api');
1112
const {
1213
Tools,
@@ -405,12 +406,32 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
405406
}
406407
const { clientInfo, clientMetadata } = clientTokenData;
407408

408-
// 2. get decrypted tokens before deletion
409-
const tokens = await MCPTokenStorage.getTokens({
410-
userId,
411-
serverName,
412-
findToken: db.findToken,
413-
});
409+
// 2. get decrypted tokens before deletion.
410+
// Token retrieval can throw ReauthenticationRequiredError (or other
411+
// errors) when the refresh token is missing/expired — exactly the
412+
// state that triggers a user-initiated revoke. Swallow it here so
413+
// the DB and flow-state cleanup below always runs. Revocation is
414+
// best-effort and the individual calls already wrap their own
415+
// try/catch.
416+
let tokens = null;
417+
try {
418+
tokens = await MCPTokenStorage.getTokens({
419+
userId,
420+
serverName,
421+
findToken: db.findToken,
422+
});
423+
} catch (error) {
424+
if (error instanceof ReauthenticationRequiredError) {
425+
logger.info(
426+
`[maybeUninstallOAuthMCP] No usable tokens for ${serverName} — skipping revocation, continuing cleanup`,
427+
);
428+
} else {
429+
logger.warn(
430+
`[maybeUninstallOAuthMCP] Unexpected error retrieving tokens for ${serverName}:`,
431+
error,
432+
);
433+
}
434+
}
414435

415436
// 3. revoke OAuth tokens at the provider
416437
const revocationEndpoint =
@@ -489,4 +510,5 @@ module.exports = {
489510
updateUserPluginsController,
490511
resendVerificationController,
491512
deleteUserMcpServers,
513+
maybeUninstallOAuthMCP,
492514
};
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
const mockGetTokens = jest.fn();
2+
const mockDeleteUserTokens = jest.fn();
3+
const mockGetClientInfoAndMetadata = jest.fn();
4+
const mockRevokeOAuthToken = jest.fn();
5+
const mockGetServerConfig = jest.fn();
6+
const mockGetOAuthServers = jest.fn();
7+
const mockGetAllowedDomains = jest.fn();
8+
const mockDeleteFlow = jest.fn();
9+
const mockGetLogStores = jest.fn();
10+
const mockFindToken = jest.fn();
11+
const mockDeleteTokens = jest.fn();
12+
const mockLoggerInfo = jest.fn();
13+
const mockLoggerWarn = jest.fn();
14+
const mockLoggerError = jest.fn();
15+
16+
jest.mock('@librechat/data-schemas', () => ({
17+
logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
18+
webSearchKeys: [],
19+
}));
20+
21+
jest.mock('@librechat/api', () => {
22+
class ReauthenticationRequiredError extends Error {
23+
constructor(serverName, reason) {
24+
super(`Re-authentication required for "${serverName}": ${reason}`);
25+
this.name = 'ReauthenticationRequiredError';
26+
}
27+
}
28+
return {
29+
MCPOAuthHandler: {
30+
revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
31+
generateFlowId: (userId, serverName) => `${userId}:${serverName}`,
32+
},
33+
MCPTokenStorage: {
34+
getTokens: (...args) => mockGetTokens(...args),
35+
getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
36+
deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
37+
},
38+
ReauthenticationRequiredError,
39+
normalizeHttpError: jest.fn(),
40+
extractWebSearchEnvVars: jest.fn(),
41+
needsRefresh: jest.fn(),
42+
getNewS3URL: jest.fn(),
43+
};
44+
});
45+
46+
jest.mock('librechat-data-provider', () => ({
47+
Tools: {},
48+
CacheKeys: { FLOWS: 'flows' },
49+
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
50+
FileSources: {},
51+
ResourceType: {},
52+
}));
53+
54+
jest.mock('~/config', () => ({
55+
getMCPManager: jest.fn(),
56+
getFlowStateManager: jest.fn(() => ({
57+
deleteFlow: (...args) => mockDeleteFlow(...args),
58+
})),
59+
getMCPServersRegistry: jest.fn(() => ({
60+
getServerConfig: (...args) => mockGetServerConfig(...args),
61+
getOAuthServers: (...args) => mockGetOAuthServers(...args),
62+
getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
63+
})),
64+
}));
65+
66+
jest.mock('~/cache', () => ({
67+
getLogStores: (...args) => mockGetLogStores(...args),
68+
}));
69+
70+
jest.mock('~/server/services/PluginService', () => ({
71+
updateUserPluginAuth: jest.fn(),
72+
deleteUserPluginAuth: jest.fn(),
73+
}));
74+
75+
jest.mock('~/server/services/twoFactorService', () => ({
76+
verifyOTPOrBackupCode: jest.fn(),
77+
}));
78+
79+
jest.mock('~/server/services/AuthService', () => ({
80+
verifyEmail: jest.fn(),
81+
resendVerificationEmail: jest.fn(),
82+
}));
83+
84+
jest.mock('~/server/services/Config/getCachedTools', () => ({
85+
invalidateCachedTools: jest.fn(),
86+
}));
87+
88+
jest.mock('~/server/services/Files/process', () => ({
89+
processDeleteRequest: jest.fn(),
90+
}));
91+
92+
jest.mock('~/server/services/Config', () => ({
93+
getAppConfig: jest.fn(),
94+
}));
95+
96+
jest.mock('~/models', () => ({
97+
findToken: (...args) => mockFindToken(...args),
98+
deleteTokens: (...args) => mockDeleteTokens(...args),
99+
updateUser: jest.fn(),
100+
deleteAllUserSessions: jest.fn(),
101+
deleteAllSharedLinks: jest.fn(),
102+
updateUserPlugins: jest.fn(),
103+
deleteUserById: jest.fn(),
104+
deleteMessages: jest.fn(),
105+
deletePresets: jest.fn(),
106+
deleteUserKey: jest.fn(),
107+
getUserById: jest.fn(),
108+
deleteConvos: jest.fn(),
109+
deleteFiles: jest.fn(),
110+
getFiles: jest.fn(),
111+
deleteToolCalls: jest.fn(),
112+
deleteUserAgents: jest.fn(),
113+
deleteUserPrompts: jest.fn(),
114+
deleteTransactions: jest.fn(),
115+
deleteBalances: jest.fn(),
116+
deleteAllAgentApiKeys: jest.fn(),
117+
deleteAssistants: jest.fn(),
118+
deleteConversationTags: jest.fn(),
119+
deleteAllUserMemories: jest.fn(),
120+
deleteActions: jest.fn(),
121+
removeUserFromAllGroups: jest.fn(),
122+
deleteAclEntries: jest.fn(),
123+
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
124+
}));
125+
126+
const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');
127+
const { ReauthenticationRequiredError } = require('@librechat/api');
128+
129+
const userId = 'user-123';
130+
const pluginKey = 'mcp_acme';
131+
const serverName = 'acme';
132+
133+
const serverConfig = {
134+
url: 'https://acme.example.com',
135+
oauth: {
136+
revocation_endpoint: 'https://acme.example.com/revoke',
137+
revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
138+
},
139+
oauth_headers: { 'X-Tenant': 'acme' },
140+
};
141+
142+
const appConfig = {
143+
mcpServers: { acme: serverConfig },
144+
};
145+
146+
const clientInfo = { client_id: 'cid', client_secret: 'csec' };
147+
const clientMetadata = {};
148+
149+
function setupOAuthServerFound() {
150+
mockGetServerConfig.mockResolvedValue(serverConfig);
151+
mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
152+
mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
153+
mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
154+
}
155+
156+
describe('maybeUninstallOAuthMCP', () => {
157+
beforeEach(() => {
158+
jest.clearAllMocks();
159+
});
160+
161+
test('is a no-op when pluginKey is not an MCP key', async () => {
162+
await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);
163+
164+
expect(mockGetServerConfig).not.toHaveBeenCalled();
165+
expect(mockGetTokens).not.toHaveBeenCalled();
166+
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
167+
expect(mockDeleteFlow).not.toHaveBeenCalled();
168+
});
169+
170+
test('is a no-op when the MCP server is not an OAuth server', async () => {
171+
mockGetServerConfig.mockResolvedValue(serverConfig);
172+
mockGetOAuthServers.mockResolvedValue(new Set(['other']));
173+
174+
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
175+
176+
expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
177+
expect(mockGetTokens).not.toHaveBeenCalled();
178+
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
179+
});
180+
181+
test('returns early when client info is missing', async () => {
182+
setupOAuthServerFound();
183+
mockGetClientInfoAndMetadata.mockResolvedValue(null);
184+
185+
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
186+
187+
expect(mockGetTokens).not.toHaveBeenCalled();
188+
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
189+
expect(mockDeleteFlow).not.toHaveBeenCalled();
190+
});
191+
192+
test('revokes both tokens and runs cleanup on happy path', async () => {
193+
setupOAuthServerFound();
194+
mockGetTokens.mockResolvedValue({
195+
access_token: 'access-abc',
196+
refresh_token: 'refresh-xyz',
197+
});
198+
mockRevokeOAuthToken.mockResolvedValue(undefined);
199+
mockDeleteUserTokens.mockResolvedValue(undefined);
200+
mockDeleteFlow.mockResolvedValue(undefined);
201+
202+
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
203+
204+
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
205+
expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
206+
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
207+
expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
208+
expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');
209+
210+
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
211+
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
212+
213+
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
214+
expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
215+
expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
216+
});
217+
218+
test('skips revocation but still runs cleanup when getTokens throws ReauthenticationRequiredError', async () => {
219+
setupOAuthServerFound();
220+
mockGetTokens.mockRejectedValue(new ReauthenticationRequiredError(serverName, 'missing'));
221+
mockDeleteUserTokens.mockResolvedValue(undefined);
222+
mockDeleteFlow.mockResolvedValue(undefined);
223+
224+
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
225+
226+
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
227+
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
228+
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
229+
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('No usable tokens'));
230+
});
231+
232+
test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
233+
setupOAuthServerFound();
234+
mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
235+
mockDeleteUserTokens.mockResolvedValue(undefined);
236+
mockDeleteFlow.mockResolvedValue(undefined);
237+
238+
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
239+
240+
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
241+
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
242+
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
243+
expect(mockLoggerWarn).toHaveBeenCalledWith(
244+
expect.stringContaining('Unexpected error retrieving tokens'),
245+
expect.any(Error),
246+
);
247+
});
248+
249+
test('continues cleanup when only one token type is present', async () => {
250+
setupOAuthServerFound();
251+
mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
252+
mockRevokeOAuthToken.mockResolvedValue(undefined);
253+
mockDeleteUserTokens.mockResolvedValue(undefined);
254+
mockDeleteFlow.mockResolvedValue(undefined);
255+
256+
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
257+
258+
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
259+
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
260+
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
261+
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
262+
});
263+
264+
test('still runs cleanup even when both revocation calls fail', async () => {
265+
setupOAuthServerFound();
266+
mockGetTokens.mockResolvedValue({
267+
access_token: 'a',
268+
refresh_token: 'r',
269+
});
270+
mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
271+
mockDeleteUserTokens.mockResolvedValue(undefined);
272+
mockDeleteFlow.mockResolvedValue(undefined);
273+
274+
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
275+
276+
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
277+
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
278+
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
279+
expect(mockLoggerError).toHaveBeenCalled();
280+
});
281+
});

0 commit comments

Comments
 (0)