Skip to content

Commit fa6a063

Browse files
authored
fix(codex): recover from refresh-token rotation races instead of dying (#20)
ChatGPT rotates the refresh token on every refresh, and the single-flight guard only covers one process. When two openscience processes (CLI + workspace server) race a refresh — common while requests retry against an exhausted usage limit — the loser persists nothing and keeps a revoked token, so every later refresh fails even after the usage limit resets. The only way out was noticing yourself that you had to re-login. The loader now treats a failed refresh as possibly-stale local state: re-read persisted auth (the racing winner already stored the rotated pair), adopt a still-valid access token or retry the refresh with the rotated refresh token, and only then fail — with an actionable 'reconnect with openscience auth login' message instead of a raw 'Token refresh failed: 400'.
1 parent 0606844 commit fa6a063

1 file changed

Lines changed: 43 additions & 14 deletions

File tree

backend/cli/src/plugin/codex.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -431,20 +431,49 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
431431
// Check if token needs refresh
432432
if (!currentAuth.access || currentAuth.expires < Date.now()) {
433433
log.info("refreshing codex access token")
434-
const tokens = await refreshAccessTokenSingleFlight(currentAuth.refresh)
435-
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
436-
await input.client.auth.set({
437-
path: { id: "openai-codex" },
438-
body: {
439-
type: "oauth",
440-
refresh: tokens.refresh_token,
441-
access: tokens.access_token,
442-
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
443-
...(newAccountId && { accountId: newAccountId }),
444-
},
445-
})
446-
currentAuth.access = tokens.access_token
447-
authWithAccount.accountId = newAccountId
434+
let tokens: TokenResponse | undefined
435+
try {
436+
tokens = await refreshAccessTokenSingleFlight(currentAuth.refresh)
437+
} catch (e) {
438+
// ChatGPT rotates the refresh token on every refresh, and the
439+
// single-flight guard only covers this process. When two
440+
// openscience processes (CLI + workspace server) race a
441+
// refresh — common while requests are being retried against an
442+
// exhausted usage limit — the loser is left holding a revoked
443+
// token and every later refresh fails, even after the limit
444+
// resets. The winner has already persisted the rotated pair,
445+
// so re-read auth before giving up.
446+
const latest = (await getAuth()) as typeof currentAuth & { accountId?: string }
447+
if (latest.type === "oauth" && latest.access && latest.expires > Date.now()) {
448+
currentAuth.access = latest.access
449+
authWithAccount.accountId = latest.accountId ?? authWithAccount.accountId
450+
} else {
451+
if (latest.type === "oauth" && latest.refresh && latest.refresh !== currentAuth.refresh) {
452+
tokens = await refreshAccessTokenSingleFlight(latest.refresh).catch(() => undefined)
453+
}
454+
if (!tokens) {
455+
log.warn("codex token refresh failed", { error: String(e) })
456+
throw new Error(
457+
"Codex sign-in expired. Reconnect it with `openscience auth login` and choose Codex — Sign in with ChatGPT.",
458+
)
459+
}
460+
}
461+
}
462+
if (tokens) {
463+
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
464+
await input.client.auth.set({
465+
path: { id: "openai-codex" },
466+
body: {
467+
type: "oauth",
468+
refresh: tokens.refresh_token,
469+
access: tokens.access_token,
470+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
471+
...(newAccountId && { accountId: newAccountId }),
472+
},
473+
})
474+
currentAuth.access = tokens.access_token
475+
authWithAccount.accountId = newAccountId
476+
}
448477
}
449478

450479
// Build headers

0 commit comments

Comments
 (0)