Skip to content
Draft
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
20 changes: 15 additions & 5 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Comment on lines +1235 to +1241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Require a ChatGPT account-ID claim from the access token.

Line 1236 falls back to request-controlled body.chatgptAccountId. A token with an email claim but no supported account-ID claim can therefore select a different collision identity and bypass checkManualImportCollision.

Reject the import when extractAccountId(undefined, body.accessToken) returns undefined. Add a regression test that submits such a token with a supplied chatgptAccountId and expects no credential or account row.

Proposed fix
-    const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
+    const derivedAccountId = extractAccountId(undefined, body.accessToken);
+    if (!derivedAccountId) {
+      return jsonResponse({ error: "Access token does not contain a ChatGPT account ID claim" }, 400);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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);
// Manual-import identity must come from the token, not request-controlled metadata.
const derivedAccountId = extractAccountId(undefined, body.accessToken);
if (!derivedAccountId) {
return jsonResponse({ error: "Access token does not contain a ChatGPT account ID claim" }, 400);
}
const derivedEmail = extractEmail(undefined, body.accessToken);
if (!derivedEmail) {
return jsonResponse({ error: "Access token does not contain an email claim" }, 400);
}
const collision = checkManualImportCollision(derivedAccountId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codex/auth-api.ts` around lines 1235 - 1241, Require
extractAccountId(undefined, body.accessToken) to return a value in the
manual-import flow before calling checkManualImportCollision; remove the
fallback to body.chatgptAccountId and reject tokens without an account-ID claim.
Add a regression test covering a token with an email but no supported account-ID
claim plus a supplied chatgptAccountId, asserting that neither credential nor
account rows are created.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject tokens without a verified account ID

When a warmup-valid access token has an email but no account-id claim recognized by extractAccountId, derivedAccountId still falls back to request-controlled body.chatgptAccountId; this new collision check therefore compares and later persists an unverified identity, allowing the same token to bypass duplicate detection by submitting another account ID that the upstream warmup accepts. Reject tokens without a token-derived account ID, as is already done for email, or obtain the ID from a provider-verified response before checking the collision.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

if (collision.collision) {
return jsonResponse({ error: collision.reason }, 400);
}
Expand All @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
48 changes: 46 additions & 2 deletions tests/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}): Record<string, unknown> {
return {
id: "manual-test",
email: "manual-test@example.test",
accessToken: "access-manual-test",
accessToken: manualAccessToken(),
refreshToken: "refresh-manual-test",
chatgptAccountId: "acct-manual-test",
...overrides,
Expand Down Expand Up @@ -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",
});
Expand All @@ -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();
Expand All @@ -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",
})),
});
Expand Down
Loading