From 0bc7f86e4b4e9a20434373383fe7d78cf1db157c Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 28 Sep 2026 19:30:14 +0700 Subject: [PATCH] fix(codex): stop refresh-token reuse that logs accounts out on auto-ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI rotates the refresh token on every refresh and revokes the whole session on reuse. A 5-day refreshLeadMs (access tokens live ~1h) rotated the token on every call, and three refresh writers (usage poll, auto-ping tick, 5-min background refresher) each held stale snapshots — auto-ping firing at reset time reliably triggered reuse and logged the account out. - registry: refreshLeadMs 5d -> 10min (refresh only near actual expiry) - refreshAndUpdateCredentials: re-read connection from DB before refresh; throw on unrecoverable refresh instead of continuing with a dead token - checkAndRefreshToken: adopt newer DB tokens before refreshing Co-Authored-By: Claude Code --- open-sse/providers/registry/codex.js | 4 +++- src/app/api/usage/[connectionId]/route.js | 11 +++++++++++ src/sse/services/tokenRefresh.js | 21 ++++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) 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 ────────────────────────────────────────