mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧹 fix: Clear MCP OAuth Tokens On Revoke
Fixes #12912.\n\n- Clear stored MCP OAuth tokens and flow state on revoke cleanup-only paths.\n- Keep provider revocation best-effort when token and client metadata are available.\n- Add controller and function coverage for stale metadata, missing config, and cleanup failure paths.
This commit is contained in:
parent
eb22bb6969
commit
4e45e8e17c
3 changed files with 530 additions and 62 deletions
|
|
@ -7,7 +7,6 @@ const {
|
|||
MCPTokenStorage,
|
||||
normalizeHttpError,
|
||||
extractWebSearchEnvVars,
|
||||
ReauthenticationRequiredError,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
|
|
@ -376,9 +375,48 @@ const resendVerificationController = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth MCP specific uninstall logic
|
||||
*/
|
||||
/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */
|
||||
const clearStoredMCPOAuthState = async (userId, serverName) => {
|
||||
try {
|
||||
await MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: async (filter) => {
|
||||
await db.deleteTokens(filter);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */
|
||||
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||
// this is not an MCP server, so nothing to do here
|
||||
|
|
@ -390,29 +428,37 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|||
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
|
||||
appConfig?.mcpServers?.[serverName];
|
||||
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
|
||||
if (!oauthServers.has(serverName)) {
|
||||
// this server does not use OAuth, so nothing to do here as well
|
||||
if (!oauthServers.has(serverName) || !serverConfig) {
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. get client info used for revocation (client id, secret)
|
||||
const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: db.findToken,
|
||||
});
|
||||
let clientTokenData = null;
|
||||
try {
|
||||
clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: db.findToken,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
||||
error,
|
||||
);
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
if (clientTokenData == null) {
|
||||
logger.info(
|
||||
`[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
||||
);
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
const { clientInfo, clientMetadata } = clientTokenData;
|
||||
|
||||
// 2. get decrypted tokens before deletion.
|
||||
// Token retrieval can throw ReauthenticationRequiredError (or other
|
||||
// errors) when the refresh token is missing/expired — exactly the
|
||||
// state that triggers a user-initiated revoke. Swallow it here so
|
||||
// the DB and flow-state cleanup below always runs. Revocation is
|
||||
// best-effort and the individual calls already wrap their own
|
||||
// try/catch.
|
||||
// 2. get decrypted tokens before deletion
|
||||
let tokens = null;
|
||||
try {
|
||||
tokens = await MCPTokenStorage.getTokens({
|
||||
|
|
@ -421,16 +467,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|||
findToken: db.findToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ReauthenticationRequiredError) {
|
||||
logger.info(
|
||||
`[maybeUninstallOAuthMCP] No usable tokens for ${serverName} — skipping revocation, continuing cleanup`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[maybeUninstallOAuthMCP] Unexpected error retrieving tokens for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
logger.warn(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. revoke OAuth tokens at the provider
|
||||
|
|
@ -459,7 +499,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|||
allowedDomains,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
|
||||
logger.error(
|
||||
`[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -480,25 +523,15 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|||
allowedDomains,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
|
||||
logger.error(
|
||||
`[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. delete tokens from the DB after revocation attempts
|
||||
await MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: async (filter) => {
|
||||
await db.deleteTokens(filter);
|
||||
},
|
||||
});
|
||||
|
||||
// 5. clear the flow state for the OAuth tokens
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
|
||||
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
|
||||
await flowManager.deleteFlow(flowId, 'mcp_oauth');
|
||||
// 4. delete tokens from the DB and clear the flow state after revocation attempts
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue