fix(cli): key gemini cli auth epoch on google account identity (#71076)

Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
This commit is contained in:
Chunyue Wang
2026-04-25 20:47:58 +08:00
committed by GitHub
parent ab1d1a5c9e
commit bc73141e82
6 changed files with 278 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ describe("resolveCliAuthEpoch", () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readGeminiCliCredentialsCached: () => null,
loadAuthProfileStoreForRuntime: () => ({
version: 1,
profiles: {},
@@ -74,6 +75,70 @@ describe("resolveCliAuthEpoch", () => {
expect(second).not.toBe(first);
});
it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
let access = "gemini-access-a";
let refresh = "gemini-refresh-a";
let expires = 1;
let accountId: string | undefined = "google-account-1";
let email: string | undefined = "user-a@example.com";
setCliAuthEpochTestDeps({
readGeminiCliCredentialsCached: () => ({
type: "oauth",
provider: "google-gemini-cli",
access,
refresh,
expires,
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
}),
});
const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
access = "gemini-access-b";
refresh = "gemini-refresh-b";
expires = 2;
const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
expect(first).toBeDefined();
// Access and refresh rotation must not shift the epoch while the lifted
// Google-account identity is stable.
expect(second).toBe(first);
email = "user-b@example.com";
const third = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
expect(third).toBeDefined();
expect(third).not.toBe(second);
accountId = "google-account-2";
const fourth = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
expect(fourth).toBeDefined();
expect(fourth).not.toBe(third);
});
it("falls back to the identity-less oauth epoch when gemini id_token is absent", async () => {
let refresh = "gemini-refresh-a";
setCliAuthEpochTestDeps({
readGeminiCliCredentialsCached: () => ({
type: "oauth",
provider: "google-gemini-cli",
access: "gemini-access",
refresh,
expires: 1,
}),
});
const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
refresh = "gemini-refresh-b";
const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
expect(first).toBeDefined();
// Without lifted identity, the epoch is a provider-keyed constant that
// survives token rotation — same fallback as the Claude CLI OAuth branch.
expect(second).toBe(first);
});
it("keeps oauth auth-profile epochs stable across token refreshes", async () => {
let store: AuthProfileStore = {
version: 1,
@@ -89,6 +154,7 @@ describe("resolveCliAuthEpoch", () => {
},
};
setCliAuthEpochTestDeps({
readGeminiCliCredentialsCached: () => null,
loadAuthProfileStoreForRuntime: () => store,
});
@@ -133,6 +199,7 @@ describe("resolveCliAuthEpoch", () => {
},
};
setCliAuthEpochTestDeps({
readGeminiCliCredentialsCached: () => null,
loadAuthProfileStoreForRuntime: () => store,
});

View File

@@ -5,25 +5,29 @@ import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/ty
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
type ClaudeCliCredential,
type CodexCliCredential,
type GeminiCliCredential,
} from "./cli-credentials.js";
type CliAuthEpochDeps = {
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached;
loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime;
};
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
loadAuthProfileStoreForRuntime,
};
const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps };
export const CLI_AUTH_EPOCH_VERSION = 3;
export const CLI_AUTH_EPOCH_VERSION = 4;
export function setCliAuthEpochTestDeps(overrides: Partial<CliAuthEpochDeps>): void {
Object.assign(cliAuthEpochDeps, overrides);
@@ -72,6 +76,17 @@ function encodeCodexCredential(credential: CodexCliCredential): string {
return encodeOAuthIdentity(credential);
}
function encodeGeminiCredential(credential: GeminiCliCredential): string {
// Delegate to the shared OAuth-identity encoder. The Gemini CLI reader
// lifts the Google-account identity (sub, email) off the openid id_token
// onto the credential, so the encoder fingerprints the user through stable,
// non-secret identity fields — matching the Claude/Codex OAuth contract.
// When the id_token is absent (older logins, scope omitted), the encoder
// falls back to a provider-keyed constant, the same identity-less behavior
// the Claude CLI OAuth branch tolerates.
return encodeOAuthIdentity(credential);
}
function encodeAuthProfileCredential(credential: AuthProfileCredential): string {
switch (credential.type) {
case "api_key":
@@ -114,6 +129,12 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined
});
return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined;
}
case "google-gemini-cli": {
const credential = cliAuthEpochDeps.readGeminiCliCredentialsCached({
ttlMs: 5000,
});
return credential ? hashCliAuthEpochPart(encodeGeminiCredential(credential)) : undefined;
}
default:
return undefined;
}

View File

@@ -12,6 +12,7 @@ let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").reset
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
let readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
function mockExistingClaudeKeychainItem() {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
@@ -74,6 +75,7 @@ describe("cli credentials", () => {
writeClaudeCliKeychainCredentials,
writeClaudeCliCredentials,
readCodexCliCredentials,
readGeminiCliCredentialsCached,
} = await import("./cli-credentials.js"));
});
@@ -362,4 +364,69 @@ describe("cli credentials", () => {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it("lifts Google account identity from the Gemini id_token", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-"));
try {
const credPath = path.join(tempHome, ".gemini", "oauth_creds.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
const idTokenPayload = Buffer.from(
JSON.stringify({ sub: "google-account-42", email: "user@example.com" }),
).toString("base64url");
const idToken = `header.${idTokenPayload}.signature`;
fs.writeFileSync(
credPath,
JSON.stringify({
access_token: "gemini-access",
refresh_token: "gemini-refresh",
id_token: idToken,
expiry_date: Date.parse("2026-04-25T12:00:00Z"),
}),
"utf8",
);
const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 });
expect(creds).toMatchObject({
type: "oauth",
provider: "google-gemini-cli",
access: "gemini-access",
refresh: "gemini-refresh",
accountId: "google-account-42",
email: "user@example.com",
});
} finally {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it("reads Gemini credentials without identity fields when id_token is absent", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-noid-"));
try {
const credPath = path.join(tempHome, ".gemini", "oauth_creds.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
credPath,
JSON.stringify({
access_token: "gemini-access",
refresh_token: "gemini-refresh",
expiry_date: Date.parse("2026-04-25T12:00:00Z"),
}),
"utf8",
);
const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 });
expect(creds).toMatchObject({
type: "oauth",
provider: "google-gemini-cli",
access: "gemini-access",
refresh: "gemini-refresh",
});
expect(creds?.accountId).toBeUndefined();
expect(creds?.email).toBeUndefined();
} finally {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
});

View File

@@ -13,6 +13,7 @@ const log = createSubsystemLogger("agents/auth-profiles");
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
const GEMINI_CLI_CREDENTIALS_RELATIVE_PATH = ".gemini/oauth_creds.json";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
@@ -27,11 +28,13 @@ type CachedValue<T> = {
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
let geminiCliCache: CachedValue<GeminiCliCredential> | null = null;
export function resetCliCredentialCachesForTest(): void {
claudeCliCache = null;
codexCliCache = null;
minimaxCliCache = null;
geminiCliCache = null;
}
export type ClaudeCliCredential =
@@ -67,6 +70,16 @@ export type MiniMaxCliCredential = {
expires: number;
};
export type GeminiCliCredential = {
type: "oauth";
provider: "google-gemini-cli";
access: string;
refresh: string;
expires: number;
accountId?: string;
email?: string;
};
type ClaudeCliFileOptions = {
homeDir?: string;
};
@@ -131,6 +144,11 @@ function resolveMiniMaxCliCredentialsPath(homeDir?: string) {
return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH);
}
function resolveGeminiCliCredentialsPath(homeDir?: string) {
const baseDir = homeDir ?? resolveUserPath("~");
return path.join(baseDir, GEMINI_CLI_CREDENTIALS_RELATIVE_PATH);
}
function readFileMtimeMs(filePath: string): number | null {
try {
return fs.statSync(filePath).mtimeMs;
@@ -211,6 +229,22 @@ function decodeJwtExpiryMs(token: string): number | null {
}
}
function decodeJwtIdentityClaims(token: string): { sub?: string; email?: string } {
const parts = token.split(".");
if (parts.length < 2) {
return {};
}
try {
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
const payload = JSON.parse(payloadRaw) as { sub?: unknown; email?: unknown };
const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : undefined;
const email = typeof payload.email === "string" && payload.email ? payload.email : undefined;
return { sub, email };
} catch {
return {};
}
}
function readCodexKeychainAuthRecord(options?: {
codexHome?: string;
platform?: NodeJS.Platform;
@@ -328,6 +362,49 @@ function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCr
return readPortalCliOauthCredentials(credPath, "minimax-portal");
}
function readGeminiCliCredentials(options?: { homeDir?: string }): GeminiCliCredential | null {
const credPath = resolveGeminiCliCredentialsPath(options?.homeDir);
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") {
return null;
}
const data = raw as Record<string, unknown>;
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
const expiresAt = data.expiry_date;
if (typeof accessToken !== "string" || !accessToken) {
return null;
}
if (typeof refreshToken !== "string" || !refreshToken) {
return null;
}
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
return null;
}
// Gemini CLI's login flow stores the openid id_token alongside the OAuth
// tokens. Decode it once here to lift the Google account identity (sub,
// email) onto the credential so the shared OAuth-identity encoder can key
// the auth epoch on stable, non-secret identity material — matching the
// Claude/Codex contract that #70132 codifies. Without this lift the encoder
// collapses to a provider-keyed constant and stale bindings can survive a
// re-login under a different Google account.
const idTokenRaw = data.id_token;
const identity =
typeof idTokenRaw === "string" && idTokenRaw ? decodeJwtIdentityClaims(idTokenRaw) : {};
return {
type: "oauth",
provider: "google-gemini-cli",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
...(identity.email ? { email: identity.email } : {}),
...(identity.sub ? { accountId: identity.sub } : {}),
};
}
function readClaudeCliKeychainCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
@@ -609,3 +686,20 @@ export function readMiniMaxCliCredentialsCached(options?: {
readSourceFingerprint: () => readFileMtimeMs(credPath),
});
}
export function readGeminiCliCredentialsCached(options?: {
ttlMs?: number;
homeDir?: string;
}): GeminiCliCredential | null {
const credPath = resolveGeminiCliCredentialsPath(options?.homeDir);
return readCachedCliCredential({
ttlMs: options?.ttlMs ?? 0,
cache: geminiCliCache,
cacheKey: credPath,
read: () => readGeminiCliCredentials({ homeDir: options?.homeDir }),
setCache: (next) => {
geminiCliCache = next;
},
readSourceFingerprint: () => readFileMtimeMs(credPath),
});
}

View File

@@ -182,6 +182,33 @@ describe("cli-session helpers", () => {
).toEqual({ sessionId: "cli-session-1" });
});
it("accepts v3 bindings without authEpoch as binding upgrades to v4", () => {
// Pre-v4 google-gemini-cli sessions persisted with authEpochVersion: 3
// and no authEpoch (the local credential fingerprint returned undefined
// before id_token identity lifting). The version-gate must skip the
// epoch comparison for these so the next request after upgrade reuses
// the stored session instead of forcing a one-time invalidation.
const binding = {
sessionId: "cli-session-1",
authProfileId: undefined,
// authEpoch deliberately absent
authEpochVersion: 3,
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
};
expect(
resolveCliSessionReuse({
binding,
authProfileId: undefined,
authEpoch: "v4-identity-hash",
authEpochVersion: 4,
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",