fix(google): refresh Gemini CLI OAuth tokens

This commit is contained in:
Jason O'Neal
2026-05-05 15:14:46 -04:00
committed by Vincent Koc
parent 489cab2738
commit b34454f5b3
7 changed files with 189 additions and 39 deletions

View File

@@ -122,6 +122,10 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
refreshOAuth: async (cred) => {
const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js");
return await refreshGeminiCliOAuthToken(cred);
},
resolveUsageAuth: async (ctx) => {
const auth = await ctx.resolveOAuthToken();
if (!auth) {

View File

@@ -10,7 +10,7 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts";
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import googlePlugin from "./index.js";
import { registerGoogleProvider } from "./provider-registration.js";
@@ -22,6 +22,12 @@ const googleProviderPlugin = {
},
};
const refreshGeminiCliOAuthTokenMock = vi.hoisted(() => vi.fn());
vi.mock("./oauth.runtime.js", () => ({
refreshGeminiCliOAuthToken: refreshGeminiCliOAuthTokenMock,
}));
describe("google provider plugin hooks", () => {
it("owns replay policy and reasoning mode for the direct Gemini provider", async () => {
const { providers } = await registerProviderPlugin({
@@ -265,4 +271,40 @@ describe("google provider plugin hooks", () => {
expect(bridge.setMediaTimestamp(20)).toBeUndefined();
expect(bridge.sendUserMessage?.("hello")).toBeUndefined();
});
it("refreshes Gemini CLI OAuth through the provider-owned refresh hook", async () => {
refreshGeminiCliOAuthTokenMock.mockResolvedValueOnce({
type: "oauth",
provider: "google-gemini-cli",
access: "fresh-access",
refresh: "fresh-refresh",
expires: Date.now() + 60_000,
email: "user@example.com",
projectId: "project-1",
});
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-gemini-cli");
const credential = {
type: "oauth" as const,
provider: "google-gemini-cli",
access: "stale-access",
refresh: "stale-refresh",
expires: Date.now() - 60_000,
email: "user@example.com",
projectId: "project-1",
};
await expect(provider.refreshOAuth?.(credential)).resolves.toMatchObject({
access: "fresh-access",
refresh: "fresh-refresh",
email: "user@example.com",
projectId: "project-1",
});
expect(refreshGeminiCliOAuthTokenMock).toHaveBeenCalledWith(credential);
});
});

View File

@@ -1 +1 @@
export { loginGeminiCliOAuth } from "./oauth.js";
export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js";

View File

@@ -876,4 +876,29 @@ describe("loginGeminiCliOAuth", () => {
expect(result.projectId).toBeUndefined();
expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]);
});
it("refreshes Gemini CLI OAuth tokens without loadCodeAssist in personal OAuth mode", async () => {
mockSettingsExistsSync.mockReturnValue(true);
mockSettingsReadFileSync.mockReturnValue(
JSON.stringify({
security: {
auth: {
selectedType: "oauth-personal",
},
},
}),
);
const { requests } = installGeminiOAuthFetchMock(() => undefined);
const { refreshTokensForGeminiCli } = await import("./oauth.token.js");
const result = await refreshTokensForGeminiCli({ refresh: "refresh-token" });
expect(result).toMatchObject({
access: "access-token",
refresh: "refresh-token",
email: "lobster@openclaw.ai",
projectId: undefined,
});
expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]);
});
});

View File

@@ -4,6 +4,84 @@ import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "
import { isGeminiCliPersonalOAuth } from "./oauth.settings.js";
import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js";
async function requestTokenGrant(body: URLSearchParams): Promise<{
access_token?: string;
refresh_token?: string;
expires_in?: number;
}> {
const response = await fetchWithTimeout(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "*/*",
"User-Agent": "google-api-nodejs-client/9.15.1",
},
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
return (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
}
async function buildGeminiCliCredentials(params: {
tokenResponse: {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
refreshTokenFallback?: string;
existing?: Pick<GeminiCliOAuthCredentials, "email" | "projectId">;
}): Promise<GeminiCliOAuthCredentials> {
const accessToken = params.tokenResponse.access_token;
if (!accessToken) {
throw new Error("No access token received. Please try again.");
}
let identity: { email?: string; projectId?: string } = params.existing ?? {};
try {
if (!identity.email || !identity.projectId) {
const discovered = await resolveGeminiCliIdentity(accessToken);
identity = {
email: identity.email ?? discovered.email,
projectId: identity.projectId ?? discovered.projectId,
};
}
} catch {
// If identity discovery is temporarily unavailable during refresh, keep the
// already-stored identity binding instead of failing token renewal.
}
const expiresInMs =
typeof params.tokenResponse.expires_in === "number"
? params.tokenResponse.expires_in * 1000
: 0;
const expiresAt = Date.now() + expiresInMs - 5 * 60 * 1000;
return {
refresh: params.tokenResponse.refresh_token ?? params.refreshTokenFallback ?? "",
access: accessToken,
expires: expiresAt,
projectId: identity.projectId,
email: identity.email,
};
}
async function resolveGeminiCliIdentity(
accessToken: string,
): Promise<{ email?: string; projectId?: string }> {
return isGeminiCliPersonalOAuth()
? await resolveGooglePersonalOAuthIdentity(accessToken)
: await resolveGoogleOAuthIdentity(accessToken);
}
export async function exchangeCodeForTokens(
code: string,
verifier: string,
@@ -20,41 +98,36 @@ export async function exchangeCodeForTokens(
body.set("client_secret", clientSecret);
}
const response = await fetchWithTimeout(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "*/*",
"User-Agent": "google-api-nodejs-client/9.15.1",
},
body,
const refreshed = await buildGeminiCliCredentials({
tokenResponse: await requestTokenGrant(body),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
if (!refreshed.refresh) {
throw new Error("No refresh token received. Please try again.");
}
const identity = isGeminiCliPersonalOAuth()
? await resolveGooglePersonalOAuthIdentity(data.access_token)
: await resolveGoogleOAuthIdentity(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId: identity.projectId,
email: identity.email,
};
return refreshed;
}
export async function refreshTokensForGeminiCli(credentials: {
refresh: string;
email?: string;
projectId?: string;
}): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
grant_type: "refresh_token",
refresh_token: credentials.refresh,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
return await buildGeminiCliCredentials({
tokenResponse: await requestTokenGrant(body),
refreshTokenFallback: credentials.refresh,
existing: {
email: credentials.email,
projectId: credentials.projectId,
},
});
}

View File

@@ -8,7 +8,7 @@ import {
waitForLocalCallback,
} from "./oauth.flow.js";
import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js";
import { exchangeCodeForTokens } from "./oauth.token.js";
import { exchangeCodeForTokens, refreshTokensForGeminiCli } from "./oauth.token.js";
export { clearCredentialsCache, extractGeminiCliCredentials };
export type { GeminiCliOAuthContext, GeminiCliOAuthCredentials };
@@ -90,3 +90,9 @@ async function manualFlow(
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
export async function refreshGeminiCliOAuthToken(
credentials: Pick<GeminiCliOAuthCredentials, "refresh" | "email" | "projectId">,
): Promise<GeminiCliOAuthCredentials> {
return await refreshTokensForGeminiCli(credentials);
}

View File

@@ -725,7 +725,7 @@ export async function resolveApiKeyForProvider(params: {
return result;
}
} catch (err) {
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
log.warn(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
}
}
@@ -923,7 +923,7 @@ export async function hasAvailableAuthForProvider(params: {
return true;
}
} catch (err) {
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
log.warn(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
}
}
return false;