Separate Gemini OAuth state from PKCE verifier (#59116)

* fix(google): separate oauth state from pkce verifier

* fix(google): drop unused oauth callback state arg

* docs(changelog): add #59116 google oauth state fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-02 09:51:11 -07:00
committed by GitHub
parent 367969759c
commit a26f4d0f3e
4 changed files with 79 additions and 14 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
## 2026.4.2-beta.1

View File

@@ -14,7 +14,11 @@ export function generatePkce(): { verifier: string; challenge: string } {
return { verifier, challenge };
}
export function buildAuthUrl(challenge: string, verifier: string): string {
export function generateOAuthState(): string {
return randomBytes(32).toString("hex");
}
export function buildAuthUrl(challenge: string, state: string): string {
const { clientId } = resolveOAuthClientConfig();
const params = new URLSearchParams({
client_id: clientId,
@@ -23,7 +27,7 @@ export function buildAuthUrl(challenge: string, verifier: string): string {
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
state,
access_type: "offline",
prompt: "consent",
});
@@ -32,7 +36,6 @@ export function buildAuthUrl(challenge: string, verifier: string): string {
export function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) {
@@ -42,7 +45,7 @@ export function parseCallbackInput(
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
const state = url.searchParams.get("state");
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
@@ -51,10 +54,7 @@ export function parseCallbackInput(
}
return { code, state };
} catch {
if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState };
return { error: "Paste the full redirect URL, not just the code." };
}
}

View File

@@ -279,6 +279,13 @@ describe("loginGeminiCliOAuth", () => {
});
}
function getFormField(body: RequestInit["body"], name: string): string | null {
if (!(body instanceof URLSearchParams)) {
throw new Error("Expected URLSearchParams body");
}
return body.get(name);
}
type LoginGeminiCliOAuthFn = (options: {
isRemote: boolean;
openUrl: () => Promise<void>;
@@ -399,6 +406,61 @@ describe("loginGeminiCliOAuth", () => {
});
});
it("keeps OAuth state separate from the PKCE verifier during manual login", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = getRequestUrl(input);
requests.push({ url, init });
if (url === TOKEN_URL) {
return responseJson({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
});
}
if (url === USERINFO_URL) {
return responseJson({ email: "lobster@openclaw.ai" });
}
if (url === LOAD_PROD) {
return responseJson({
currentTier: { id: "standard-tier" },
cloudaicompanionProject: { id: "prod-project" },
});
}
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { loginGeminiCliOAuth } = await import("./oauth.js");
const { authUrl } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
const authState = new URL(authUrl).searchParams.get("state");
expect(authState).toBeTruthy();
const tokenRequest = requests.find((request) => request.url === TOKEN_URL);
expect(tokenRequest).toBeDefined();
const codeVerifier = getFormField(tokenRequest?.init?.body, "code_verifier");
expect(codeVerifier).toBeTruthy();
expect(codeVerifier).not.toBe(authState);
});
it("rejects manual callback input when the returned state does not match", async () => {
const { loginGeminiCliOAuth } = await import("./oauth.js");
await expect(
loginGeminiCliOAuth({
isRemote: true,
openUrl: async () => {},
log: () => {},
note: async () => {},
prompt: async () =>
"http://localhost:8085/oauth2callback?code=oauth-code&state=wrong-state",
progress: { update: () => {}, stop: () => {} },
}),
).rejects.toThrow("OAuth state mismatch - please try again");
});
it("falls back to GOOGLE_CLOUD_PROJECT when all loadCodeAssist endpoints fail", async () => {
process.env.GOOGLE_CLOUD_PROJECT = "env-project";

View File

@@ -1,6 +1,7 @@
import { clearCredentialsCache, extractGeminiCliCredentials } from "./oauth.credentials.js";
import {
buildAuthUrl,
generateOAuthState,
generatePkce,
parseCallbackInput,
shouldUseManualOAuthFlow,
@@ -32,18 +33,19 @@ export async function loginGeminiCliOAuth(
);
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(challenge, verifier);
const state = generateOAuthState();
const authUrl = buildAuthUrl(challenge, state);
if (needsManual) {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
const parsed = parseCallbackInput(callbackInput);
if ("error" in parsed) {
throw new Error(parsed.error);
}
if (parsed.state !== verifier) {
if (parsed.state !== state) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
@@ -59,7 +61,7 @@ export async function loginGeminiCliOAuth(
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
expectedState: state,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
@@ -75,11 +77,11 @@ export async function loginGeminiCliOAuth(
ctx.progress.update("Local callback server failed. Switching to manual mode...");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
const parsed = parseCallbackInput(callbackInput);
if ("error" in parsed) {
throw new Error(parsed.error, { cause: err });
}
if (parsed.state !== verifier) {
if (parsed.state !== state) {
throw new Error("OAuth state mismatch - please try again", { cause: err });
}
ctx.progress.update("Exchanging authorization code for tokens...");