fix(cli): key oauth session epochs on identity

This commit is contained in:
Ayaan Zaidi
2026-04-23 08:47:06 +05:30
parent c866820fed
commit 3eb6edc67c
3 changed files with 96 additions and 40 deletions

View File

@@ -30,21 +30,23 @@ describe("resolveCliAuthEpoch", () => {
).resolves.toBeUndefined();
});
it("keeps claude cli oauth epochs stable across access-token refreshes", async () => {
it("keeps identity-less claude cli oauth epochs stable across token changes", async () => {
let access = "access-a";
let refresh = "refresh-a";
let expires = 1;
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "anthropic",
access,
refresh: "refresh",
refresh,
expires,
}),
});
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
access = "access-b";
refresh = "refresh-b";
expires = 2;
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
@@ -52,20 +54,19 @@ describe("resolveCliAuthEpoch", () => {
expect(second).toBe(first);
});
it("changes claude cli oauth epochs when the refresh token changes", async () => {
let refresh = "refresh-a";
it("changes claude cli token epochs when the static token changes", async () => {
let token = "token-a";
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
type: "token",
provider: "anthropic",
access: "access",
refresh,
token,
expires: 1,
}),
});
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
refresh = "refresh-b";
token = "token-b";
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
expect(first).toBeDefined();
@@ -73,7 +74,7 @@ describe("resolveCliAuthEpoch", () => {
expect(second).not.toBe(first);
});
it("keeps oauth auth-profile epochs stable across access-token refreshes", async () => {
it("keeps oauth auth-profile epochs stable across token refreshes", async () => {
let store: AuthProfileStore = {
version: 1,
profiles: {
@@ -81,8 +82,9 @@ describe("resolveCliAuthEpoch", () => {
type: "oauth",
provider: "anthropic",
access: "access-a",
refresh: "refresh",
refresh: "refresh-a",
expires: 1,
email: "user@example.com",
},
},
};
@@ -101,8 +103,9 @@ describe("resolveCliAuthEpoch", () => {
type: "oauth",
provider: "anthropic",
access: "access-b",
refresh: "refresh",
refresh: "refresh-b",
expires: 2,
email: "user@example.com",
},
},
};
@@ -115,7 +118,7 @@ describe("resolveCliAuthEpoch", () => {
expect(second).toBe(first);
});
it("changes oauth auth-profile epochs when the refresh token changes", async () => {
it("changes oauth auth-profile epochs when the account identity changes", async () => {
let store: AuthProfileStore = {
version: 1,
profiles: {
@@ -123,8 +126,9 @@ describe("resolveCliAuthEpoch", () => {
type: "oauth",
provider: "anthropic",
access: "access",
refresh: "refresh-a",
refresh: "refresh",
expires: 1,
email: "user-a@example.com",
},
},
};
@@ -143,8 +147,9 @@ describe("resolveCliAuthEpoch", () => {
type: "oauth",
provider: "anthropic",
access: "access",
refresh: "refresh-b",
refresh: "refresh",
expires: 1,
email: "user-b@example.com",
},
},
};
@@ -162,6 +167,8 @@ describe("resolveCliAuthEpoch", () => {
let access = "local-access-a";
let localRefresh = "local-refresh-a";
let refresh = "profile-refresh-a";
let accountId = "acct-1";
let email = "user-a@example.com";
setCliAuthEpochTestDeps({
readCodexCliCredentialsCached: () => ({
type: "oauth",
@@ -169,7 +176,7 @@ describe("resolveCliAuthEpoch", () => {
access,
refresh: localRefresh,
expires: 1,
accountId: "acct-1",
accountId,
}),
loadAuthProfileStoreForRuntime: () => ({
version: 1,
@@ -180,6 +187,7 @@ describe("resolveCliAuthEpoch", () => {
access: "profile-access",
refresh,
expires: 1,
email,
},
},
}),
@@ -204,18 +212,31 @@ describe("resolveCliAuthEpoch", () => {
provider: "codex-cli",
authProfileId: "openai:work",
});
accountId = "acct-2";
const fifth = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai:work",
});
email = "user-b@example.com";
const sixth = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai:work",
});
expect(first).toBeDefined();
expect(third).toBeDefined();
expect(fourth).toBeDefined();
expect(second).toBe(first);
expect(third).not.toBe(second);
expect(fourth).not.toBe(third);
expect(third).toBe(second);
expect(fourth).toBe(third);
expect(fifth).toBeDefined();
expect(sixth).toBeDefined();
expect(fifth).not.toBe(fourth);
expect(sixth).not.toBe(fifth);
});
it("can ignore local codex state when the backend is profile-owned", async () => {
let localAccess = "local-access-a";
let profileRefresh = "profile-refresh-a";
let profileAccountId = "acct-1";
setCliAuthEpochTestDeps({
readCodexCliCredentialsCached: () => ({
type: "oauth",
@@ -234,7 +255,7 @@ describe("resolveCliAuthEpoch", () => {
access: "profile-access",
refresh: profileRefresh,
expires: 1,
accountId: "acct-1",
accountId: profileAccountId,
},
},
}),
@@ -257,10 +278,17 @@ describe("resolveCliAuthEpoch", () => {
authProfileId: "openai-codex:default",
skipLocalCredential: true,
});
profileAccountId = "acct-2";
const fourth = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
skipLocalCredential: true,
});
expect(first).toBeDefined();
expect(second).toBe(first);
expect(third).toBeDefined();
expect(third).not.toBe(second);
expect(third).toBe(second);
expect(fourth).toBeDefined();
expect(fourth).not.toBe(third);
});
});

View File

@@ -23,7 +23,7 @@ const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps };
export const CLI_AUTH_EPOCH_VERSION = 2;
export const CLI_AUTH_EPOCH_VERSION = 3;
export function setCliAuthEpochTestDeps(overrides: Partial<CliAuthEpochDeps>): void {
Object.assign(cliAuthEpochDeps, overrides);
@@ -41,20 +41,35 @@ function encodeUnknown(value: unknown): string {
return JSON.stringify(value ?? null);
}
function encodeOAuthIdentity(credential: {
type: "oauth";
provider: string;
clientId?: string;
email?: string;
enterpriseUrl?: string;
projectId?: string;
accountId?: string;
}): string {
return JSON.stringify([
"oauth",
credential.provider,
credential.clientId ?? null,
credential.email ?? null,
credential.enterpriseUrl ?? null,
credential.projectId ?? null,
credential.accountId ?? null,
]);
}
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
if (credential.type === "oauth") {
return JSON.stringify(["oauth", credential.provider, credential.refresh]);
return encodeOAuthIdentity(credential);
}
return JSON.stringify(["token", credential.provider, credential.token]);
}
function encodeCodexCredential(credential: CodexCliCredential): string {
return JSON.stringify([
credential.type,
credential.provider,
credential.refresh,
credential.accountId ?? null,
]);
return encodeOAuthIdentity(credential);
}
function encodeAuthProfileCredential(credential: AuthProfileCredential): string {
@@ -79,16 +94,7 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string
credential.displayName ?? null,
]);
case "oauth":
return JSON.stringify([
"oauth",
credential.provider,
credential.refresh,
credential.clientId ?? null,
credential.email ?? null,
credential.enterpriseUrl ?? null,
credential.projectId ?? null,
credential.accountId ?? null,
]);
return encodeOAuthIdentity(credential);
}
throw new Error("Unsupported auth profile credential type");
}

View File

@@ -160,6 +160,28 @@ describe("cli-session helpers", () => {
).toEqual({ sessionId: "cli-session-1" });
});
it("accepts older auth epoch versions for binding upgrades", () => {
const binding = {
sessionId: "cli-session-1",
authProfileId: "anthropic:work",
authEpoch: "refresh-token-auth-epoch",
authEpochVersion: 2,
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
};
expect(
resolveCliSessionReuse({
binding,
authProfileId: "anthropic:work",
authEpoch: "identity-auth-epoch",
authEpochVersion: 3,
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
}),
).toEqual({ sessionId: "cli-session-1" });
});
it("does not treat model changes as a session mismatch", () => {
const binding = {
sessionId: "cli-session-1",