Skip to content
Merged
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
9 changes: 6 additions & 3 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,15 +286,18 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
}

/**
* Stable short account id. MUST be deterministic for a given credential: legacy
* single-credential stores are re-normalized on EVERY load without being persisted,
* Stable collision-resistant account id. MUST be deterministic for a given credential:
* legacy single-credential stores are re-normalized on EVERY load without being persisted,
* so a time-salted id would differ between two loads (getAccountSet vs
* getAccountCredential), surfacing as a spurious OAuthLoginRequiredError and making
* refresh persists silently miss the account (rotated refresh token lost).
*
* Keep 128 bits of SHA-256 rather than the historical 32-bit prefix. Existing persisted
* account ids are read as-is; only newly-derived ids and legacy normalization use this width.
*/
function newAccountId(cred: OAuthCredentials): string {
const identity = cred.accountId ?? cred.email ?? cred.refresh;
return createHash("sha256").update(identity).digest("hex").slice(0, 8);
return createHash("sha256").update(identity).digest("hex").slice(0, 32);
}

function normalizeAccount(value: unknown): ProviderAccount | null {
Expand Down
100 changes: 100 additions & 0 deletions tests/oauth-account-id-collision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
resetHardenedStateForTests,
setIcaclsRunnerForTests,
} from "../src/lib/windows-secret-acl";
import {
getAccountSet,
getCredential,
saveCredential,
} from "../src/oauth/store";

const TEST_DIR = join(import.meta.dir, ".tmp-oauth-account-id-collision-test");
let previousOpencodexHome: string | undefined;

const COLLIDING_ACCOUNT_A = "account-collision-16138";
const COLLIDING_ACCOUNT_B = "account-collision-28806";

describe("OAuth account id collision hardening", () => {
beforeEach(() => {
previousOpencodexHome = process.env.OPENCODEX_HOME;
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
mkdirSync(TEST_DIR, { recursive: true });
process.env.OPENCODEX_HOME = TEST_DIR;
resetHardenedStateForTests();
setIcaclsRunnerForTests(() => ({
success: true,
exitCode: 0,
timedOut: false,
stdout: "",
}));
});

afterEach(() => {
setIcaclsRunnerForTests(null);
resetHardenedStateForTests();
if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousOpencodexHome;
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
});

test("distinct identities that collide on the historical 32-bit prefix get distinct slots", async () => {
// sha256(account-collision-16138) and sha256(account-collision-28806) both
// start with da1e26d2. The historical 8-hex id therefore aliased these
// two accounts and made active-account lookup return the wrong credential.
await saveCredential("anthropic", {
access: "access-a",
refresh: "refresh-a",
expires: Date.now() + 3600_000,
accountId: COLLIDING_ACCOUNT_A,
});
await saveCredential("anthropic", {
access: "access-b",
refresh: "refresh-b",
expires: Date.now() + 3600_000,
accountId: COLLIDING_ACCOUNT_B,
});

const set = getAccountSet("anthropic");
expect(set).not.toBeNull();
expect(set!.accounts).toHaveLength(2);
expect(new Set(set!.accounts.map(account => account.id)).size).toBe(2);
expect(set!.accounts.every(account => account.id.length === 32)).toBe(true);
expect(getCredential("anthropic")?.accountId).toBe(COLLIDING_ACCOUNT_B);
expect(getCredential("anthropic")?.access).toBe("access-b");
});

test("existing persisted 32-bit account ids remain valid and are not rewritten", async () => {
const authPath = join(TEST_DIR, "auth.json");
writeFileSync(authPath, JSON.stringify({
anthropic: {
activeAccountId: "deadbeef",
accounts: [{
id: "deadbeef",
credential: {
access: "old-access",
refresh: "old-refresh",
expires: Date.now() + 3600_000,
accountId: "existing-account",
},
}],
},
}));

expect(getAccountSet("anthropic")?.activeAccountId).toBe("deadbeef");

await saveCredential("anthropic", {
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 7200_000,
accountId: "existing-account",
});

const set = getAccountSet("anthropic");
expect(set?.activeAccountId).toBe("deadbeef");
expect(set?.accounts[0]?.id).toBe("deadbeef");
expect(getCredential("anthropic")?.access).toBe("rotated-access");
});
Comment on lines +69 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the legacy single-credential normalization path.

Lines 71-84 persist an account set. This bypasses normalizeAccountSet lines 327-331 in src/oauth/store.ts, where a raw legacy credential derives its ID on every load.

Add a test that persists a raw provider credential without accounts, calls getAccountSet("anthropic") twice, and asserts that both derived IDs are equal and 32 characters long. This verifies the deterministic legacy-normalization contract described by this PR.

As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”

🤖 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 `@tests/oauth-account-id-collision.test.ts` around lines 69 - 99, Add a focused
test near the existing account-ID collision tests that persists a raw Anthropic
credential without an accounts array, calls getAccountSet("anthropic") twice,
and asserts both derived account IDs are identical and exactly 32 characters
long. Cover the legacy normalization path in normalizeAccountSet while
preserving the existing test setup and cleanup conventions.

Source: Path instructions

});
Loading