diff --git a/src/app/callback/page.tsx b/src/app/callback/page.tsx index 1e5600288e..ebf3be1384 100644 --- a/src/app/callback/page.tsx +++ b/src/app/callback/page.tsx @@ -16,7 +16,9 @@ import { useEffect, useState } from "react"; */ export default function CallbackPage() { const [status, setStatus] = useState<"processing" | "success" | "done" | "manual">("processing"); - const [currentUrl, setCurrentUrl] = useState(""); + const [currentUrl] = useState(() => + typeof window === "undefined" ? "" : window.location.href + ); const t = useTranslations("auth"); useEffect(() => { @@ -35,9 +37,25 @@ export default function CallbackPage() { }; let sent = false; + let openerSameOrigin = false; + const queueStatusUpdate = (nextStatus: "processing" | "success" | "done" | "manual") => { + queueMicrotask(() => setStatus(nextStatus)); + }; + + if (window.opener) { + try { + openerSameOrigin = window.opener.location.origin === window.location.origin; + } catch { + openerSameOrigin = false; + } + } // Method 1: postMessage to opener (popup mode). // May be null when Google OAuth's COOP header severs the opener reference. + // For remote OmniRoute + local loopback callbacks, the callback page origin + // is http://127.0.0.1: while the opener is the public OmniRoute origin. + // Use a wildcard fallback only for the opener that initiated this popup; the + // parent validates the OAuth state before accepting the callback. if (window.opener) { try { // Target this origin specifically — popup mode is only used when isTrueLocalhost, @@ -50,6 +68,15 @@ export default function CallbackPage() { } catch (e) { console.log("postMessage failed:", e); } + + if (!openerSameOrigin) { + try { + window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); + sent = true; + } catch (e) { + console.log("cross-origin postMessage failed:", e); + } + } } // Method 2: BroadcastChannel — works across browsing context groups for same origin. @@ -75,23 +102,23 @@ export default function CallbackPage() { } if (sent && (code || error)) { - if (window.opener) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- initialization effect, window-only - setStatus("success"); + if (window.opener && openerSameOrigin) { + queueStatusUpdate("success"); setTimeout(() => { window.close(); // If close is prevented (browser policy), fall through to manual close prompt. setTimeout(() => setStatus("done"), 500); }, 1500); } else { - // Opened as new tab or opener severed by COOP — show close prompt. - setStatus("done"); + // Opened as a tab, opener severed by COOP, or remote dashboard using a + // loopback/tunnel callback. Keep the full URL visible as a manual fallback + // in case the opener cannot receive the cross-origin postMessage. + queueStatusUpdate("manual"); } } else { // No code/error in URL or all send methods failed — show URL for manual copy. // Batch the URL and status update so they render together (React 18 auto-batching). - setCurrentUrl(window.location.href); - setStatus("manual"); + queueStatusUpdate("manual"); } }, []); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 650a4b3dbe..6f9ffaa7e5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6812,13 +6812,13 @@ "title": "Connect {providerName}", "waiting": "Waiting for authorization", "completeAuthInPopup": "Complete authorization in popup.", - "popupClosedHint": "If the popup closed without redirecting back (e.g. Qoder), this dialog will auto-switch to manual URL entry mode.", + "popupClosedHint": "If the popup closes or cannot relay the callback, this dialog will auto-switch to manual URL entry mode.", "popupBlocked": "Popup blocked? Enter URL manually", "deviceCodeVisitUrl": "Visit the URL below and enter code:", "deviceCodeVerificationUrl": "Verification URL", "deviceCodeYourCode": "Your code", "deviceCodeWaiting": "Waiting for authorization...", - "googleOAuthWarning": "Remote access + Google OAuth: Default credentials only accept redirects to localhost. After authorizing, your browser will try to open localhost — copy that full URL and paste it below. For fully remote use without this manual step, configure your own OAuth credentials.", + "googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like 127.0.0.1. The browser that approves Google must be able to reach OmniRoute on that local port, usually by opening OmniRoute locally or using an SSH/local-forward tunnel. For fully remote use without this local callback, configure your own OAuth credentials.", "remoteAccessInfo": "Remote access: Since you're accessing OmniRoute remotely, after authorization you'll see an error page (localhost not found). This is normal — just copy the full URL from your browser address bar and paste it below.", "step1OpenUrl": "Step 1: Open this URL in your browser", "copy": "Copy", diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 3689b4aa47..edc9136ea8 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -12,36 +12,61 @@ import { PROVIDERS } from "./providers/index"; const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]); -function normalizeBaseUrl(value) { +type OAuthRedirectEnv = Record; + +function hasValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function firstValue(...values: Array): string | undefined { + return values.find(hasValue); +} + +function normalizeBaseUrl(value: unknown): string { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) return ""; return trimmed.replace(/\/+$/, ""); } -function hasCustomGoogleOAuthCredentials(providerName, env = process.env) { +function hasCustomGoogleOAuthCredentials( + providerName: string, + env: OAuthRedirectEnv | null | undefined = process.env +): boolean { if (providerName === "antigravity") { - return !!env.ANTIGRAVITY_OAUTH_CLIENT_ID?.trim(); + return ( + hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_ID) && + hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_SECRET) + ); } if (providerName === "gemini-cli") { - return !!env.GEMINI_CLI_OAUTH_CLIENT_ID?.trim() || !!env.GEMINI_OAUTH_CLIENT_ID?.trim(); + const clientId = firstValue(env?.GEMINI_CLI_OAUTH_CLIENT_ID, env?.GEMINI_OAUTH_CLIENT_ID); + const clientSecret = firstValue( + env?.GEMINI_CLI_OAUTH_CLIENT_SECRET, + env?.GEMINI_OAUTH_CLIENT_SECRET + ); + return hasValue(clientId) && hasValue(clientSecret); } return false; } +function isLoopbackHostname(hostname: string): boolean { + return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(hostname); +} + /** - * Google providers default to localhost redirects so the embedded public + * Google providers default to loopback 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 -) { + providerName: string, + redirectUri: string, + env: OAuthRedirectEnv | null | undefined = process.env +): string { if (!GOOGLE_BROWSER_PROVIDERS.has(providerName)) { return redirectUri; } @@ -59,15 +84,16 @@ export function resolveBrowserOAuthRedirectUri( try { const requested = new URL(redirectUri); - const isLocalhostRedirect = /^(localhost|127\.0\.0\.1)$/i.test(requested.hostname); - if (!isLocalhostRedirect) { + if (!isLoopbackHostname(requested.hostname)) { return redirectUri; } + + const callbackPath = + requested.pathname && requested.pathname !== "/" ? requested.pathname : "/callback"; + return `${publicBaseUrl}${callbackPath}${requested.search}`; } catch { return redirectUri; } - - return `${publicBaseUrl}/callback`; } /** diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index df0c59b69f..ab5fa69910 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -381,9 +381,12 @@ 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): 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. + // - Google OAuth providers (antigravity, gemini-cli): default to loopback so the + // bundled native/desktop credentials keep working. Prefer 127.0.0.1 over + // localhost for the Google native-app handoff; Google documents that localhost + // can run into local firewall/name-resolution edge cases. The authorize route + // upgrades this to the public callback when custom Google web credentials plus + // NEXT_PUBLIC_BASE_URL or OMNIROUTE_PUBLIC_BASE_URL are configured. // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) // - Localhost: use localhost:port let redirectUri: string; @@ -395,10 +398,10 @@ export default function OAuthModal({ const port = window.location.port || "20128"; redirectUri = `http://localhost:${port}/auth/callback`; } else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) { - // Google OAuth built-in credentials only accept localhost redirect URIs. - // Even in remote deployments we use localhost — user copies the callback URL manually. + // Google OAuth built-in credentials only accept loopback redirect URIs. + // Even in remote deployments we use loopback — user copies the callback URL manually. const port = window.location.port || "20128"; - redirectUri = `http://localhost:${port}/callback`; + redirectUri = `http://127.0.0.1:${port}/callback`; } else if (!isLocalhost) { // Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback) // Supports PUBLIC_URL env var override, or falls back to window.location.origin. @@ -497,6 +500,13 @@ export default function OAuthModal({ const { code, state, error: callbackError, errorDescription } = data; + if (authData?.state && state && state !== authData.state) { + callbackProcessedRef.current = true; + setError("OAuth state mismatch. Restart the connection and try again."); + setStep("error"); + return; + } + if (callbackError) { callbackProcessedRef.current = true; setError(errorDescription || callbackError); @@ -515,12 +525,26 @@ export default function OAuthModal({ // Accept same-origin OR localhost with same port (remote access scenario: // dashboard at 192.168.x:port, callback redirects to localhost:port) const currentPort = window.location.port; - const isLocalhostSamePort = - event.origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) && - new URL(event.origin).port === currentPort; - if (event.origin !== window.location.origin && !isLocalhostSamePort) return; + let isLoopbackOrigin = false; + let isLocalhostSamePort = false; + try { + const eventUrl = new URL(event.origin); + isLoopbackOrigin = /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(eventUrl.hostname); + isLocalhostSamePort = isLoopbackOrigin && eventUrl.port === currentPort; + } catch { + // Ignore malformed origins. + } + + const payload = event.data?.data; + const hasMatchingState = !!authData?.state && payload?.state === authData.state; + const isGoogleLoopbackRelay = + GOOGLE_OAUTH_PROVIDERS.has(provider) && isLoopbackOrigin && hasMatchingState; + + if (event.origin !== window.location.origin && !isLocalhostSamePort && !isGoogleLoopbackRelay) { + return; + } if (event.data?.type === "oauth_callback") { - handleCallback(event.data.data); + handleCallback(payload); } }; window.addEventListener("message", handleMessage); @@ -568,7 +592,7 @@ export default function OAuthModal({ window.removeEventListener("storage", handleStorage); if (channel) channel.close(); }; - }, [authData, exchangeTokens]); + }, [authData, exchangeTokens, provider]); // Fix #344: Detect when OAuth popup is closed without completing authorization // Some providers (like Qoder) redirect to their own chat UI instead of sending a callback, diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index dc0c0d3103..7f6c9b4ab0 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -325,6 +325,7 @@ test("custom Google OAuth credentials switch Antigravity remote callbacks to NEX { NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com/", ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-antigravity-secret", } ); @@ -338,12 +339,84 @@ test("custom Google OAuth credentials switch Gemini remote callbacks to OMNIROUT { OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", GEMINI_CLI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com", + GEMINI_CLI_OAUTH_CLIENT_SECRET: "custom-gemini-secret", } ); assert.equal(redirectUri, "https://omniroute.example.com/callback"); }); +test("custom Google OAuth callbacks preserve the requested callback path and query", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/auth/callback?source=popup", + { + NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com/base", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-antigravity-secret", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/base/auth/callback?source=popup"); +}); + +test("custom Google OAuth credentials switch IPv6 loopback callbacks to public base URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "gemini-cli", + "http://[::1]:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + GEMINI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com", + GEMINI_OAUTH_CLIENT_SECRET: "custom-gemini-secret", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/callback"); +}); + +test("custom Google OAuth callbacks default root loopback paths to callback path", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128", + { + NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-antigravity-secret", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/callback"); +}); + +test("custom Google OAuth credentials ignore blank Gemini CLI values before checking Gemini fallback values", () => { + 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: " ", + GEMINI_CLI_OAUTH_CLIENT_SECRET: " ", + GEMINI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com", + GEMINI_OAUTH_CLIENT_SECRET: "custom-gemini-secret", + } + ); + + assert.equal(redirectUri, "https://omniroute.example.com/callback"); +}); + +test("Google OAuth callbacks stay on loopback when custom credentials are incomplete", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/callback", + { + NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com", + } + ); + + assert.equal(redirectUri, "http://127.0.0.1:20128/callback"); +}); + test("Google OAuth callbacks stay on localhost when no custom credentials are configured", () => { const redirectUri = resolveBrowserOAuthRedirectUri( "antigravity",