fix(oauth): bind Google refresh to the client that issued the token (#12106)

Vincula o refresh OAuth do Google ao client que emitiu o token, com teste próprio (`google-oauth-client-binding.test.ts`). Validado no worktree combinado. Obrigado!
This commit is contained in:
Bob.Hou
2026-08-30 10:10:44 -04:00
committed by GitHub
parent 14dc6e8513
commit 039a425401
4 changed files with 244 additions and 3 deletions

View File

@@ -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
);

View File

@@ -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 };
}

View File

@@ -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:<clientId>". */
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:<id> / 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),
};
}

View File

@@ -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<string, unknown>,
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:<id> 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;
}
});