mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 17:21:13 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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...");
|
||||
|
||||
Reference in New Issue
Block a user