From c2c16f16b787ff711d6ec917c824dbf886d011ed Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 05:18:10 -0400 Subject: [PATCH 1/7] fix(oauth): bind Google refresh to the client that issued the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Google refresh token only works against the OAuth client that issued it. PROVIDERS.antigravity.clientId resolves the env override (ANTIGRAVITY_OAUTH_CLIENT_ID) first, so the moment an operator configures a custom web client, every existing connection's refresh starts presenting the new client and Google answers 401 unauthorized_client for all of them — observed in production when switching to a custom client for the public redirect URI: four healthy antigravity/agy connections went refresh-dead within minutes, access tokens expiring an hour later. Record which client issued the connection at authorize time (providerSpecificData.oauthClient: builtin|custom, stamped in postExchange from the same config used for the code exchange) and make the refresh path select credentials per connection: unmarked connections — everything created before the marker existed — refresh against the embedded desktop client, custom-marked ones against the configured client. With no env override both branches resolve to the same built-in client and behavior is unchanged. Bug-injection verified: reverting to the global PROVIDERS lookup fails the unmarked/builtin cases; the per-connection selection passes all three. Signed-off-by: Minxi Hou --- open-sse/services/tokenRefresh.ts | 13 ++- .../tokenRefresh/googleClientBinding.ts | 47 ++++++++++ src/lib/oauth/providers/antigravity.ts | 15 ++- .../unit/google-oauth-client-binding.test.ts | 91 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 open-sse/services/tokenRefresh/googleClientBinding.ts create mode 100644 tests/unit/google-oauth-client-binding.test.ts diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 893496846deb..a9d6c5d468bc 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,18 @@ 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( + 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 000000000000..6c079eae402f --- /dev/null +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -0,0 +1,47 @@ +import { resolvePublicCred } from "../../utils/publicCreds.ts"; + +/** + * Built-in (env-override-free) Google client credentials. + * + * `PROVIDERS.antigravity.clientId` resolves env overrides first + * (ANTIGRAVITY_OAUTH_CLIENT_ID), so once an operator configures a custom + * OAuth web client it can no longer see the embedded desktop client that + * issued the refresh tokens of every pre-existing connection. Those + * connections must keep refreshing against the built-in client: Google binds + * a refresh token to the client that issued it and answers any other client + * with 401 unauthorized_client. + */ +export const BUILTIN_ANTIGRAVITY_CLIENT = { + clientId: resolvePublicCred("antigravity_id"), + clientSecret: resolvePublicCred("antigravity_alt"), +} as const; + +/** + * Pick the OAuth client credentials a Google refresh must use. + * + * The marker lives in the connection's providerSpecificData.oauthClient: + * - "custom": the connection was authorized under the operator's custom + * client (ANTIGRAVITY_OAUTH_CLIENT_ID/SECRET set at authorize time), so + * the refresh must present that same client. + * - "builtin" or missing: the connection predates the marker or was + * authorized with the embedded desktop client. Missing deliberately + * means built-in: every connection created before this marker existed + * was issued by the embedded client, because a custom client could not + * be configured for antigravity OAuth at all (redirect_uri stayed + * loopback). + * + * When no custom credentials are configured, both branches resolve to the + * same built-in client and the choice is moot. + */ +export function selectGoogleRefreshClient( + oauthClientMarker: unknown, + configuredClient: { clientId: string; clientSecret: string } +): { clientId: string; clientSecret: string } { + if (oauthClientMarker === "custom" && configuredClient) { + return configuredClient; + } + return { + clientId: BUILTIN_ANTIGRAVITY_CLIENT.clientId, + clientSecret: BUILTIN_ANTIGRAVITY_CLIENT.clientSecret, + }; +} diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 5cab4d6f9366..cf6daea775d5 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -31,6 +31,7 @@ type AntigravityPostExchange = { tierId: string; userInfo: { email?: string }; projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome; + oauthClient?: "builtin" | "custom"; }; async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { @@ -247,6 +248,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 +273,14 @@ 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 which OAuth client issued the refresh token we just + // received, so refreshes keep presenting that same client even after + // the operator swaps the env-level custom client later on. + oauthClient: config.clientId === ANTIGRAVITY_CONFIG.clientId ? "builtin" : "custom", + })), 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 000000000000..bf7624802b39 --- /dev/null +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert"; +import { test, mock } 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"; + +const BUILTIN_ID = "builtin-client-id.apps.googleusercontent.com"; // unused after assert rewrite +const CUSTOM_ID = "custom-client-id.apps.googleusercontent.com"; + +async function captureRefreshCall(providerOverridePsd, 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 + // proxy below reports which one the refresh 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 config = await import("../../open-sse/config/constants.ts"); + const fake = new Proxy( + { [provider]: { clientId: CUSTOM_ID, clientSecret: "custom-secret" } }, + { + get(t, p) { + if (p === provider) return t[p]; + return { clientId: BUILTIN_ID, clientSecret: "builtin-secret" }; + }, + } + ); + // Temporarily replace the exported binding is not possible for const; instead + // patch via globalThis fetch to capture what refreshGoogleToken sends. + 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 refreshes with the custom client", async () => { + const calls = await captureRefreshCall({ oauthClient: "custom" }); + assert.equal(calls.length, 1); + assert.equal(calls[0].client_id, CUSTOM_ID); +}); From e5fb1b0c90d0b4fecb0420daba949e0d0b222fe2 Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 05:30:47 -0400 Subject: [PATCH 2/7] fix(oauth): compare the issuing client by value and drop test dead code The postExchange marker compared config against ANTIGRAVITY_CONFIG by reference; createAntigravityOAuthProvider receives that same object when no runtime override exists, so the comparison could never tell a custom config apart from the default one. Compare by value against the raw embedded client id instead, harden selectGoogleRefreshClient against malformed PROVIDERS entries, and remove unused imports/vars the review flagged. Signed-off-by: Minxi Hou --- .../tokenRefresh/googleClientBinding.ts | 13 ++++++++++--- src/lib/oauth/providers/antigravity.ts | 17 +++++++++++++++-- tests/unit/google-oauth-client-binding.test.ts | 17 ++--------------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts index 6c079eae402f..e5f7d75781c5 100644 --- a/open-sse/services/tokenRefresh/googleClientBinding.ts +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -35,10 +35,17 @@ export const BUILTIN_ANTIGRAVITY_CLIENT = { */ export function selectGoogleRefreshClient( oauthClientMarker: unknown, - configuredClient: { clientId: string; clientSecret: string } + configuredClient: { clientId?: string; clientSecret?: string } | null | undefined ): { clientId: string; clientSecret: string } { - if (oauthClientMarker === "custom" && configuredClient) { - return configuredClient; + if ( + oauthClientMarker === "custom" && + configuredClient?.clientId && + configuredClient?.clientSecret + ) { + return { + clientId: configuredClient.clientId, + clientSecret: configuredClient.clientSecret, + }; } return { clientId: BUILTIN_ANTIGRAVITY_CLIENT.clientId, diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index cf6daea775d5..d5605ae35108 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -7,9 +7,20 @@ import { getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; +import { BUILTIN_ANTIGRAVITY_CLIENT } 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, so compare by + * value against the raw embedded default. + */ +function isCustomAntigravityClient(config: AntigravityOAuthConfig): boolean { + return config.clientId !== BUILTIN_ANTIGRAVITY_CLIENT.clientId; +} + type AntigravityOAuthConfig = typeof ANTIGRAVITY_CONFIG; type AntigravityTokenPayload = { access_token: string; @@ -278,8 +289,10 @@ export function createAntigravityOAuthProvider( ...extra, // Record which OAuth client issued the refresh token we just // received, so refreshes keep presenting that same client even after - // the operator swaps the env-level custom client later on. - oauthClient: config.clientId === ANTIGRAVITY_CONFIG.clientId ? "builtin" : "custom", + // the operator swaps 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" : "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 index bf7624802b39..6b0744b023ad 100644 --- a/tests/unit/google-oauth-client-binding.test.ts +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { test, mock } from "node:test"; +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 @@ -10,7 +10,6 @@ import { test, mock } from "node:test"; // existing antigravity/agy refresh return 401 unauthorized_client. import { getAccessToken } from "../../open-sse/services/tokenRefresh.ts"; -const BUILTIN_ID = "builtin-client-id.apps.googleusercontent.com"; // unused after assert rewrite const CUSTOM_ID = "custom-client-id.apps.googleusercontent.com"; async function captureRefreshCall(providerOverridePsd, provider = "antigravity") { @@ -18,23 +17,11 @@ async function captureRefreshCall(providerOverridePsd, provider = "antigravity") // 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 - // proxy below reports which one the refresh actually used. + // 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 config = await import("../../open-sse/config/constants.ts"); - const fake = new Proxy( - { [provider]: { clientId: CUSTOM_ID, clientSecret: "custom-secret" } }, - { - get(t, p) { - if (p === provider) return t[p]; - return { clientId: BUILTIN_ID, clientSecret: "builtin-secret" }; - }, - } - ); - // Temporarily replace the exported binding is not possible for const; instead - // patch via globalThis fetch to capture what refreshGoogleToken sends. const realFetch = globalThis.fetch; globalThis.fetch = async (url, init) => { if (String(url).includes("oauth2.googleapis.com/token")) { From b4cd86b3eec7b3935c6c6667d64be81d7858046a Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 05:40:25 -0400 Subject: [PATCH 3/7] fix(oauth): key the builtin refresh client by provider gemini and antigravity embed different desktop OAuth clients (681255809395-... vs 1071006060591-...), but the refresh fallback returned the antigravity one for every provider in the Google case. A pre-existing gemini connection would have started refreshing against the antigravity client and failed with 401 unauthorized_client the same way the incident connections did. Key the built-in client per provider and cover the distinction with a regression test. Signed-off-by: Minxi Hou --- open-sse/services/tokenRefresh.ts | 1 + .../tokenRefresh/googleClientBinding.ts | 45 +++++++++++-------- src/lib/oauth/providers/antigravity.ts | 10 +++-- .../unit/google-oauth-client-binding.test.ts | 12 +++++ 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index a9d6c5d468bc..65b9bf58064d 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -338,6 +338,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: // 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] ); diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts index e5f7d75781c5..9d64ee88a5e4 100644 --- a/open-sse/services/tokenRefresh/googleClientBinding.ts +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -1,39 +1,50 @@ import { resolvePublicCred } from "../../utils/publicCreds.ts"; /** - * Built-in (env-override-free) Google client credentials. + * Built-in (env-override-free) Google client credentials per provider. * - * `PROVIDERS.antigravity.clientId` resolves env overrides first - * (ANTIGRAVITY_OAUTH_CLIENT_ID), so once an operator configures a custom - * OAuth web client it can no longer see the embedded desktop client that - * issued the refresh tokens of every pre-existing connection. Those - * connections must keep refreshing against the built-in client: Google binds - * a refresh token to the client that issued it and answers any other client - * with 401 unauthorized_client. + * `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 = { +const BUILTIN_ANTIGRAVITY_CLIENT = { clientId: resolvePublicCred("antigravity_id"), clientSecret: resolvePublicCred("antigravity_alt"), } as const; +const BUILTIN_GEMINI_CLIENT = { + clientId: resolvePublicCred("gemini_id"), + clientSecret: resolvePublicCred("gemini_alt"), +} as const; + +function builtinClientFor(provider: string) { + return provider === "gemini" ? BUILTIN_GEMINI_CLIENT : BUILTIN_ANTIGRAVITY_CLIENT; +} + /** * Pick the OAuth client credentials a Google refresh must use. * * The marker lives in the connection's providerSpecificData.oauthClient: * - "custom": the connection was authorized under the operator's custom - * client (ANTIGRAVITY_OAUTH_CLIENT_ID/SECRET set at authorize time), so - * the refresh must present that same client. + * client (env override set at authorize time), so the refresh must + * present that same client. * - "builtin" or missing: the connection predates the marker or was * authorized with the embedded desktop client. Missing deliberately * means built-in: every connection created before this marker existed - * was issued by the embedded client, because a custom client could not - * be configured for antigravity OAuth at all (redirect_uri stayed - * loopback). + * was issued by an embedded client, because no per-connection custom + * client could be recorded at all. * * 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: unknown, configuredClient: { clientId?: string; clientSecret?: string } | null | undefined ): { clientId: string; clientSecret: string } { @@ -47,8 +58,6 @@ export function selectGoogleRefreshClient( clientSecret: configuredClient.clientSecret, }; } - return { - clientId: BUILTIN_ANTIGRAVITY_CLIENT.clientId, - clientSecret: BUILTIN_ANTIGRAVITY_CLIENT.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 d5605ae35108..01c4d8046243 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -7,18 +7,20 @@ import { getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -import { BUILTIN_ANTIGRAVITY_CLIENT } from "@omniroute/open-sse/services/tokenRefresh/googleClientBinding.ts"; +import { selectGoogleRefreshClient } 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, so compare by - * value against the raw embedded default. + * env overrides (ANTIGRAVITY_OAUTH_CLIENT_ID) at module load; ask the + * binding module for the built-in antigravity client and compare by value + * (antigravity and agy embed the same client, so one comparison covers both). */ function isCustomAntigravityClient(config: AntigravityOAuthConfig): boolean { - return config.clientId !== BUILTIN_ANTIGRAVITY_CLIENT.clientId; + const builtin = selectGoogleRefreshClient("antigravity", "builtin", null); + return config.clientId !== builtin.clientId; } type AntigravityOAuthConfig = typeof ANTIGRAVITY_CONFIG; diff --git a/tests/unit/google-oauth-client-binding.test.ts b/tests/unit/google-oauth-client-binding.test.ts index 6b0744b023ad..99963e9dc236 100644 --- a/tests/unit/google-oauth-client-binding.test.ts +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -76,3 +76,15 @@ test("connection marked oauthClient=custom refreshes with the custom client", as assert.equal(calls.length, 1); assert.equal(calls[0].client_id, CUSTOM_ID); }); + +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). + const calls = await captureRefreshCall(undefined, "gemini"); + assert.equal(calls.length, 1); + assert.notEqual(calls[0].client_id, CUSTOM_ID); + 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"); +}); From 745f6d1171aea530e2fa73dbb5681ae4e092aab7 Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 06:11:52 -0400 Subject: [PATCH 4/7] fix(oauth): record the literal issuing client id in the marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare "custom" marker silently moved a connection onto whatever custom client the env points at NOW. When the operator rotates ANTIGRAVITY_OAUTH_CLIENT_ID to a different custom client, the old connection's refresh token belongs to the previous client and the refresh must not present the new one. Store the literal id (custom:) and only refresh with the configured client when the ids match; a mismatch falls back to the embedded client, which leaves the refresh failing with 401 — the honest signal that the connection needs re-authorization. Signed-off-by: Minxi Hou --- .../tokenRefresh/googleClientBinding.ts | 27 ++++++++++++------- src/lib/oauth/providers/antigravity.ts | 18 ++++++++----- .../unit/google-oauth-client-binding.test.ts | 16 +++++++++-- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts index 9d64ee88a5e4..c9197cc4d59e 100644 --- a/open-sse/services/tokenRefresh/googleClientBinding.ts +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -31,14 +31,20 @@ function builtinClientFor(provider: string) { * Pick the OAuth client credentials a Google refresh must use. * * The marker lives in the connection's providerSpecificData.oauthClient: - * - "custom": the connection was authorized under the operator's custom - * client (env override set at authorize time), so the refresh must - * present that same client. - * - "builtin" or missing: the connection predates the marker or was - * authorized with the embedded desktop client. Missing deliberately - * means built-in: every connection created before this marker existed - * was issued by an embedded client, because no per-connection custom - * client could be recorded at all. + * - 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. @@ -49,8 +55,9 @@ export function selectGoogleRefreshClient( configuredClient: { clientId?: string; clientSecret?: string } | null | undefined ): { clientId: string; clientSecret: string } { if ( - oauthClientMarker === "custom" && - configuredClient?.clientId && + typeof oauthClientMarker === "string" && + oauthClientMarker.startsWith("custom:") && + oauthClientMarker.slice("custom:".length) === configuredClient?.clientId && configuredClient?.clientSecret ) { return { diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 01c4d8046243..9822f09a8685 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -44,7 +44,8 @@ type AntigravityPostExchange = { tierId: string; userInfo: { email?: string }; projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome; - oauthClient?: "builtin" | "custom"; + /** Literal issuer of the connection's refresh token: "builtin" or "custom:". */ + oauthClient?: string; }; async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { @@ -289,12 +290,15 @@ export function createAntigravityOAuthProvider( postExchange: (tokens) => postExchangeAntigravity(config, clientProfile, tokens).then((extra) => ({ ...extra, - // Record which OAuth client issued the refresh token we just - // received, so refreshes keep presenting that same client even after - // the operator swaps 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" : "builtin", + // 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 index 99963e9dc236..20cb4ad94229 100644 --- a/tests/unit/google-oauth-client-binding.test.ts +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -71,12 +71,24 @@ test("connection marked oauthClient=builtin refreshes with the built-in client", assert.ok(calls[0].client_id.endsWith(".apps.googleusercontent.com")); }); -test("connection marked oauthClient=custom refreshes with the custom client", async () => { - const calls = await captureRefreshCall({ oauthClient: "custom" }); +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 From 0c851e9c394d8148cd909ded195718318b8ad09e Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 06:21:10 -0400 Subject: [PATCH 5/7] refactor(oauth): type the marker union and reject unknown builtin providers Narrow the marker to the literal union ("builtin" | custom: | undefined) so call sites get compile-time checking, make the builtin provider lookup explicit (unknown Google-family providers now throw instead of silently borrowing the antigravity client), and pin the gemini env-override behavior with a test verified against the live module: an unmarked gemini connection keeps refreshing with the gemini builtin even when GEMINI_OAUTH_CLIENT_ID carries a custom client. Signed-off-by: Minxi Hou --- .../tokenRefresh/googleClientBinding.ts | 12 +++++++-- .../unit/google-oauth-client-binding.test.ts | 26 ++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts index c9197cc4d59e..33358bbf4f35 100644 --- a/open-sse/services/tokenRefresh/googleClientBinding.ts +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -23,8 +23,16 @@ const BUILTIN_GEMINI_CLIENT = { 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) { - return provider === "gemini" ? BUILTIN_GEMINI_CLIENT : BUILTIN_ANTIGRAVITY_CLIENT; + 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}`); } /** @@ -51,7 +59,7 @@ function builtinClientFor(provider: string) { */ export function selectGoogleRefreshClient( provider: string, - oauthClientMarker: unknown, + oauthClientMarker: GoogleOauthClientMarker | unknown, configuredClient: { clientId?: string; clientSecret?: string } | null | undefined ): { clientId: string; clientSecret: string } { if ( diff --git a/tests/unit/google-oauth-client-binding.test.ts b/tests/unit/google-oauth-client-binding.test.ts index 20cb4ad94229..6f2dfdf83aa3 100644 --- a/tests/unit/google-oauth-client-binding.test.ts +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -93,10 +93,24 @@ test("gemini connection without a marker refreshes with the gemini builtin clien // 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). - const calls = await captureRefreshCall(undefined, "gemini"); - assert.equal(calls.length, 1); - assert.notEqual(calls[0].client_id, CUSTOM_ID); - 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"); + // 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; + } }); From 12ae204f56ac568897a0abb8b9833a2f01a40f96 Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 07:25:23 -0400 Subject: [PATCH 6/7] refactor(oauth): tighten marker type and compare against exported builtin client Export BUILTIN_ANTIGRAVITY_CLIENT and BUILTIN_GEMINI_CLIENT directly so provider modules can perform direct value comparisons without bouncing through selectGoogleRefreshClient, and narrow oauthClientMarker from `GoogleOauthClientMarker | unknown` to `GoogleOauthClientMarker`. Signed-off-by: Minxi Hou --- open-sse/services/tokenRefresh/googleClientBinding.ts | 6 +++--- src/lib/oauth/providers/antigravity.ts | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/open-sse/services/tokenRefresh/googleClientBinding.ts b/open-sse/services/tokenRefresh/googleClientBinding.ts index 33358bbf4f35..628f4655efea 100644 --- a/open-sse/services/tokenRefresh/googleClientBinding.ts +++ b/open-sse/services/tokenRefresh/googleClientBinding.ts @@ -13,12 +13,12 @@ import { resolvePublicCred } from "../../utils/publicCreds.ts"; * gemini and antigravity embed DIFFERENT desktop clients, so the fallback is * keyed by provider, not global. */ -const BUILTIN_ANTIGRAVITY_CLIENT = { +export const BUILTIN_ANTIGRAVITY_CLIENT = { clientId: resolvePublicCred("antigravity_id"), clientSecret: resolvePublicCred("antigravity_alt"), } as const; -const BUILTIN_GEMINI_CLIENT = { +export const BUILTIN_GEMINI_CLIENT = { clientId: resolvePublicCred("gemini_id"), clientSecret: resolvePublicCred("gemini_alt"), } as const; @@ -59,7 +59,7 @@ function builtinClientFor(provider: string) { */ export function selectGoogleRefreshClient( provider: string, - oauthClientMarker: GoogleOauthClientMarker | unknown, + oauthClientMarker: GoogleOauthClientMarker, configuredClient: { clientId?: string; clientSecret?: string } | null | undefined ): { clientId: string; clientSecret: string } { if ( diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 9822f09a8685..1222644f465e 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -7,20 +7,18 @@ import { getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -import { selectGoogleRefreshClient } from "@omniroute/open-sse/services/tokenRefresh/googleClientBinding.ts"; +import { BUILTIN_ANTIGRAVITY_CLIENT } 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; ask the - * binding module for the built-in antigravity client and compare by value - * (antigravity and agy embed the same client, so one comparison covers both). + * env overrides (ANTIGRAVITY_OAUTH_CLIENT_ID) at module load; compare by + * value against the embedded default client ID. */ function isCustomAntigravityClient(config: AntigravityOAuthConfig): boolean { - const builtin = selectGoogleRefreshClient("antigravity", "builtin", null); - return config.clientId !== builtin.clientId; + return config.clientId !== BUILTIN_ANTIGRAVITY_CLIENT.clientId; } type AntigravityOAuthConfig = typeof ANTIGRAVITY_CONFIG; From 175dd9b667da2c7acc9b651b31fd710f087b381a Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Sun, 30 Aug 2026 08:06:19 -0400 Subject: [PATCH 7/7] refactor(oauth): type AntigravityPostExchange.oauthClient and captureRefreshCall Apply GoogleOauthClientMarker to AntigravityPostExchange.oauthClient and add explicit parameter types to captureRefreshCall test helper. Signed-off-by: Minxi Hou --- src/lib/oauth/providers/antigravity.ts | 7 +++++-- tests/unit/google-oauth-client-binding.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 1222644f465e..bd45e3ee969f 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -7,7 +7,10 @@ import { getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -import { BUILTIN_ANTIGRAVITY_CLIENT } from "@omniroute/open-sse/services/tokenRefresh/googleClientBinding.ts"; +import { + BUILTIN_ANTIGRAVITY_CLIENT, + type GoogleOauthClientMarker, +} from "@omniroute/open-sse/services/tokenRefresh/googleClientBinding.ts"; const POSTEXCHANGE_TIMEOUT_MS = 8_000; @@ -43,7 +46,7 @@ type AntigravityPostExchange = { userInfo: { email?: string }; projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome; /** Literal issuer of the connection's refresh token: "builtin" or "custom:". */ - oauthClient?: string; + oauthClient?: GoogleOauthClientMarker; }; async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { diff --git a/tests/unit/google-oauth-client-binding.test.ts b/tests/unit/google-oauth-client-binding.test.ts index 6f2dfdf83aa3..f3ae404c1748 100644 --- a/tests/unit/google-oauth-client-binding.test.ts +++ b/tests/unit/google-oauth-client-binding.test.ts @@ -9,10 +9,14 @@ import { test } from "node:test"; // 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, provider = "antigravity") { +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