mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🪃 fix: Retry MCP OAuth Token Refresh Without Scope on Server Rejection (#13412)
LibreChat sends `scope` on the refresh_token grant by default (PR #7924) because some authorization servers expect it. Salesforce rejects any scope on refresh with HTTP 400 "scope parameter not supported" (confirmed in production logs for case 00046259), which broke token refresh and forced re-authentication — amplifying the multi-replica PKCE retry storm. RFC 6749 §6 makes scope optional on refresh (the server reuses the original grant). postRefreshRequest now sends scope as before and retries once WITHOUT it only when the failure is specifically a scope-parameter rejection (isScopeParameterRejection), so servers that need scope are unaffected and Salesforce-like servers self-heal with no operator config.
This commit is contained in:
parent
cb6bd71ab9
commit
a83bcb3490
2 changed files with 212 additions and 6 deletions
141
packages/api/src/mcp/__tests__/handler.refreshScope.test.ts
Normal file
141
packages/api/src/mcp/__tests__/handler.refreshScope.test.ts
Normal file
|
|
@ -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<typeof fetch>;
|
||||
|
||||
/** 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=');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof createHardenedOAuthFetch>,
|
||||
tokenUrl: string | URL,
|
||||
headers: HeadersInit,
|
||||
body: URLSearchParams,
|
||||
serverName: string,
|
||||
): Promise<Response> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue