Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export {
updateAccountQuota,
} from "./quota";
import { extractAccountId } from "../oauth/chatgpt";
import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { getMainAccountPlan, isMainAccountTokenLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
import {
Expand Down Expand Up @@ -566,12 +566,29 @@ const MAIN_TERMINAL_AUTH_CODES = new Set([
"invalid_refresh_token",
]);

async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
if (resp.status === 401) return true;
/**
* A WHAM 401 is not itself proof the local credential died. Upstream edges can
* transiently reject a still-valid access token (region/anti-abuse/rotation
* races), and fail-closing on every bare 401 makes a healthy main account flip
* needs-reauth on the next GUI quota poll. Only treat the response as terminal
* when the body carries a known terminal code or the local access-token JWT has
* actually expired.
*/
async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
if (resp.status === 401) {
if (!accessTokenLive) return true;
const code = await readMainAuthErrorCode(resp);
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
}
if (resp.status !== 403) return false;
const code = await readMainAuthErrorCode(resp);
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
}

async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
try {
const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
if (!body.displaySafe) return false;
if (!body.displaySafe) return undefined;
const parsed = JSON.parse(body.text) as {
detail?: { code?: unknown } | string;
error?: { code?: unknown } | string;
Expand All @@ -582,9 +599,9 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
: typeof parsed.error === "object" && parsed.error !== null
? parsed.error.code
: parsed.code;
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
return code;
} catch {
return false;
return undefined;
}
}

Expand Down Expand Up @@ -704,7 +721,7 @@ async function fetchMainAccountInfoWhileOwned(
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenLive());
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
if (retried) return retried;
if (terminalAuthFailure) {
Expand Down
33 changes: 27 additions & 6 deletions tests/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ let previousCodexHome: string | undefined;
let previousManualImportEnv: string | undefined;
let previousFetch: typeof fetch;

function jwtWithExp(exp: number): string {
const enc = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
return enc({ alg: "RS256", typ: "JWT" }) + "." + enc({ exp }) + ".sig";
}

function makeConfig(overrides: Partial<OcxConfig> = {}): OcxConfig {
return {
port: 10100,
Expand Down Expand Up @@ -742,9 +747,9 @@ describe("codex-auth API", () => {
expect(main?.needsReauth).toBe(true);
});

test("main account 401 marks needsReauth and exposes it in the DTO (#327)", async () => {
test("bare main account 401 with a live token is transient, not reauth", async () => {
writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
tokens: { access_token: "expired-main", account_id: "acct-main" },
tokens: { access_token: "live-main", account_id: "acct-main" },
}));
globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch;

Expand All @@ -753,8 +758,23 @@ describe("codex-auth API", () => {
const data = await resp!.json() as { accounts: Array<{ id: string; hasCredential: boolean; needsReauth?: boolean }> };
const main = data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID);

expect(main).toMatchObject({ hasCredential: true, needsReauth: true });
expect(main).toMatchObject({ hasCredential: true, needsReauth: false });
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
});

test("main account 401 with an expired access token is terminal", async () => {
writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
tokens: { access_token: jwtWithExp(1), account_id: "acct-main" },
}));
globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch;

const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1");
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
const data = await resp!.json() as { accounts: Array<{ id: string; needsReauth?: boolean }> };

expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.needsReauth).toBe(true);
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true);
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
});

test("main account invalid-workspace 403 is terminal but a generic 403 is not (#327)", async () => {
Expand Down Expand Up @@ -803,7 +823,7 @@ describe("codex-auth API", () => {
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
});

test("BUG-R327: main account exposes and updates needsReauth from WHAM auth responses", async () => {
test("BUG-R327: main account exposes and updates needsReauth from terminal WHAM auth responses", async () => {
writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
tokens: {
access_token: "main-access",
Expand All @@ -828,9 +848,10 @@ describe("codex-auth API", () => {
return data.accounts.find(account => account.id === "__main__")!;
};

expect((await listMain()).needsReauth).toBe(true);
// A bare 401 with a still-live token is transient: it must not mark reauth.
expect((await listMain()).needsReauth).toBe(false);
status = 403;
expect((await listMain()).needsReauth).toBe(true);
expect((await listMain()).needsReauth).toBe(false);
status = 200;
expect((await listMain()).needsReauth).toBe(false);
});
Expand Down
Loading