diff --git a/packages/api/src/mcp/__tests__/handler.refreshScope.test.ts b/packages/api/src/mcp/__tests__/handler.refreshScope.test.ts new file mode 100644 index 0000000000..cb80058d6b --- /dev/null +++ b/packages/api/src/mcp/__tests__/handler.refreshScope.test.ts @@ -0,0 +1,141 @@ +/** + * Coverage for the `scope` parameter fallback on the OAuth refresh_token grant + * (support case 00046259 — Salesforce). + * + * LibreChat sends `scope` on refresh by default because some authorization servers + * expect it (added in the original MCP OAuth support, PR #7924). Salesforce, however, + * rejects any `scope` on the refresh grant with HTTP 400 "scope parameter not + * supported", which broke token refresh in production and forced re-authentication + * (amplifying the multi-replica PKCE retry storm). RFC 6749 §6 makes `scope` optional + * on refresh, so the handler now retries once without it — only when the server + * rejected the request because of that parameter. + * + * Mirrors handler.test.ts: SDK discovery is mocked and global.fetch is stubbed + * (the public hostname still resolves through the real SSRF gate). Request bodies are + * snapshotted at call time because the fallback reuses (and mutates) one URLSearchParams. + */ + +import type { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { MCPOAuthHandler } from '~/mcp/oauth'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ + startAuthorization: jest.fn(), + discoverAuthorizationServerMetadata: jest.fn(), + discoverOAuthProtectedResourceMetadata: jest.fn(), + registerClient: jest.fn(), + exchangeAuthorization: jest.fn(), + extractWWWAuthenticateParams: jest.fn(() => ({})), +})); + +import { discoverAuthorizationServerMetadata } from '@modelcontextprotocol/sdk/client/auth.js'; + +const mockDiscover = discoverAuthorizationServerMetadata as jest.MockedFunction< + typeof discoverAuthorizationServerMetadata +>; + +describe('MCPOAuthHandler.refreshOAuthTokens — scope parameter fallback', () => { + const refreshToken = 'refresh-token-abc'; + const originalFetch = global.fetch; + const mockFetch = jest.fn() as unknown as jest.MockedFunction; + + /** Bodies captured at the moment each request is sent (the fallback mutates one URLSearchParams). */ + const sentBodies: string[] = []; + let responseQueue: Response[] = []; + + const metadata = { + serverName: 'salesforce', + serverUrl: 'https://auth.example.com', + clientInfo: { + client_id: 'client-abc', + scope: 'refresh_token mcp_api', + }, + }; + + const discovered = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + token_endpoint_auth_methods_supported: ['none'], + response_types_supported: ['code'], + } as AuthorizationServerMetadata; + + const scopeRejection = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => + '{"error":"invalid_request","error_description":"scope parameter not supported"}', + } as Response; + + const success = { + ok: true, + json: async () => ({ + access_token: 'fresh-access', + refresh_token: 'fresh-refresh', + token_type: 'Bearer', + }), + } as Response; + + beforeEach(() => { + jest.clearAllMocks(); + sentBodies.length = 0; + responseQueue = []; + global.fetch = mockFetch; + mockDiscover.mockResolvedValue(discovered); + mockFetch.mockImplementation((async (_url: unknown, init?: { body?: unknown }) => { + sentBodies.push(String(init?.body ?? '')); + const next = responseQueue.shift(); + if (!next) { + throw new Error('No queued response for fetch call'); + } + return next; + }) as unknown as typeof fetch); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('retries without scope when the server rejects the scope parameter, then succeeds', async () => { + responseQueue = [scopeRejection, success]; + + const result = await MCPOAuthHandler.refreshOAuthTokens(refreshToken, metadata, {}, {}); + + expect(sentBodies).toHaveLength(2); + expect(sentBodies[0]).toContain('scope='); + expect(sentBodies[1]).not.toContain('scope='); + expect(sentBodies[1]).toContain('grant_type=refresh_token'); + expect(result.access_token).toBe('fresh-access'); + }); + + it('does NOT retry and surfaces the error when the failure is unrelated to scope', async () => { + responseQueue = [ + { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => '{"error":"invalid_grant","error_description":"expired refresh token"}', + } as Response, + ]; + + await expect( + MCPOAuthHandler.refreshOAuthTokens(refreshToken, metadata, {}, {}), + ).rejects.toThrow(/invalid_grant|Token refresh failed/); + expect(sentBodies).toHaveLength(1); + expect(sentBodies[0]).toContain('scope='); + }); + + it('makes a single request (no scope, no retry) when no scope is configured', async () => { + const noScope = { ...metadata, clientInfo: { client_id: 'client-abc' } }; + responseQueue = [success]; + + await MCPOAuthHandler.refreshOAuthTokens(refreshToken, noScope, {}, {}); + + expect(sentBodies).toHaveLength(1); + expect(sentBodies[0]).not.toContain('scope='); + }); +}); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 6f0aed3101..31649c783a 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -1066,6 +1066,67 @@ export class MCPOAuthHandler { } as MCPOAuthTokens; } + /** + * Posts a `refresh_token` grant, transparently retrying without the `scope` + * parameter when — and only when — the authorization server rejects the request + * because of that parameter. + * + * RFC 6749 §6 makes `scope` optional on refresh (the server reuses the originally + * granted scope when it is omitted). LibreChat sends it by default because some + * servers expect it, but others — notably Salesforce — reject any `scope` on the + * refresh grant with HTTP 400 "scope parameter not supported". A failed refresh + * forces a full re-authentication, which on multi-replica deployments amplifies the + * PKCE retry storm, so we recover automatically instead of surfacing the failure. + */ + private static async postRefreshRequest( + oauthFetch: ReturnType, + tokenUrl: string | URL, + headers: HeadersInit, + body: URLSearchParams, + serverName: string, + ): Promise { + const response = await oauthFetch(tokenUrl, { method: 'POST', headers, body }); + if (response.ok || !body.has('scope')) { + return response; + } + + /** Body is consumed here, so this branch must either retry or throw — never fall through. */ + const errorText = await response.text(); + if (!this.isScopeParameterRejection(response.status, errorText)) { + throw new Error( + `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + logger.warn( + `[MCPOAuth] ${serverName} rejected the scope parameter on token refresh (HTTP ${response.status}); retrying without scope per RFC 6749 §6`, + ); + body.delete('scope'); + return oauthFetch(tokenUrl, { method: 'POST', headers, body }); + } + + /** + * Narrowly detects "this server does not accept a `scope` parameter on the refresh + * grant" so the retry-without-scope fallback can never mask unrelated refresh + * failures (e.g. `invalid_grant` for an expired/revoked refresh token). Matches the + * RFC 6749 `invalid_scope` error and Salesforce's "scope parameter not supported". + */ + private static isScopeParameterRejection(status: number, body: string): boolean { + if (status < 400 || status >= 500) { + return false; + } + const lower = body.toLowerCase(); + if (!lower.includes('scope')) { + return false; + } + return ( + lower.includes('invalid_scope') || + lower.includes('not supported') || + lower.includes('unsupported') || + lower.includes('not allowed') + ); + } + /** * Refreshes OAuth tokens using a refresh token */ @@ -1215,11 +1276,13 @@ export class MCPOAuthHandler { }); const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); - const response = await oauthFetch(tokenUrl, { - method: 'POST', + const response = await this.postRefreshRequest( + oauthFetch, + tokenUrl, headers, body, - }); + metadata.serverName, + ); if (!response.ok) { const errorText = await response.text(); @@ -1299,11 +1362,13 @@ export class MCPOAuthHandler { } const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); - const response = await oauthFetch(tokenUrl, { - method: 'POST', + const response = await this.postRefreshRequest( + oauthFetch, + tokenUrl, headers, body, - }); + metadata.serverName, + ); if (!response.ok) { const errorText = await response.text();