Skip to content

Commit f62207d

Browse files
committed
fix(codex-auth): detect duplicate refresh grants
1 parent be45b0b commit f62207d

4 files changed

Lines changed: 26 additions & 6 deletions

File tree

devlog/_plan/260624_codex-multi-auth-security-implementation/40_phase40-manual-import-disable.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Required imports:
219219
Assertions:
220220

221221
- `checkAccountIdCollision("shared-team-account", "member-b@example.test")` returns no collision when an existing pool account has the same ChatGPT account id but a different email;
222-
- Correction 2026-06-27: `checkAccountIdCollision("shared-team-account", "MEMBER-A@example.test")` also returns no collision. ChatGPT account id plus email is not an authoritative duplicate key because one user can legitimately hold both personal and business subscriptions. Duplicate prevention remains scoped to local alias/credential lifecycle checks.
222+
- Correction 2026-06-27: `checkAccountIdCollision("shared-team-account", "MEMBER-A@example.test")` also returns no collision. ChatGPT account id plus email is not an authoritative duplicate key because one user can legitimately hold both personal and business subscriptions. Duplicate prevention remains scoped to local alias checks and exact OAuth refresh-grant fingerprint matches.
223223

224224
### MODIFY `src/codex-auth-api.ts` OAuth wording
225225

src/codex-auth-api.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,9 @@ export async function handleCodexAuthAPI(
214214
if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) {
215215
return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400);
216216
}
217-
// 1.1: JWT-derived account ID is authoritative; collision check
217+
// 1.1: Duplicate check uses local alias plus exact credential material, not account identity.
218218
const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
219-
const collision = checkAccountIdCollision(derivedAccountId, body.email);
219+
const collision = checkAccountIdCollision(derivedAccountId, body.email, body.refreshToken);
220220
if (collision.collision) {
221221
return jsonResponse({ error: collision.reason }, 400);
222222
}
@@ -414,7 +414,7 @@ export async function handleCodexAuthAPI(
414414
const { getCredential } = await import("./oauth/store");
415415
const cred = getCredential("chatgpt");
416416
if (cred) {
417-
// 1.2: account-ID-based collision check (JWT-derived, not email)
417+
// 1.2: Duplicate check uses local alias plus exact credential material, not account identity.
418418
const oauthAccountId = cred.accountId;
419419
if (!oauthAccountId) {
420420
codexAuthLoginState.set(flowId, {
@@ -425,7 +425,7 @@ export async function handleCodexAuthAPI(
425425
completed = true;
426426
break;
427427
}
428-
const collision = checkAccountIdCollision(oauthAccountId, cred.email);
428+
const collision = checkAccountIdCollision(oauthAccountId, cred.email, cred.refresh);
429429
if (collision.collision) {
430430
codexAuthLoginState.set(flowId, {
431431
status: "error", error: collision.reason, doneAt: Date.now(),

src/codex-auth-collision.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { join } from "node:path";
33
import os from "node:os";
4+
import { loadCodexAccountStore, refreshGrantFingerprintForToken } from "./codex-account-store";
45
import { extractAccountId } from "./oauth/chatgpt";
56

67
export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
@@ -28,9 +29,18 @@ export function getMainChatgptAccountId(): string | null {
2829

2930
// ChatGPT account ids and emails are not authoritative duplicate keys:
3031
// one user can legitimately hold both personal and business subscriptions.
32+
// Only the local alias and exact OAuth grant material are duplicate keys.
3133
export function checkAccountIdCollision(
3234
_chatgptAccountId: string,
3335
_email?: string | null,
36+
refreshToken?: string | null,
3437
): { collision: true; reason: string } | { collision: false } {
38+
if (!refreshToken) return { collision: false };
39+
const candidateFingerprint = refreshGrantFingerprintForToken(refreshToken);
40+
for (const [poolId, cred] of Object.entries(loadCodexAccountStore())) {
41+
if (refreshGrantFingerprintForToken(cred.refreshToken) === candidateFingerprint) {
42+
return { collision: true, reason: `Credential is already in the pool (${poolId}).` };
43+
}
44+
}
3545
return { collision: false };
3646
}

tests/codex-auth-collision.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,18 @@ describe("codex auth account collision", () => {
5757
test("allows the same email and account id because personal and business subscriptions can coexist", async () => {
5858
seedAccount("team-member-a", "member-a@example.test", "shared-team-account");
5959

60-
expect(checkAccountIdCollision("shared-team-account", "MEMBER-A@example.test")).toEqual({
60+
expect(checkAccountIdCollision("shared-team-account", "MEMBER-A@example.test", "refresh-personal-business")).toEqual({
6161
collision: false,
6262
});
6363
});
64+
65+
test("rejects the exact same refresh grant under a different alias", async () => {
66+
seedAccount("team-member-a", "member-a@example.test", "shared-team-account");
67+
68+
const result = checkAccountIdCollision("different-account-id", "other@example.test", "refresh-team-member-a");
69+
expect(result.collision).toBe(true);
70+
if (result.collision) {
71+
expect(result.reason).toContain("Credential is already in the pool");
72+
}
73+
});
6474
});

0 commit comments

Comments
 (0)