⚠️ 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

@ -21,7 +21,10 @@ export class FlowStateManager<T = unknown> {
this.ttl = ttl;
this.keyv = store;
this.intervals = new Set();
this.setupCleanupHandlers();
if (!ci) {
this.setupCleanupHandlers();
}
}
private setupCleanupHandlers() {
@ -42,6 +45,49 @@ export class FlowStateManager<T = unknown> {
return `${type}:${flowId}`;
}
/**
* Normalizes an expiration timestamp to milliseconds.
* Detects whether the input is in seconds or milliseconds based on magnitude.
* Timestamps below 10 billion are assumed to be in seconds (valid until ~2286).
* @param timestamp - The expiration timestamp (in seconds or milliseconds)
* @returns The timestamp normalized to milliseconds
*/
private normalizeExpirationTimestamp(timestamp: number): number {
const SECONDS_THRESHOLD = 1e10;
if (timestamp < SECONDS_THRESHOLD) {
return timestamp * 1000;
}
return timestamp;
}
/**
* Checks if a flow's token has expired based on its expires_at field
* @param flowState - The flow state to check
* @returns true if the token has expired, false otherwise (including if no expires_at exists)
*/
private isTokenExpired(flowState: FlowState<T> | undefined): boolean {
if (!flowState?.result) {
return false;
}
if (typeof flowState.result !== 'object') {
return false;
}
if (!('expires_at' in flowState.result)) {
return false;
}
const expiresAt = (flowState.result as { expires_at: unknown }).expires_at;
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
return false;
}
const normalizedExpiresAt = this.normalizeExpirationTimestamp(expiresAt);
return normalizedExpiresAt < Date.now();
}
/**
* Creates a new flow and waits for its completion
*/
@ -272,16 +318,16 @@ export class FlowStateManager<T = unknown> {
): Promise<T> {
const flowKey = this.getFlowKey(flowId, type);
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
logger.debug(`[${flowKey}] Flow already exists`);
if (existingState && !this.isTokenExpired(existingState)) {
logger.debug(`[${flowKey}] Flow already exists with valid token`);
return this.monitorFlow(flowKey, type, signal);
}
await new Promise((resolve) => setTimeout(resolve, 250));
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
logger.debug(`[${flowKey}] Flow exists on 2nd check`);
if (existingState && !this.isTokenExpired(existingState)) {
logger.debug(`[${flowKey}] Flow exists on 2nd check with valid token`);
return this.monitorFlow(flowKey, type, signal);
}