⚠️ fix: OAuth Error and Token Expiry Detection and Reporting Improvements (#10922)

* fix: create new flows on invalid_grant errors

* chore: fix failing test

* chore: keep isOAuthError test function in sync with implementation

* test: add tests for OAuth error detection on invalid grant errors

* test: add tests for creating new flows when token expires

* test: add test for flow clean up prior to creation

* refactor: consolidate token expiration handling in FlowStateManager

- Removed the old token expiration checks and replaced them with a new method, `isTokenExpired`, to streamline the logic.
- Introduced `normalizeExpirationTimestamp` to handle timestamp normalization for both seconds and milliseconds.
- Updated tests to ensure proper functionality of flow management with token expiration scenarios.

* fix: conditionally setup cleanup handlers in FlowStateManager

- Updated the FlowStateManager constructor to only call setupCleanupHandlers if the ci parameter is not set, improving flexibility in flow management.

* chore: enhance OAuth token refresh logging

- Introduced a new method, `processRefreshResponse`, to streamline the processing of token refresh responses from the OAuth server.
- Improved logging to provide detailed information about token refresh operations, including whether new tokens were received and if the refresh token was rotated.
- Updated existing token handling logic to utilize the new method, ensuring consistency and clarity in token management.

* chore: enhance logging for MCP server reinitialization

- Updated the logging in the reinitMCPServer function to provide more detailed information about the response, including success status, OAuth requirements, presence of the OAuth URL, and the count of tools involved. This improves the clarity and usefulness of logs for debugging purposes.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-12-12 10:51:28 -08:00 committed by GitHub
parent ef96ce2b4b
commit abeaab6e17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1191 additions and 419 deletions

View file

@ -167,6 +167,10 @@ export class MCPConnectionFactory {
config?.oauth,
);
// Delete any existing flow state to ensure we start fresh
// This prevents stale codeVerifier issues when re-authenticating
await this.flowManager!.deleteFlow(flowId, 'mcp_oauth');
// Create the flow state so the OAuth callback can find it
// We spawn this in the background without waiting for it
this.flowManager!.createFlow(flowId, 'mcp_oauth', flowMetadata).catch(() => {

View file

@ -70,6 +70,10 @@ describe('MCPConnection Error Detection', () => {
if (message.includes('401') || message.includes('non-200 status code (401)')) {
return true;
}
// Check for invalid_grant (OAuth servers return this for expired/revoked grants)
if (message.includes('invalid_grant')) {
return true;
}
// Check for invalid_token (OAuth servers return this for expired/revoked tokens)
if (message.includes('invalid_token')) {
return true;
@ -159,6 +163,14 @@ describe('MCPConnection Error Detection', () => {
const error = { message: 'The access token is invalid_token or expired' };
expect(isOAuthError(error)).toBe(true);
});
it('should detect OAuth error for invalid_grant', () => {
const error = {
message:
'Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_grant","error_description":"The provided authorization grant is invalid, expired, or revoked"}',
};
expect(isOAuthError(error)).toBe(true);
});
});
describe('error type differentiation', () => {

View file

@ -49,6 +49,7 @@ describe('MCPConnectionFactory', () => {
createFlow: jest.fn(),
createFlowWithHandler: jest.fn(),
getFlowState: jest.fn(),
deleteFlow: jest.fn().mockResolvedValue(true),
} as unknown as jest.Mocked<FlowStateManager<MCPOAuthTokens | null>>;
mockConnectionInstance = {
@ -266,6 +267,78 @@ describe('MCPConnectionFactory', () => {
}),
);
});
it('should delete existing flow before creating new OAuth flow to prevent stale codeVerifier', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
user: mockUser,
};
const oauthOptions = {
user: mockUser,
useOAuth: true,
returnOnOAuth: true,
oauthStart: jest.fn(),
flowManager: mockFlowManager,
};
const mockFlowData = {
authorizationUrl: 'https://auth.example.com',
flowId: 'user123:test-server',
flowMetadata: {
serverName: 'test-server',
userId: 'user123',
serverUrl: 'https://api.example.com',
state: 'test-state',
codeVerifier: 'new-code-verifier-xyz',
clientInfo: { client_id: 'test-client' },
metadata: {
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
issuer: 'https://api.example.com',
},
},
};
mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData);
mockFlowManager.deleteFlow.mockResolvedValue(true);
mockFlowManager.createFlow.mockRejectedValue(new Error('Timeout expected'));
mockConnectionInstance.isConnected.mockResolvedValue(false);
let oauthRequiredHandler: (data: Record<string, unknown>) => Promise<void>;
mockConnectionInstance.on.mockImplementation((event, handler) => {
if (event === 'oauthRequired') {
oauthRequiredHandler = handler as (data: Record<string, unknown>) => Promise<void>;
}
return mockConnectionInstance;
});
try {
await MCPConnectionFactory.create(basicOptions, oauthOptions);
} catch {
// Expected to fail due to connection not established
}
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
// Verify deleteFlow was called with correct parameters
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('user123:test-server', 'mcp_oauth');
// Verify deleteFlow was called before createFlow
const deleteCallOrder = mockFlowManager.deleteFlow.mock.invocationCallOrder[0];
const createCallOrder = mockFlowManager.createFlow.mock.invocationCallOrder[0];
expect(deleteCallOrder).toBeLessThan(createCallOrder);
// Verify createFlow was called with fresh metadata
expect(mockFlowManager.createFlow).toHaveBeenCalledWith(
'user123:test-server',
'mcp_oauth',
expect.objectContaining({
codeVerifier: 'new-code-verifier-xyz',
}),
);
});
});
describe('connection retry logic', () => {

View file

@ -652,7 +652,7 @@ export class MCPConnection extends EventEmitter {
}
// Check if it's an OAuth authentication error
if (errorCode === 401 || errorCode === 403) {
if (this.isOAuthError(error)) {
logger.warn(`${this.getLogPrefix()} OAuth authentication error detected`);
this.emit('oauthError', error);
}
@ -815,6 +815,10 @@ export class MCPConnection extends EventEmitter {
if (message.includes('invalid_token')) {
return true;
}
// Check for invalid_grant (OAuth servers return this for expired/revoked grants)
if (message.includes('invalid_grant')) {
return true;
}
// Check for authentication required
if (message.includes('authentication required') || message.includes('unauthorized')) {
return true;

View file

@ -558,6 +558,39 @@ export class MCPOAuthHandler {
: `${baseUrl}/api/mcp/oauth/callback`;
}
/**
* Processes and logs a token refresh response from an OAuth server.
* Normalizes the response to MCPOAuthTokens format and logs debug info about refresh token rotation.
*/
private static processRefreshResponse(
tokens: Record<string, unknown>,
serverName: string,
source: string,
): MCPOAuthTokens {
const hasNewRefreshToken = !!tokens.refresh_token;
logger.debug(`[MCPOAuth] Token refresh response (${source})`, {
serverName,
has_new_access_token: !!tokens.access_token,
has_new_refresh_token: hasNewRefreshToken,
refresh_token_rotated: hasNewRefreshToken,
expires_in: tokens.expires_in,
});
if (!hasNewRefreshToken) {
logger.debug(
`[MCPOAuth] OAuth server did not return new refresh_token for ${serverName} - existing refresh token remains valid (normal for non-rotating providers)`,
);
}
return {
...tokens,
obtained_at: Date.now(),
expires_at:
typeof tokens.expires_in === 'number' ? Date.now() + tokens.expires_in * 1000 : undefined,
} as MCPOAuthTokens;
}
/**
* Refreshes OAuth tokens using a refresh token
*/
@ -687,12 +720,7 @@ export class MCPOAuthHandler {
}
const tokens = await response.json();
return {
...tokens,
obtained_at: Date.now(),
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
};
return this.processRefreshResponse(tokens, metadata.serverName, 'stored client info');
}
// Fallback: If we have pre-configured OAuth settings, use them
@ -771,12 +799,7 @@ export class MCPOAuthHandler {
}
const tokens = await response.json();
return {
...tokens,
obtained_at: Date.now(),
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
};
return this.processRefreshResponse(tokens, metadata.serverName, 'pre-configured OAuth');
}
/** For auto-discovered OAuth, we need the server URL */
@ -828,12 +851,7 @@ export class MCPOAuthHandler {
}
const tokens = await response.json();
return {
...tokens,
obtained_at: Date.now(),
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
};
return this.processRefreshResponse(tokens, metadata.serverName, 'auto-discovered OAuth');
} catch (error) {
logger.error(`[MCPOAuth] Failed to refresh tokens for ${metadata.serverName}`, error);
throw error;

View file

@ -134,6 +134,9 @@ export class MCPTokenStorage {
// Store refresh token if available
if (tokens.refresh_token) {
logger.debug(
`${logPrefix} New refresh token received from OAuth server, will store/update`,
);
const encryptedRefreshToken = await encryptV2(tokens.refresh_token);
const extendedTokens = tokens as ExtendedOAuthTokens;
const refreshTokenExpiry = extendedTokens.refresh_token_expires_in
@ -173,6 +176,10 @@ export class MCPTokenStorage {
await createToken(refreshTokenData);
logger.debug(`${logPrefix} Created refresh token (no update methods available)`);
}
} else {
logger.debug(
`${logPrefix} No refresh token in response - OAuth server did not rotate refresh token (this is normal for some providers)`,
);
}
/** Store client information if provided */
@ -320,6 +327,13 @@ export class MCPTokenStorage {
const newTokens = await refreshTokens(decryptedRefreshToken, metadata);
logger.debug(`${logPrefix} Refresh completed`, {
has_new_access_token: !!newTokens.access_token,
has_new_refresh_token: !!newTokens.refresh_token,
refresh_token_will_be_rotated: !!newTokens.refresh_token,
expires_at: newTokens.expires_at,
});
// Store the refreshed tokens (handles both create and update)
// Pass existing token state to avoid duplicate DB calls
await this.storeTokens({