diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index c468aefc..f01153db 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -103,7 +103,9 @@ export default { codex_cli_simplified_flow: "true", originator: "codex_cli_rs", }, - refreshLeadMs: 432000000, + // Access tokens live ~1h; a 5d lead rotated the refresh token on EVERY call — + // reuse of a rotated token revokes the whole OpenAI session (account logout). + refreshLeadMs: 600000, refresh: { encoding: "form", scope: "openid profile email offline_access", diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 84dd7674..3ca9840f 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -3,6 +3,7 @@ import "open-sse/index.js"; import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; import { getUsageForProvider } from "open-sse/services/usage.js"; +import { isUnrecoverableRefreshError } from "open-sse/services/tokenRefresh.js"; import { getExecutor } from "open-sse/executors/index.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers"; @@ -21,6 +22,11 @@ function isAuthExpiredMessage(usage) { * @returns Promise<{ connection, refreshed: boolean }> */ export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) { + // Re-read latest tokens: OpenAI rotates the refresh token on every refresh, and + // refreshing with a stale snapshot (reuse) revokes the whole session → account logout. + const latest = connection.id ? await getProviderConnectionById(connection.id) : null; + if (latest) connection = latest; + const executor = getExecutor(connection.provider); // Build credentials object from connection @@ -47,6 +53,11 @@ export async function refreshAndUpdateCredentials(connection, force = false, pro // Use executor's refreshCredentials method (with optional proxy) const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions); + // Refresh token reused/invalidated — token family is revoked; do not continue with the dead token. + if (refreshResult && isUnrecoverableRefreshError(refreshResult)) { + throw new Error("Refresh token invalid or reused. Please re-authorize the connection."); + } + if (!refreshResult) { // Refresh failed but we still have an accessToken — try with existing token if (connection.accessToken) { diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index 58a6f870..b253df5c 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -1,6 +1,6 @@ // Re-export from open-sse with local logger import * as log from "../utils/logger.js"; -import { updateProviderConnection } from "../../lib/localDb.js"; +import { getProviderConnectionById, updateProviderConnection } from "../../lib/localDb.js"; import { getProjectIdForConnection, invalidateProjectId, @@ -227,6 +227,25 @@ export async function checkAndRefreshToken(provider, credentials, options = {}) creds.connectionId = creds.id; } + // Adopt latest DB tokens: OpenAI rotates the refresh token on every refresh, and + // refreshing with a stale snapshot (reuse) revokes the whole session → account logout. + if (creds.connectionId) { + const latest = await getProviderConnectionById(creds.connectionId).catch(() => null); + const latestRefreshMs = Date.parse(latest?.lastRefreshAt || ""); + const credsRefreshMs = Date.parse(creds.lastRefreshAt || ""); + const dbIsNewer = Number.isFinite(latestRefreshMs) + && (!Number.isFinite(credsRefreshMs) || latestRefreshMs > credsRefreshMs); + if (dbIsNewer && latest.refreshToken && latest.refreshToken !== creds.refreshToken) { + creds = { + ...creds, + refreshToken: latest.refreshToken, + accessToken: latest.accessToken || creds.accessToken, + expiresAt: latest.expiresAt || latest.tokenExpiresAt || creds.expiresAt, + lastRefreshAt: latest.lastRefreshAt || creds.lastRefreshAt, + }; + } + } + const force = options?.force === true; // ── 1. Regular access-token expiry ────────────────────────────────────────