mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:24:46 +00:00
fix(google): refresh Gemini CLI OAuth tokens
This commit is contained in:
committed by
Vincent Koc
parent
489cab2738
commit
b34454f5b3
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { loginGeminiCliOAuth } from "./oauth.js";
|
||||
export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js";
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user