fix(codex): stop refresh-token reuse that logs accounts out on auto-ping

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 <noreply@anthropic.com>
This commit is contained in:
decoluaandClaude Code committed 2026-09-28 19:30:14 +07:00
1 parent 9f41ee754b
commit 0bc7f86e4b
3 files changed
+34 -2

No files matched your search

+3 -1
View File
@@ -103,7 +103,9 @@ export default {
codex_cli_simplified_flow: "true", codex_cli_simplified_flow: "true",
originator: "codex_cli_rs", 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: { refresh: {
encoding: "form", encoding: "form",
scope: "openid profile email offline_access", scope: "openid profile email offline_access",
+11
View File
@@ -3,6 +3,7 @@ import "open-sse/index.js";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { getUsageForProvider } from "open-sse/services/usage.js"; 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 { getExecutor } from "open-sse/executors/index.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers"; import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
@@ -21,6 +22,11 @@ function isAuthExpiredMessage(usage) {
* @returns Promise<{ connection, refreshed: boolean }> * @returns Promise<{ connection, refreshed: boolean }>
*/ */
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) { 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); const executor = getExecutor(connection.provider);
// Build credentials object from connection // 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) // Use executor's refreshCredentials method (with optional proxy)
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions); 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) { if (!refreshResult) {
// Refresh failed but we still have an accessToken — try with existing token // Refresh failed but we still have an accessToken — try with existing token
if (connection.accessToken) { if (connection.accessToken) {
+20 -1
View File
@@ -1,6 +1,6 @@
// Re-export from open-sse with local logger // Re-export from open-sse with local logger
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { updateProviderConnection } from "../../lib/localDb.js"; import { getProviderConnectionById, updateProviderConnection } from "../../lib/localDb.js";
import { import {
getProjectIdForConnection, getProjectIdForConnection,
invalidateProjectId, invalidateProjectId,
@@ -227,6 +227,25 @@ export async function checkAndRefreshToken(provider, credentials, options = {})
creds.connectionId = creds.id; 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; const force = options?.force === true;
// ── 1. Regular access-token expiry ──────────────────────────────────────── // ── 1. Regular access-token expiry ────────────────────────────────────────