diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 893496846d..65b9bf5806 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -45,6 +45,7 @@ import { refreshKimiCodingToken } from "./tokenRefresh/providers/kimiCoding.ts"; import { refreshGitLabDuoToken } from "./tokenRefresh/providers/gitlabDuo.ts"; import { refreshClaudeOAuthToken } from "./tokenRefresh/providers/claudeOAuth.ts"; import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; +import { selectGoogleRefreshClient } from "./tokenRefresh/googleClientBinding.ts"; import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; @@ -332,10 +333,19 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "gemini": case "antigravity": case "agy": { + // Google binds each refresh token to the client that issued it. When + // the operator overrides the client via env, connections authorized by + // the built-in desktop client must not be refreshed against the custom + // one (401 unauthorized_client, 2026-08-30 incident). + const refreshClient = selectGoogleRefreshClient( + provider, + credentials.providerSpecificData?.oauthClient, + PROVIDERS[provider] + ); const result = await refreshGoogleToken( credentials.refreshToken, - PROVIDERS[provider].clientId, - PROVIDERS[provider].clientSecret, + refreshClient.clientId, + refreshClient.clientSecret, log, proxyConfig ); diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts new file mode 100644 index 0000000000..628f4655ef --- /dev/null +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -0,0 +1,78 @@ +import { resolvePublicCred } from "../../utils/publicCreds.ts"; + +/** + * Built-in (env-override-free) Google client credentials per provider. + * + * `PROVIDERS[x].clientId` resolves env overrides first + * (ANTIGRAVITY_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_ID), so once an operator + * configures a custom OAuth client the resolved value can no longer see the + * embedded client that issued the refresh tokens of every pre-existing + * connection. Those connections must keep refreshing against the built-in + * client of THEIR provider: Google binds a refresh token to the client that + * issued it and answers any other client with 401 unauthorized_client. + * gemini and antigravity embed DIFFERENT desktop clients, so the fallback is + * keyed by provider, not global. + */ +export const BUILTIN_ANTIGRAVITY_CLIENT = { + clientId: resolvePublicCred("antigravity_id"), + clientSecret: resolvePublicCred("antigravity_alt"), +} as const; + +export const BUILTIN_GEMINI_CLIENT = { + clientId: resolvePublicCred("gemini_id"), + clientSecret: resolvePublicCred("gemini_alt"), +} as const; + +/** Marker recorded at authorize time; the literal id guards client rotation. */ +export type GoogleOauthClientMarker = "builtin" | `custom:${string}` | undefined; + +function builtinClientFor(provider: string) { + if (provider === "gemini") return BUILTIN_GEMINI_CLIENT; + if (provider === "antigravity" || provider === "agy") return BUILTIN_ANTIGRAVITY_CLIENT; + // Unknown Google-family provider: no embedded client exists to fall back + // to. The antigravity client would mint Google 401s for tokens it never + // issued, so refuse loudly instead of guessing. + throw new Error(`no builtin OAuth client registered for provider: ${provider}`); +} + +/** + * Pick the OAuth client credentials a Google refresh must use. + * + * The marker lives in the connection's providerSpecificData.oauthClient: + * - a string starting with "custom:" records the LITERAL client id that + * issued the token. The refresh compares it against the currently + * configured client: matching means the operator's custom client is + * unchanged and can refresh; a mismatch (the operator swapped to a + * different custom client after authorization) or a missing/malformed + * marker means the connection predates per-connection binding or lost + * its issuer, so the embedded desktop client of the connection's own + * provider is the only client that can still own that token. + * - "builtin": authorized with the embedded desktop client. + * + * Storing the literal id (not just a boolean) matters because Google binds + * each refresh token to the exact issuing client; "custom" alone would + * silently move old connections onto a *different* custom client when the + * operator rotates credentials. + * + * When no custom credentials are configured, both branches resolve to the + * same built-in client and the choice is moot. + */ +export function selectGoogleRefreshClient( + provider: string, + oauthClientMarker: GoogleOauthClientMarker, + configuredClient: { clientId?: string; clientSecret?: string } | null | undefined +): { clientId: string; clientSecret: string } { + if ( + typeof oauthClientMarker === "string" && + oauthClientMarker.startsWith("custom:") && + oauthClientMarker.slice("custom:".length) === configuredClient?.clientId && + configuredClient?.clientSecret + ) { + return { + clientId: configuredClient.clientId, + clientSecret: configuredClient.clientSecret, + }; + } + const builtin = builtinClientFor(provider); + return { clientId: builtin.clientId, clientSecret: builtin.clientSecret }; +} diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 00c150ac14..3831aed629 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -7,9 +7,23 @@ import { getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; +import { + BUILTIN_ANTIGRAVITY_CLIENT, + type GoogleOauthClientMarker, +} from "@omniroute/open-sse/services/tokenRefresh/googleClientBinding.ts"; const POSTEXCHANGE_TIMEOUT_MS = 8_000; +/** + * True when the OAuth config carries operator-provided credentials instead + * of the embedded desktop client. `ANTIGRAVITY_CONFIG.clientId` resolves + * env overrides (ANTIGRAVITY_OAUTH_CLIENT_ID) at module load; compare by + * value against the embedded default client ID. + */ +function isCustomAntigravityClient(config: AntigravityOAuthConfig): boolean { + return config.clientId !== BUILTIN_ANTIGRAVITY_CLIENT.clientId; +} + type AntigravityOAuthConfig = typeof ANTIGRAVITY_CONFIG; type AntigravityTokenPayload = { access_token: string; @@ -31,6 +45,8 @@ type AntigravityPostExchange = { tierId: string; userInfo: { email?: string }; projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome; + /** Literal issuer of the connection's refresh token: "builtin" or "custom:". */ + oauthClient?: GoogleOauthClientMarker; }; async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { @@ -247,6 +263,11 @@ function mapAntigravityTokens( clientProfile, projectId: extra?.projectId, tier: extra?.tierId, + // Which OAuth client issued this connection's refresh token. The token + // refresh must present the same client Google saw at authorize time; + // switching the operator's custom client via env afterwards must not + // retroactively move existing connections (401 unauthorized_client). + oauthClient: extra?.oauthClient, // The Antigravity backend ships new models frequently (e.g. Gemini 3.7 // Flash tiers appeared upstream weeks before the pinned catalog knew // them). Default new connections into the 24h model auto-sync (#488) so @@ -267,7 +288,19 @@ export function createAntigravityOAuthProvider( buildAuthUrl: buildAntigravityAuthUrl, exchangeToken: (runtimeConfig, code, redirectUri) => exchangeAntigravityToken(runtimeConfig, clientProfile, code, redirectUri), - postExchange: (tokens) => postExchangeAntigravity(config, clientProfile, tokens), + postExchange: (tokens) => + postExchangeAntigravity(config, clientProfile, tokens).then((extra) => ({ + ...extra, + // Record the LITERAL client id that issued the refresh token we + // just received (custom: / builtin), so refreshes keep + // presenting that same client even after the operator rotates the + // env-level custom client later on. Compare by value against the + // embedded default: `config` may be the very same object as + // ANTIGRAVITY_CONFIG when no runtime override exists. + oauthClient: isCustomAntigravityClient(config) + ? `custom:${config.clientId}` + : "builtin", + })), mapTokens: (tokens, extra) => mapAntigravityTokens(clientProfile, tokens, extra), }; } diff --git a/tests/unit/google-oauth-client-binding.test.ts b/tests/unit/google-oauth-client-binding.test.ts new file mode 100644 index 0000000000..f3ae404c17 --- /dev/null +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert"; +import { test } from "node:test"; + +// A Google refresh token is bound to the OAuth client that issued it. When an +// operator overrides ANTIGRAVITY_OAUTH_CLIENT_ID/SECRET with their own web +// client, existing connections (issued by the built-in desktop client) must +// keep refreshing against the built-in credentials, and only connections +// created under the custom client should refresh against the custom one. +// Regression: 2026-08-30, switching env credentials globally made every +// existing antigravity/agy refresh return 401 unauthorized_client. +import { getAccessToken } from "../../open-sse/services/tokenRefresh.ts"; +import type { GoogleOauthClientMarker } from "../../open-sse/services/tokenRefresh/googleClientBinding.ts"; + +const CUSTOM_ID = "custom-client-id.apps.googleusercontent.com"; + +async function captureRefreshCall( + providerOverridePsd?: { oauthClient?: GoogleOauthClientMarker } & Record, + provider = "antigravity" +) { + const calls = []; + // refreshGoogleToken reads PROVIDERS[provider].clientId from + // ../config/constants.ts. The registry resolves the built-in desktop client + // unless env overrides exist; point the env at the "custom" client so the + // captured refresh reports which one the code actually used. + const realId = process.env.ANTIGRAVITY_OAUTH_CLIENT_ID; + const realSecret = process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET; + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID = CUSTOM_ID; + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET = "custom-secret"; + const realFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + if (String(url).includes("oauth2.googleapis.com/token")) { + const body = new URLSearchParams(init.body); + calls.push({ client_id: body.get("client_id"), client_secret: body.get("client_secret") }); + } + return { + ok: true, + json: async () => ({ access_token: "at", expires_in: 3600, refresh_token: undefined }), + text: async () => "{}", + }; + }; + try { + await getAccessToken( + provider, + { + connectionId: "test-conn", + refreshToken: "rt", + accessToken: null, + providerSpecificData: providerOverridePsd, + }, + { warn() {}, info() {}, error() {} } + ); + } finally { + globalThis.fetch = realFetch; + if (realId === undefined) delete process.env.ANTIGRAVITY_OAUTH_CLIENT_ID; + else process.env.ANTIGRAVITY_OAUTH_CLIENT_ID = realId; + if (realSecret === undefined) delete process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET; + else process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET = realSecret; + } + return calls; +} + +test("existing connection without oauthClient marker refreshes with the built-in client", async () => { + const calls = await captureRefreshCall(undefined); + assert.equal(calls.length, 1); + // The built-in client is the masked constant decoded at runtime; asserting + // it is NOT the env-configured custom client is the behavioral contract. + assert.notEqual(calls[0].client_id, CUSTOM_ID); + assert.ok(calls[0].client_id.endsWith(".apps.googleusercontent.com")); +}); + +test("connection marked oauthClient=builtin refreshes with the built-in client", async () => { + const calls = await captureRefreshCall({ oauthClient: "builtin" }); + assert.equal(calls.length, 1); + assert.notEqual(calls[0].client_id, CUSTOM_ID); + assert.ok(calls[0].client_id.endsWith(".apps.googleusercontent.com")); +}); + +test("connection marked oauthClient=custom: matching the configured client refreshes with it", async () => { + const calls = await captureRefreshCall({ oauthClient: `custom:${CUSTOM_ID}` }); + assert.equal(calls.length, 1); + assert.equal(calls[0].client_id, CUSTOM_ID); +}); + +test("custom-marked connection falls back to builtin after the operator rotates the custom client", async () => { + // The marker stores the LITERAL issuing client id. When the operator swaps + // to a different custom client, the old connection's token belongs to a + // client neither the env nor the embedded default can represent — the + // builtin fallback is chosen (and the refresh will fail with 401, which is + // the honest outcome: that connection needs re-authorization). + const calls = await captureRefreshCall({ oauthClient: "custom:rotated-away-id.apps.googleusercontent.com" }); + assert.equal(calls.length, 1); + assert.notEqual(calls[0].client_id, CUSTOM_ID); + assert.ok(calls[0].client_id.endsWith(".apps.googleusercontent.com")); +}); + +test("gemini connection without a marker refreshes with the gemini builtin client", async () => { + // gemini embeds a DIFFERENT desktop client than antigravity; the fallback + // must be keyed by provider or every pre-existing gemini connection would + // suddenly refresh against the antigravity client (401 unauthorized_client). + // This also exercises the env-override interplay verified live: with + // GEMINI_OAUTH_CLIENT_ID set to a custom client, an unmarked gemini + // connection still refreshes against the gemini builtin. + const realGeminiId = process.env.GEMINI_OAUTH_CLIENT_ID; + const realGeminiSecret = process.env.GEMINI_OAUTH_CLIENT_SECRET; + process.env.GEMINI_OAUTH_CLIENT_ID = "fake-custom-gemini-id.apps.googleusercontent.com"; + process.env.GEMINI_OAUTH_CLIENT_SECRET = "fake-secret"; + try { + const calls = await captureRefreshCall(undefined, "gemini"); + assert.equal(calls.length, 1); + assert.notEqual(calls[0].client_id, "fake-custom-gemini-id.apps.googleusercontent.com"); + assert.ok(calls[0].client_id.endsWith(".apps.googleusercontent.com")); + const agyCalls = await captureRefreshCall(undefined, "antigravity"); + assert.notEqual(calls[0].client_id, agyCalls[0].client_id, "gemini and antigravity built-ins differ"); + } finally { + if (realGeminiId === undefined) delete process.env.GEMINI_OAUTH_CLIENT_ID; + else process.env.GEMINI_OAUTH_CLIENT_ID = realGeminiId; + if (realGeminiSecret === undefined) delete process.env.GEMINI_OAUTH_CLIENT_SECRET; + else process.env.GEMINI_OAUTH_CLIENT_SECRET = realGeminiSecret; + } +});