From a26f4d0f3ef0757db6c6c40277cc06a5de76c52f Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:51:11 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + extensions/google/oauth.flow.ts | 16 ++++----- extensions/google/oauth.test.ts | 62 +++++++++++++++++++++++++++++++++ extensions/google/oauth.ts | 14 ++++---- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df034415137..689c24a8079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts index 39de9493c48..e5e3f91ad2e 100644 --- a/extensions/google/oauth.flow.ts +++ b/extensions/google/oauth.flow.ts @@ -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." }; } } diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 434342ae8e2..e4290a48ac1 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -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; @@ -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"; diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts index be12c64a4e1..ff959daae1f 100644 --- a/extensions/google/oauth.ts +++ b/extensions/google/oauth.ts @@ -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...");