fix(oauth): repair Google loopback callback flow (#2796)

Integrated into release/v3.8.6
This commit is contained in:
akarray
2026-05-27 22:04:31 +02:00
committed by GitHub
parent 0c6c5e212e
commit 83445a96b8
5 changed files with 185 additions and 35 deletions

View File

@@ -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:<port> 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");
}
}, []);

View File

@@ -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 <code>localhost</code>. After authorizing, your browser will try to open <code>localhost</code> — copy that full URL and paste it below. For fully remote use without this manual step, <a>configure your own OAuth credentials</a>.",
"googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like <code>127.0.0.1</code>. 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, <a>configure your own OAuth credentials</a>.",
"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",

View File

@@ -12,36 +12,61 @@ import { PROVIDERS } from "./providers/index";
const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
function normalizeBaseUrl(value) {
type OAuthRedirectEnv = Record<string, string | undefined>;
function hasValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function firstValue(...values: Array<string | undefined>): 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`;
}
/**

View File

@@ -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,

View File

@@ -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",