mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
fix(oauth): repair Google loopback callback flow (#2796)
Integrated into release/v3.8.6
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user