From 6cfdf789002c2306dbab653cfacadb63eed5588c Mon Sep 17 00:00:00 2001 From: akarray Date: Wed, 27 May 2026 13:30:08 +0200 Subject: [PATCH] fix: honor public callbacks for remote Google OAuth --- .../api/oauth/[provider]/[action]/route.ts | 5 +- src/lib/oauth/providers.ts | 60 +++++++++++++++++++ src/shared/components/OAuthModal.tsx | 8 +-- tests/unit/oauth-providers-config.test.ts | 40 +++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 194601c698..56c74dd71b 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -6,6 +6,7 @@ import { exchangeTokens, requestDeviceCode, pollForToken, + resolveBrowserOAuthRedirectUri, } from "@/lib/oauth/providers"; import { createProviderConnection, @@ -80,7 +81,9 @@ export async function GET( const { searchParams } = new URL(request.url); if (action === "authorize") { - const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback"; + const requestedRedirectUri = + searchParams.get("redirect_uri") || "http://localhost:8080/callback"; + const redirectUri = resolveBrowserOAuthRedirectUri(provider, requestedRedirectUri); const authData = generateAuthData(provider, redirectUri); if (provider === "qoder" && !authData.authUrl) { return NextResponse.json({ diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 5709042630..3689b4aa47 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -10,6 +10,66 @@ import { generatePKCE, generateState } from "./utils/pkce"; import { PROVIDERS } from "./providers/index"; +const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]); + +function normalizeBaseUrl(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return ""; + return trimmed.replace(/\/+$/, ""); +} + +function hasCustomGoogleOAuthCredentials(providerName, env = process.env) { + if (providerName === "antigravity") { + return !!env.ANTIGRAVITY_OAUTH_CLIENT_ID?.trim(); + } + + if (providerName === "gemini-cli") { + return !!env.GEMINI_CLI_OAUTH_CLIENT_ID?.trim() || !!env.GEMINI_OAUTH_CLIENT_ID?.trim(); + } + + return false; +} + +/** + * Google providers default to localhost redirects so the embedded public + * credentials keep working on out-of-the-box local installs. When operators + * provide their own Google OAuth client IDs for a remote deployment, prefer the + * public callback URL documented in .env.example / docs/README so the popup can + * navigate back to OmniRoute instead of stalling on localhost. + */ +export function resolveBrowserOAuthRedirectUri( + providerName, + redirectUri, + env = process.env +) { + if (!GOOGLE_BROWSER_PROVIDERS.has(providerName)) { + return redirectUri; + } + + if (!hasCustomGoogleOAuthCredentials(providerName, env)) { + return redirectUri; + } + + const publicBaseUrl = + normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env.OMNIROUTE_PUBLIC_BASE_URL); + + if (!publicBaseUrl) { + return redirectUri; + } + + try { + const requested = new URL(redirectUri); + const isLocalhostRedirect = /^(localhost|127\.0\.0\.1)$/i.test(requested.hostname); + if (!isLocalhostRedirect) { + return redirectUri; + } + } catch { + return redirectUri; + } + + return `${publicBaseUrl}/callback`; +} + /** * Get provider handler */ diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 8f021c54d4..df0c59b69f 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -381,9 +381,9 @@ export default function OAuthModal({ // - Codex/OpenAI: always port 1455 (registered in OAuth app) // - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback // (on true localhost the callback server handles it; this is only reached on remote) - // - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of - // where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with - // the built-in credentials. Remote users must configure their own credentials. + // - Google OAuth providers (antigravity, gemini-cli): default to localhost so the + // bundled credentials keep working. The authorize route upgrades this to the public + // callback when custom Google credentials + NEXT_PUBLIC_BASE_URL are configured. // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) // - Localhost: use localhost:port let redirectUri: string; @@ -433,7 +433,7 @@ export default function OAuthModal({ ); } - setAuthData({ ...data, redirectUri }); + setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri }); // For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL) if (!isTrueLocalhost || forceManual) { diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index c277c5aea3..dc0c0d3103 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -18,8 +18,10 @@ const providersModule = await import("../../src/lib/oauth/providers/index.ts"); const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts"); const registryModule = await import("../../open-sse/config/providerRegistry.ts"); const antigravityHeadersModule = await import("../../open-sse/services/antigravityHeaders.ts"); +const oauthHelpersModule = await import("../../src/lib/oauth/providers.ts"); const PROVIDERS = providersModule.default; +const { resolveBrowserOAuthRedirectUri } = oauthHelpersModule; const { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, @@ -316,6 +318,44 @@ test("browser-based providers expose buildAuthUrl and return provider-specific a assert.equal(clineUrl.origin, "https://api.cline.bot"); }); +test("custom Google OAuth credentials switch Antigravity remote callbacks to NEXT_PUBLIC_BASE_URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://localhost:20128/callback", + { + NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com/", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/callback"); +}); + +test("custom Google OAuth credentials switch Gemini remote callbacks to OMNIROUTE_PUBLIC_BASE_URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "gemini-cli", + "http://127.0.0.1:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + GEMINI_CLI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/callback"); +}); + +test("Google OAuth callbacks stay on localhost when no custom credentials are configured", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://localhost:20128/callback", + { + NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com", + } + ); + + assert.equal(redirectUri, "http://localhost:20128/callback"); +}); + test("device and import-token providers expose the flow-specific fields expected by their configs", () => { const deviceProviders = ["qwen", "kimi-coding", "github", "kiro", "amazon-q", "kilocode"];