diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 043df28286..7f8524456c 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -43,7 +43,13 @@ import { parseAccountPoolStrategy, parseAccountPriority, } from "./pool-rotation"; -import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; +import { + checkAccountIdCollision, + checkManualImportCollision, + getMainChatgptAccountId, + readCodexTokens, + readCodexTokensResult, +} from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -67,7 +73,7 @@ export { setAccountQuotaFromParsed, updateAccountQuota, } from "./quota"; -import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; +import { extractAccountId, decodeJwtPayload, extractEmail } from "../oauth/chatgpt"; import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper"; import { reconcileLiveStateStores } from "../lib/state-store-registrations"; @@ -1226,9 +1232,13 @@ export async function handleCodexAuthAPI( const runtimeConfig = getRuntimeConfig(config); const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); - // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. + // Manual-import identity must come from the token, not request-controlled metadata. const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; - const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); + const derivedEmail = extractEmail(undefined, body.accessToken); + if (!derivedEmail) { + return jsonResponse({ error: "Access token does not contain an email claim" }, 400); + } + const collision = checkManualImportCollision(derivedAccountId); if (collision.collision) { return jsonResponse({ error: collision.reason }, 400); } @@ -1249,7 +1259,7 @@ export async function handleCodexAuthAPI( markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); const accounts = latestConfig.codexAccounts ?? []; - accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); + accounts.push(withCodexAccountLogLabel({ id: body.id, email: derivedEmail, plan: body.plan, isMain: false }, accounts)); latestConfig.codexAccounts = accounts; saveRuntimeConfig(config, latestConfig); reconcileLiveStateStores(); diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index a7d10e7c97..93fdf86bbd 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -105,3 +105,18 @@ export function checkAccountIdCollision( } return { collision: false }; } + +// Manual imports do not have provider-verified email or plan metadata, so they cannot safely use +// those fields to distinguish members that share an account id. +export function checkManualImportCollision( + chatgptAccountId: string, +): { collision: true; reason: string } | { collision: false } { + for (const account of loadConfig().codexAccounts ?? []) { + if (!isSelectableCodexPoolAccount(account)) continue; + const cred = getCodexAccountCredential(account.id); + if (cred?.chatgptAccountId === chatgptAccountId) { + return { collision: true, reason: `Account is already in the pool (${account.id}).` }; + } + } + return { collision: false }; +} diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 661309e74d..3ebc2ed302 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -88,11 +88,22 @@ function enableManualImport(): void { process.env[MANUAL_IMPORT_ENV] = "1"; } +function manualAccessToken( + email = "manual-test@example.test", + chatgptAccountId = "acct-manual-test", +): string { + const payload = Buffer.from(JSON.stringify({ + email, + chatgpt_account_id: chatgptAccountId, + })).toString("base64url"); + return `header.${payload}.signature`; +} + function manualImportBody(overrides: Record = {}): Record { return { id: "manual-test", email: "manual-test@example.test", - accessToken: "access-manual-test", + accessToken: manualAccessToken(), refreshToken: "refresh-manual-test", chatgptAccountId: "acct-manual-test", ...overrides, @@ -2239,7 +2250,7 @@ describe("codex-auth API", () => { expect(config.codexAccounts?.map(a => a.id)).toEqual(["manual-enabled"]); expect(config.codexAccounts?.[0]?.logLabel).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); expect(getCodexAccountCredential("manual-enabled")).toMatchObject({ - accessToken: "access-manual-test", + accessToken: expect.stringMatching(/^header\./), refreshToken: "refresh-manual-test", chatgptAccountId: "acct-manual-test", }); @@ -2248,6 +2259,38 @@ describe("codex-auth API", () => { expect(warmup.calls()).toBe(1); }); + test("POST /api/codex-auth/accounts uses token email to reject a changed-email duplicate", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const config = makeConfig(); + const firstBody = manualImportBody({ id: "manual-original" }); + const firstRequest = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(firstBody), + }); + expect((await handleCodexAuthAPI(firstRequest, new URL(firstRequest.url), config))!.status).toBe(200); + + const duplicateRequest = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ + id: "manual-copy", + email: "changed@example.test", + plan: "business", + })), + }); + const duplicateResponse = await handleCodexAuthAPI(duplicateRequest, new URL(duplicateRequest.url), config); + + expect(duplicateResponse!.status).toBe(400); + expect(await duplicateResponse!.json()).toMatchObject({ + error: "Account is already in the pool (manual-original).", + }); + expect(config.codexAccounts).toHaveLength(1); + expect(config.codexAccounts?.[0]?.email).toBe("manual-test@example.test"); + expect(getCodexAccountCredential("manual-copy")).toBeNull(); + }); + test("POST /api/codex-auth/accounts allows a pool account matching the main login", async () => { enableManualImport(); mockCodexWarmupSuccess(); @@ -2263,6 +2306,7 @@ describe("codex-auth API", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(manualImportBody({ id: "manual-main-match", + accessToken: manualAccessToken("manual-test@example.test", "acct-main-login"), chatgptAccountId: "acct-main-login", })), });