From 4ef77dadece5ce2590eb4b0b4402e76194764dcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 12:59:24 -0400 Subject: [PATCH] fix(google): normalize unsafe oauth expiry --- extensions/google/oauth.test.ts | 30 ++++++++++++++++++++++++++++++ extensions/google/oauth.token.ts | 16 +++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 4a0769a4167..c5f255cbec3 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -950,4 +950,34 @@ describe("loginGeminiCliOAuth", () => { expect(Number.isFinite(result.expires)).toBe(true); expect(result.expires).toBeLessThanOrEqual(beforeRefresh); }); + + it("keeps unsafe token expiry values out of refreshed Gemini CLI credentials", async () => { + mockSettingsExistsSync.mockReturnValue(true); + mockSettingsReadFileSync.mockReturnValue( + JSON.stringify({ + security: { + auth: { + selectedType: "oauth-personal", + }, + }, + }), + ); + + const beforeRefresh = Date.now(); + installGeminiOAuthFetchMock(() => undefined, { + tokenResponse: () => + responseJson({ + access_token: "access-token", + expires_in: Number.MAX_SAFE_INTEGER, + }), + }); + const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); + const result = await refreshTokensForGeminiCli({ + refresh: "refresh-token", + email: "lobster@openclaw.ai", + }); + + expect(Number.isSafeInteger(result.expires)).toBe(true); + expect(result.expires).toBeLessThanOrEqual(beforeRefresh); + }); }); diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts index 0266936dda7..1cdd23c3df7 100644 --- a/extensions/google/oauth.token.ts +++ b/extensions/google/oauth.token.ts @@ -1,9 +1,12 @@ +import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime"; import { resolveOAuthClientConfig } from "./oauth.credentials.js"; import { fetchWithTimeout } from "./oauth.http.js"; import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js"; import { isGeminiCliPersonalOAuth } from "./oauth.settings.js"; import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js"; +const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; + async function requestTokenGrant(body: URLSearchParams): Promise<{ access_token?: string; refresh_token?: string; @@ -31,11 +34,11 @@ async function requestTokenGrant(body: URLSearchParams): Promise<{ }; } -function resolveExpiresInMs(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return 0; - } - return Math.trunc(value * 1000); +function resolveTokenExpiresAt(value: unknown): number { + return ( + resolveExpiresAtMsFromDurationSeconds(value, { bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ?? + Date.now() - TOKEN_EXPIRY_BUFFER_MS + ); } async function buildGeminiCliCredentials(params: { @@ -70,8 +73,7 @@ async function buildGeminiCliCredentials(params: { // already-stored identity binding instead of failing token renewal. } - const expiresInMs = resolveExpiresInMs(params.tokenResponse.expires_in); - const expiresAt = Date.now() + expiresInMs - 5 * 60 * 1000; + const expiresAt = resolveTokenExpiresAt(params.tokenResponse.expires_in); return { refresh: params.tokenResponse.refresh_token ?? params.refreshTokenFallback ?? "",