Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions src/app/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Comment on lines +19 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since currentUrl is never updated via a state setter and is only read when status === "manual" (which occurs after mount on the client side), there is no need to manage it as React state. Declaring it as a simple constant is cleaner, more performant, and avoids unnecessary state overhead.

  const currentUrl = typeof window === "undefined" ? "" : window.location.href;

const t = useTranslations("auth");

useEffect(() => {
Expand All @@ -35,9 +37,25 @@ export default function CallbackPage() {
};

let sent = false;
let openerSameOrigin = false;
const queueStatusUpdate = (nextStatus: "processing" | "success" | "done" | "manual") => {
queueMicrotask(() => setStatus(nextStatus));
};
Comment on lines +41 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using queueMicrotask to defer state updates inside useEffect is unnecessary and is generally a React anti-pattern. React 18 automatically batches state updates, and setting state on mount is standard practice. You can remove this helper entirely and call setStatus directly (as is already done on line 110 with setStatus("done")).


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,
Expand All @@ -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);
}
}
Comment on lines +72 to +79
Comment on lines +72 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Using the wildcard "*" in postMessage when sending sensitive OAuth data (like the authorization code and state) is a security vulnerability (Cross-Origin Information Leakage). If the window.opener has been navigated to a malicious origin, that origin can intercept the authorization code and potentially compromise the user's account.

Recommendation

To secure this cross-origin communication, you can encode the client's window.location.origin into the OAuth state parameter when initiating the authorization flow (e.g., state = originalState + "|" + b64EncodedOrigin). When the callback page receives the state parameter, it can decode the origin and use it as the target origin for postMessage instead of "*".

}

// Method 2: BroadcastChannel — works across browsing context groups for same origin.
Expand All @@ -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");
}
Comment on lines 118 to 122
}, []);

Expand Down
4 changes: 2 additions & 2 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 39 additions & 13 deletions src/lib/oauth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +54 to +56

/**
* 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;
}
Expand All @@ -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`;
}

/**
Expand Down
48 changes: 36 additions & 12 deletions src/shared/components/OAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Comment on lines +503 to +508

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

If authData.state is expected (truthy) but the callback returns a missing or empty state parameter, the current check authData?.state && state && state !== authData.state will evaluate to false and silently bypass the state validation. This exposes the flow to CSRF attacks where the state parameter is omitted.

To ensure robust CSRF protection, the state validation should fail if the expected state is present but the returned state is either missing or does not match.

Suggested change
if (authData?.state && state && state !== authData.state) {
callbackProcessedRef.current = true;
setError("OAuth state mismatch. Restart the connection and try again.");
setStep("error");
return;
}
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);
Expand All @@ -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);
}
Comment on lines +538 to 548
Comment on lines 546 to 548

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the received message has type === "oauth_callback" but payload (i.e., event.data.data) is undefined or null, calling handleCallback(payload) will cause a runtime crash because handleCallback immediately destructures data (e.g., const { code, ... } = data) without a null/undefined guard.

Adding a check to ensure payload is truthy before calling handleCallback prevents this potential crash.

Suggested change
if (event.data?.type === "oauth_callback") {
handleCallback(event.data.data);
handleCallback(payload);
}
if (event.data?.type === "oauth_callback" && payload) {
handleCallback(payload);
}

};
window.addEventListener("message", handleMessage);
Expand Down Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/oauth-providers-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
);

Expand All @@ -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",
Expand Down