mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
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:
@@ -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
|
||||
);
|
||||
|
||||
78
open-sse/services/tokenRefresh/googleClientBinding.ts
Normal file
78
open-sse/services/tokenRefresh/googleClientBinding.ts
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
120
tests/unit/google-oauth-client-binding.test.ts
Normal file
120
tests/unit/google-oauth-client-binding.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user