mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* ⏳ fix: Extend and decouple MCP OAuth flow timeouts The OAuth auth button disappeared after 2 minutes (the internal OAuth handling timeout) while the flow state lived for 3 minutes, leaving users who didn't click immediately stuck in an unrecoverable re-auth loop. The handling timeouts also reused the connection/init timeout, so a short initTimeout would shrink the OAuth window further. - Add MCP_OAUTH_HANDLING_TIMEOUT (10m) and MCP_OAUTH_FLOW_TTL (15m) to mcpConfig - Decouple the reactive/proactive OAuth waits from initTimeout/connectionTimeout - Use OAUTH_FLOW_TTL for the FlowStateManager TTL and the UI status window - Ensure the flow TTL outlives the handling timeout, fixing the "Flow state not found" race - Remove dead FLOW_TTL constant and document new env vars Fixes #13615 * ⏳ fix: Coordinate OAuth pending window with handling timeout Address Codex review: the extended OAuth wait was still capped by other timeouts that were not updated. - Align PENDING_STALE_MS (button validity + pending-flow reuse window) with MCP_OAUTH_HANDLING_TIMEOUT so a flow stays reusable for the full wait instead of 2 minutes (Finding 3) - Clamp MCP_OAUTH_FLOW_TTL to never fall below the handling timeout so a callback near the deadline still finds its flow state (Finding 2) - Floor attemptToConnect's timeout to the handling window for OAuth servers so the reactive in-connect OAuth wait is not killed by the 30s connection timeout (Finding 1) - Update flow staleness tests to reference the threshold symbolically * ⏳ fix: Align OAuth window across status, action flows, and client polling Address Codex round 2: extending the server wait exposed three more windows that were still capped or now over-extended. - checkOAuthFlowStatus reports a PENDING flow as active only within the usable PENDING_STALE_MS window, not the longer Keyv retention TTL, so the connect button reappears instead of a stuck 'connecting' state - Give Action (custom tool) OAuth its own FlowStateManager on the prior 3-minute TTL so the longer MCP OAuth TTL can't leave an action tool call waiting up to 15 minutes - Extend the MCP server-card client polling to the 10-minute handling window so a user who completes OAuth after 3 minutes is still picked up * 🧪 test: Make stale-flow CSRF test track PENDING_STALE_MS The CSRF-fallback stale-flow test hardcoded a 3-minute age, which is now within the 10-minute PENDING_STALE_MS window and was wrongly treated as active. Derive the age from PENDING_STALE_MS so it tracks the constant. * ⏳ fix: Add grace buffers and surface OAuth timeout to the client Address Codex round 3 (near-deadline edges): - Clamp MCP_OAUTH_FLOW_TTL to handling timeout + 60s grace (not equality), so flow state outlives the wait instead of expiring at the same instant - Extend attemptToConnect's OAuth floor by a 60s grace so a user who authorizes near the deadline still gets the post-OAuth reconnect - Surface OAUTH_HANDLING_TIMEOUT on the connection-status response and have the client poll for the configured window instead of a hardcoded 10 minutes, so a tuned server deadline isn't capped on the client * ⏳ fix: Refresh client OAuth timeout from the first status refetch If the connection-status cache is empty when polling starts, the client captured the 10-minute fallback and never picked up a tuned oauthTimeout. Re-read it after each refetch so a longer configured deadline is honored even on a cold cache. * 📝 refactor: Type oauthTimeout on MCPConnectionStatusResponse Declare the oauthTimeout field on the shared response type in data-provider instead of an ad-hoc inline cast in the client hook, and replace the pre-existing 'as any' on the status query read with the typed getQueryData. Type-level only; no runtime change.
133 lines
4.9 KiB
JavaScript
133 lines
4.9 KiB
JavaScript
const express = require('express');
|
|
const jwt = require('jsonwebtoken');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { CacheKeys } = require('librechat-data-provider');
|
|
const {
|
|
getBasePath,
|
|
getAccessToken,
|
|
setOAuthSession,
|
|
validateOAuthCsrf,
|
|
OAUTH_CSRF_COOKIE,
|
|
setOAuthCsrfCookie,
|
|
validateOAuthSession,
|
|
OAUTH_SESSION_COOKIE,
|
|
} = require('@librechat/api');
|
|
const { findToken, updateToken, createToken } = require('~/models');
|
|
const { requireJwtAuth } = require('~/server/middleware');
|
|
const { getActionFlowStateManager } = require('~/config');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
const router = express.Router();
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
const OAUTH_CSRF_COOKIE_PATH = '/api/actions';
|
|
|
|
/**
|
|
* Sets a CSRF cookie binding the action OAuth flow to the current browser session.
|
|
* Must be called before the user opens the IdP authorization URL.
|
|
*
|
|
* @route POST /actions/:action_id/oauth/bind
|
|
*/
|
|
router.post('/:action_id/oauth/bind', requireJwtAuth, setOAuthSession, async (req, res) => {
|
|
try {
|
|
const { action_id } = req.params;
|
|
const user = req.user;
|
|
if (!user?.id) {
|
|
return res.status(401).json({ error: 'User not authenticated' });
|
|
}
|
|
const flowId = `${user.id}:${action_id}`;
|
|
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
logger.error('[Action OAuth] Failed to set CSRF binding cookie', error);
|
|
res.status(500).json({ error: 'Failed to bind OAuth flow' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Handles the OAuth callback and exchanges the authorization code for tokens.
|
|
*
|
|
* @route GET /actions/:action_id/oauth/callback
|
|
* @param {string} req.params.action_id - The ID of the action.
|
|
* @param {string} req.query.code - The authorization code returned by the provider.
|
|
* @param {string} req.query.state - The state token to verify the authenticity of the request.
|
|
* @returns {void} Sends a success message after updating the action with OAuth tokens.
|
|
*/
|
|
router.get('/:action_id/oauth/callback', async (req, res) => {
|
|
const { action_id } = req.params;
|
|
const { code, state } = req.query;
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getActionFlowStateManager(flowsCache);
|
|
const basePath = getBasePath();
|
|
let identifier = action_id;
|
|
try {
|
|
let decodedState;
|
|
try {
|
|
decodedState = jwt.verify(state, JWT_SECRET);
|
|
} catch (err) {
|
|
logger.error('Error verifying state parameter:', err);
|
|
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
if (decodedState.action_id !== action_id) {
|
|
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
if (!decodedState.user) {
|
|
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
identifier = `${decodedState.user}:${action_id}`;
|
|
|
|
if (
|
|
!validateOAuthCsrf(req, res, identifier, OAUTH_CSRF_COOKIE_PATH) &&
|
|
!validateOAuthSession(req, decodedState.user)
|
|
) {
|
|
logger.error('[Action OAuth] CSRF validation failed: no valid CSRF or session cookie', {
|
|
identifier,
|
|
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
|
|
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
|
|
});
|
|
await flowManager.failFlow(identifier, 'oauth', 'CSRF validation failed');
|
|
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
|
|
}
|
|
|
|
const flowState = await flowManager.getFlowState(identifier, 'oauth');
|
|
if (!flowState) {
|
|
throw new Error('OAuth flow not found');
|
|
}
|
|
|
|
const tokenData = await getAccessToken(
|
|
{
|
|
code,
|
|
userId: decodedState.user,
|
|
identifier,
|
|
client_url: flowState.metadata.client_url,
|
|
redirect_uri: flowState.metadata.redirect_uri,
|
|
token_exchange_method: flowState.metadata.token_exchange_method,
|
|
allowedAddresses: flowState.metadata.allowedAddresses,
|
|
/** Encrypted values */
|
|
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
|
|
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
|
|
},
|
|
{
|
|
findToken,
|
|
updateToken,
|
|
createToken,
|
|
},
|
|
);
|
|
await flowManager.completeFlow(identifier, 'oauth', tokenData);
|
|
|
|
const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
|
|
const redirectUrl = `${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`;
|
|
res.redirect(redirectUrl);
|
|
} catch (error) {
|
|
logger.error('Error in OAuth callback:', error);
|
|
await flowManager.failFlow(identifier, 'oauth', error);
|
|
res.redirect(`${basePath}/oauth/error?error=callback_failed`);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|