mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-10 10:02:36 +01:00
🔌 fix: Resolve MCP OAuth flow state race condition (#11941)
* 🔌 fix: Resolve MCP OAuth flow state race condition The OAuth callback arrives before the flow state is stored because `createFlow()` returns a long-running Promise that only resolves on flow COMPLETION, not when the initial PENDING state is persisted. Calling it fire-and-forget with `.catch(() => {})` meant the redirect happened before the state existed, causing "Flow state not found" errors. Changes: - Add `initFlow()` to FlowStateManager that stores PENDING state and returns immediately, decoupling state persistence from monitoring - Await `initFlow()` before emitting the OAuth redirect so the callback always finds existing state - Keep `createFlow()` in the background for monitoring, but log warnings instead of silently swallowing errors - Increase FLOWS cache TTL from 3 minutes to 10 minutes to give users more time to complete OAuth consent screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 🔌 refactor: Revert FLOWS cache TTL change The race condition fix (initFlow) is sufficient on its own. TTL configurability should be a separate enhancement via librechat.yaml mcpSettings rather than a hardcoded increase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 🔌 fix: Address PR review — restore FLOWS TTL, fix blocking-path race, clean up dead args - Restore FLOWS cache TTL to 10 minutes (was silently dropped back to 3) - Add initFlow before oauthStart in blocking handleOAuthRequired path to guarantee state persistence before any redirect - Pass {} to createFlow metadata arg (dead after initFlow writes state) - Downgrade background monitor .catch from logger.warn to logger.debug - Replace process.nextTick with Promise.resolve in test (correct semantics) - Add initFlow TTL assertion test - Add blocking-path ordering test (initFlow → oauthStart → createFlow) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a5123bfa1
commit
f7ac449ca4
5 changed files with 345 additions and 16 deletions
|
|
@ -24,7 +24,6 @@ class MockKeyv<T = string> {
|
|||
return this.store.get(key);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async set(key: string, value: FlowState<T>, _ttl?: number): Promise<true> {
|
||||
this.store.set(key, value);
|
||||
return true;
|
||||
|
|
@ -160,6 +159,71 @@ describe('FlowStateManager', () => {
|
|||
}, 15000);
|
||||
});
|
||||
|
||||
describe('initFlow', () => {
|
||||
const flowId = 'init-test-flow';
|
||||
const type = 'test-type';
|
||||
const flowKey = `${type}:${flowId}`;
|
||||
|
||||
it('stores a PENDING flow state in the cache', async () => {
|
||||
await flowManager.initFlow(flowId, type, { serverName: 'test' });
|
||||
|
||||
const state = await store.get(flowKey);
|
||||
expect(state).toBeDefined();
|
||||
expect(state!.status).toBe('PENDING');
|
||||
expect(state!.type).toBe(type);
|
||||
expect(state!.metadata).toEqual({ serverName: 'test' });
|
||||
expect(state!.createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('overwrites an existing flow state', async () => {
|
||||
await store.set(flowKey, {
|
||||
type,
|
||||
status: 'COMPLETED',
|
||||
metadata: { old: true },
|
||||
createdAt: Date.now() - 10000,
|
||||
});
|
||||
|
||||
await flowManager.initFlow(flowId, type, { new: true });
|
||||
|
||||
const state = await store.get(flowKey);
|
||||
expect(state!.status).toBe('PENDING');
|
||||
expect(state!.metadata).toEqual({ new: true });
|
||||
});
|
||||
|
||||
it('allows createFlow to find and monitor the pre-stored state', async () => {
|
||||
// initFlow stores the PENDING state
|
||||
await flowManager.initFlow(flowId, type, { preStored: true });
|
||||
|
||||
// createFlow should find the existing state and start monitoring
|
||||
const flowPromise = flowManager.createFlow(flowId, type);
|
||||
|
||||
// Complete the flow so the monitor resolves
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await flowManager.completeFlow(flowId, type, 'success');
|
||||
|
||||
const result = await flowPromise;
|
||||
expect(result).toBe('success');
|
||||
}, 15000);
|
||||
|
||||
it('passes the configured TTL to keyv.set', async () => {
|
||||
const setSpy = jest.spyOn(store, 'set');
|
||||
|
||||
await flowManager.initFlow(flowId, type, { serverName: 'test' });
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
flowKey,
|
||||
expect.objectContaining({ status: 'PENDING' }),
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates store write failures', async () => {
|
||||
jest.spyOn(store, 'set').mockRejectedValueOnce(new Error('Store write failed'));
|
||||
|
||||
await expect(flowManager.initFlow(flowId, type)).rejects.toThrow('Store write failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFlow', () => {
|
||||
const flowId = 'test-flow-123';
|
||||
const type = 'test-type';
|
||||
|
|
|
|||
|
|
@ -88,6 +88,24 @@ export class FlowStateManager<T = unknown> {
|
|||
return normalizedExpiresAt < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores initial PENDING flow state without starting the monitor loop.
|
||||
* Use this when you need to guarantee the state is persisted before
|
||||
* performing an action (e.g., an OAuth redirect), then call createFlow()
|
||||
* separately to start monitoring for completion.
|
||||
*/
|
||||
async initFlow(flowId: string, type: string, metadata: FlowMetadata = {}): Promise<void> {
|
||||
const flowKey = this.getFlowKey(flowId, type);
|
||||
const initialState: FlowState = {
|
||||
type,
|
||||
status: 'PENDING',
|
||||
metadata,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
logger.debug(`[${flowKey}] Storing initial flow state`);
|
||||
await this.keyv.set(flowKey, initialState, this.ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new flow and waits for its completion
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue