🛡️ fix: Secure MCP/Actions OAuth Flows, Resolve Race Condition & Tool Cache Cleanup (#11756)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 🔧 fix: Update OAuth error message for clarity

- Changed the default error message in the OAuth error route from 'Unknown error' to 'Unknown OAuth error' to provide clearer context during authentication failures.

* 🔒 feat: Enhance OAuth flow with CSRF protection and session management

- Implemented CSRF protection for OAuth flows by introducing `generateOAuthCsrfToken`, `setOAuthCsrfCookie`, and `validateOAuthCsrf` functions.
- Added session management for OAuth with `setOAuthSession` and `validateOAuthSession` middleware.
- Updated routes to bind CSRF tokens for MCP and action OAuth flows, ensuring secure authentication.
- Enhanced tests to validate CSRF handling and session management in OAuth processes.

* 🔧 refactor: Invalidate cached tools after user plugin disconnection

- Added a call to `invalidateCachedTools` in the `updateUserPluginsController` to ensure that cached tools are refreshed when a user disconnects from an MCP server after a plugin authentication update. This change improves the accuracy of tool data for users.

* chore: imports order

* fix: domain separator regex usage in ToolService

- Moved the declaration of `domainSeparatorRegex` to avoid redundancy in the `loadActionToolsForExecution` function, improving code clarity and performance.

* chore: OAuth flow error handling and CSRF token generation

- Enhanced the OAuth callback route to validate the flow ID format, ensuring proper error handling for invalid states.
- Updated the CSRF token generation function to require a JWT secret, throwing an error if not provided, which improves security and clarity in token generation.
- Adjusted tests to reflect changes in flow ID handling and ensure robust validation across various scenarios.
This commit is contained in:
Danny Avila 2026-02-12 14:22:05 -05:00 committed by GitHub
parent 72a30cd9c4
commit 599f4a11f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 523 additions and 141 deletions

View file

@ -0,0 +1,89 @@
import crypto from 'crypto';
import type { Request, Response, NextFunction } from 'express';
export const OAUTH_CSRF_COOKIE = 'oauth_csrf';
export const OAUTH_CSRF_MAX_AGE = 10 * 60 * 1000;
export const OAUTH_SESSION_COOKIE = 'oauth_session';
export const OAUTH_SESSION_MAX_AGE = 24 * 60 * 60 * 1000;
export const OAUTH_SESSION_COOKIE_PATH = '/api';
const isProduction = process.env.NODE_ENV === 'production';
/** Generates an HMAC-based token for OAuth CSRF protection */
export function generateOAuthCsrfToken(flowId: string, secret?: string): string {
const key = secret || process.env.JWT_SECRET;
if (!key) {
throw new Error('JWT_SECRET is required for OAuth CSRF token generation');
}
return crypto.createHmac('sha256', key).update(flowId).digest('hex').slice(0, 32);
}
/** Sets a SameSite=Lax CSRF cookie bound to a specific OAuth flow */
export function setOAuthCsrfCookie(res: Response, flowId: string, cookiePath: string): void {
res.cookie(OAUTH_CSRF_COOKIE, generateOAuthCsrfToken(flowId), {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: OAUTH_CSRF_MAX_AGE,
path: cookiePath,
});
}
/**
* Validates the per-flow CSRF cookie against the expected HMAC.
* Uses timing-safe comparison and always clears the cookie to prevent replay.
*/
export function validateOAuthCsrf(
req: Request,
res: Response,
flowId: string,
cookiePath: string,
): boolean {
const cookie = (req.cookies as Record<string, string> | undefined)?.[OAUTH_CSRF_COOKIE];
res.clearCookie(OAUTH_CSRF_COOKIE, { path: cookiePath });
if (!cookie) {
return false;
}
const expected = generateOAuthCsrfToken(flowId);
if (cookie.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(cookie), Buffer.from(expected));
}
/**
* Express middleware that sets the OAuth session cookie after JWT authentication.
* Chain after requireJwtAuth on routes that precede an OAuth redirect (e.g., reinitialize, bind).
*/
export function setOAuthSession(req: Request, res: Response, next: NextFunction): void {
const user = (req as Request & { user?: { id?: string } }).user;
if (user?.id && !(req.cookies as Record<string, string> | undefined)?.[OAUTH_SESSION_COOKIE]) {
setOAuthSessionCookie(res, user.id);
}
next();
}
/** Sets a SameSite=Lax session cookie that binds the browser to the authenticated userId */
export function setOAuthSessionCookie(res: Response, userId: string): void {
res.cookie(OAUTH_SESSION_COOKIE, generateOAuthCsrfToken(userId), {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: OAUTH_SESSION_MAX_AGE,
path: OAUTH_SESSION_COOKIE_PATH,
});
}
/** Validates the session cookie against the expected userId using timing-safe comparison */
export function validateOAuthSession(req: Request, userId: string): boolean {
const cookie = (req.cookies as Record<string, string> | undefined)?.[OAUTH_SESSION_COOKIE];
if (!cookie) {
return false;
}
const expected = generateOAuthCsrfToken(userId);
if (cookie.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(cookie), Buffer.from(expected));
}

View file

@ -1 +1,2 @@
export * from './csrf';
export * from './tokens';