Skip to content

Commit 63a7a2f

Browse files
committed
fix: harden business account identity regressions
1 parent 7b1d42c commit 63a7a2f

6 files changed

Lines changed: 313 additions & 11 deletions

File tree

lib/accounts.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export class AccountManager {
208208
const account = stored.accounts[index];
209209
if (
210210
typeof account?.refreshToken !== "string" ||
211-
!account.refreshToken
211+
!account.refreshToken.trim()
212212
) {
213213
continue;
214214
}
@@ -238,7 +238,10 @@ export class AccountManager {
238238
const baseNow = nowMs();
239239
this.accounts = stored.accounts
240240
.map((account, index): ManagedAccount | null => {
241-
if (!account.refreshToken || typeof account.refreshToken !== "string") {
241+
if (
242+
typeof account.refreshToken !== "string" ||
243+
!account.refreshToken.trim()
244+
) {
242245
return null;
243246
}
244247

lib/storage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2165,7 +2165,9 @@ async function saveFlaggedAccountsUnlocked(
21652165
await fs.mkdir(dirname(path), { recursive: true });
21662166
if (existsSync(path)) {
21672167
try {
2168-
await fs.copyFile(path, `${path}.bak`);
2168+
await copyFileWithRetry(path, `${path}.bak`, {
2169+
allowMissingSource: true,
2170+
});
21692171
} catch (backupError) {
21702172
log.warn("Failed to create flagged backup snapshot", {
21712173
path,
@@ -2175,7 +2177,7 @@ async function saveFlaggedAccountsUnlocked(
21752177
}
21762178
const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
21772179
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
2178-
await fs.rename(tempPath, path);
2180+
await renameFileWithRetry(tempPath, path);
21792181
try {
21802182
await fs.unlink(markerPath);
21812183
} catch {

test/accounts.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,12 @@ describe("AccountManager", () => {
12541254
addedAt: now,
12551255
lastUsed: now,
12561256
},
1257+
{
1258+
refreshToken: " ",
1259+
accountId: "matching-account-id",
1260+
addedAt: now,
1261+
lastUsed: now,
1262+
},
12571263
],
12581264
};
12591265

test/codex-manager-cli.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,9 +1893,16 @@ describe("codex manager cli commands", () => {
18931893
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
18941894
command: string;
18951895
summary: { ok: number; warn: number; error: number };
1896+
checks: Array<{ key: string; severity: string }>;
18961897
};
18971898
expect(payload.command).toBe("doctor");
18981899
expect(payload.summary.error).toBe(0);
1900+
expect(payload.checks).toContainEqual(
1901+
expect.objectContaining({
1902+
key: "duplicate-refresh-token",
1903+
severity: "ok",
1904+
}),
1905+
);
18991906
});
19001907

19011908
it("runs doctor --fix in dry-run mode", async () => {

test/index.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2039,6 +2039,121 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => {
20392039
).toEqual(["refresh-a", "refresh-b", "refresh-c"]);
20402040
});
20412041

2042+
it("serializes concurrent manual logins through the storage transaction queue", async () => {
2043+
const createDeferred = () => {
2044+
let resolve!: () => void;
2045+
const promise = new Promise<void>((res) => {
2046+
resolve = res;
2047+
});
2048+
return { promise, resolve };
2049+
};
2050+
2051+
mockStorage.accounts = [];
2052+
mockStorage.activeIndex = 0;
2053+
mockStorage.activeIndexByFamily = {};
2054+
2055+
const firstPersist = createDeferred();
2056+
saveAccountsMock
2057+
.mockImplementationOnce(async (storage) => {
2058+
await firstPersist.promise;
2059+
mockStorage.version = storage.version;
2060+
mockStorage.accounts = storage.accounts.map((account) => ({ ...account }));
2061+
mockStorage.activeIndex = storage.activeIndex;
2062+
mockStorage.activeIndexByFamily = {
2063+
...(storage.activeIndexByFamily ?? {}),
2064+
};
2065+
})
2066+
.mockImplementation(async (storage) => {
2067+
mockStorage.version = storage.version;
2068+
mockStorage.accounts = storage.accounts.map((account) => ({ ...account }));
2069+
mockStorage.activeIndex = storage.activeIndex;
2070+
mockStorage.activeIndexByFamily = {
2071+
...(storage.activeIndexByFamily ?? {}),
2072+
};
2073+
});
2074+
2075+
const authModule = await import("../lib/auth/auth.js");
2076+
const accountsModule = await import("../lib/accounts.js");
2077+
vi.mocked(authModule.createAuthorizationFlow)
2078+
.mockResolvedValueOnce({
2079+
pkce: { verifier: "persist-concurrent-verifier-1", challenge: "persist-concurrent-challenge-1" },
2080+
state: "persist-concurrent-state-1",
2081+
url: "https://auth.openai.com/test?state=persist-concurrent-state-1",
2082+
})
2083+
.mockResolvedValueOnce({
2084+
pkce: { verifier: "persist-concurrent-verifier-2", challenge: "persist-concurrent-challenge-2" },
2085+
state: "persist-concurrent-state-2",
2086+
url: "https://auth.openai.com/test?state=persist-concurrent-state-2",
2087+
});
2088+
vi.mocked(authModule.exchangeAuthorizationCode)
2089+
.mockResolvedValueOnce({
2090+
type: "success",
2091+
access: "access-token-1",
2092+
refresh: "refresh-1",
2093+
expires: Date.now() + 3600_000,
2094+
idToken: undefined,
2095+
})
2096+
.mockResolvedValueOnce({
2097+
type: "success",
2098+
access: "access-token-2",
2099+
refresh: "refresh-2",
2100+
expires: Date.now() + 3600_000,
2101+
idToken: undefined,
2102+
});
2103+
vi.mocked(accountsModule.extractAccountEmail)
2104+
.mockReturnValueOnce("alpha@example.com")
2105+
.mockReturnValueOnce("beta@example.com");
2106+
vi.mocked(accountsModule.extractAccountId)
2107+
.mockReturnValueOnce("workspace-alpha")
2108+
.mockReturnValueOnce("workspace-beta");
2109+
2110+
const mockClient = createMockClient();
2111+
const { OpenAIOAuthPlugin } = await import("../index.js");
2112+
const plugin =
2113+
(await OpenAIOAuthPlugin({
2114+
client: mockClient,
2115+
} as never)) as unknown as PluginType;
2116+
const manualMethod = plugin.auth.methods[1] as unknown as {
2117+
authorize: () => Promise<{
2118+
callback: (input: string) => Promise<{ type: string }>;
2119+
}>;
2120+
};
2121+
2122+
const flowOne = await manualMethod.authorize();
2123+
const flowTwo = await manualMethod.authorize();
2124+
const firstCallback = flowOne.callback(
2125+
"http://127.0.0.1:1455/auth/callback?code=alpha&state=persist-concurrent-state-1",
2126+
);
2127+
await new Promise((resolve) => setImmediate(resolve));
2128+
const secondCallback = flowTwo.callback(
2129+
"http://127.0.0.1:1455/auth/callback?code=beta&state=persist-concurrent-state-2",
2130+
);
2131+
await new Promise((resolve) => setImmediate(resolve));
2132+
2133+
expect(saveAccountsMock).toHaveBeenCalledTimes(1);
2134+
2135+
firstPersist.resolve();
2136+
2137+
const [firstResult, secondResult] = await Promise.all([
2138+
firstCallback,
2139+
secondCallback,
2140+
]);
2141+
2142+
expect(firstResult.type).toBe("success");
2143+
expect(secondResult.type).toBe("success");
2144+
expect(saveAccountsMock).toHaveBeenCalledTimes(2);
2145+
expect(mockStorage.accounts).toHaveLength(2);
2146+
expect(
2147+
mockStorage.accounts.map((account) => ({
2148+
email: account.email,
2149+
refreshToken: account.refreshToken,
2150+
})),
2151+
).toEqual([
2152+
{ email: "alpha@example.com", refreshToken: "refresh-1" },
2153+
{ email: "beta@example.com", refreshToken: "refresh-2" },
2154+
]);
2155+
});
2156+
20422157
it("updates a unique shared accountId entry when a login has no email claim", async () => {
20432158
process.env.CODEX_AUTH_ACCOUNT_ID = "shared-workspace";
20442159
mockStorage.accounts = [

0 commit comments

Comments
 (0)