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
5 changes: 4 additions & 1 deletion src/app/api/oauth/[provider]/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
exchangeTokens,
requestDeviceCode,
pollForToken,
resolveBrowserOAuthRedirectUri,
} from "@/lib/oauth/providers";
import {
createProviderConnection,
Expand Down Expand Up @@ -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({
Expand Down
60 changes: 60 additions & 0 deletions src/lib/oauth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +15 to +31

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

To ensure type safety and prevent potential runtime crashes, we should add explicit TypeScript type annotations to these helper functions and use optional chaining (env?.) when accessing environment variables. This protects against cases where env might be passed as null or undefined (e.g., in certain test configurations or lightweight runtimes).

function normalizeBaseUrl(value: unknown): string {
  const trimmed = typeof value === "string" ? value.trim() : "";
  if (!trimmed) return "";
  return trimmed.replace(/\/+$/, "");
}

function hasCustomGoogleOAuthCredentials(
  providerName: string,
  env: Record<string, string | undefined> = process.env
): boolean {
  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
) {
Comment on lines +40 to +44
Comment on lines +40 to +44

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

Add explicit TypeScript type annotations to the exported resolveBrowserOAuthRedirectUri function to maintain type safety and consistency across the codebase.

Suggested change
export function resolveBrowserOAuthRedirectUri(
providerName,
redirectUri,
env = process.env
) {
export function resolveBrowserOAuthRedirectUri(
providerName: string,
redirectUri: string,
env: Record<string, string | undefined> = process.env
): string {

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;
}
Comment on lines +60 to +68

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

The current localhost check only matches localhost and 127.0.0.1. However, modern operating systems and environments often resolve localhost to the IPv6 loopback address [::1] or ::1. If a user is running on IPv6 localhost, the redirect URI will not be recognized as a localhost redirect, and the helper will fail to resolve the public callback URL.

We should update the regular expression to also match IPv6 loopback addresses ([::1] and ::1).

Suggested change
try {
const requested = new URL(redirectUri);
const isLocalhostRedirect = /^(localhost|127\.0\.0\.1)$/i.test(requested.hostname);
if (!isLocalhostRedirect) {
return redirectUri;
}
} catch {
return redirectUri;
}
try {
const requested = new URL(redirectUri);
const isLocalhostRedirect = /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(requested.hostname);
if (!isLocalhostRedirect) {
return redirectUri;
}
} catch {
return redirectUri;
}


return `${publicBaseUrl}/callback`;
Comment on lines +53 to +70
}

/**
* Get provider handler
*/
Expand Down
8 changes: 4 additions & 4 deletions src/shared/components/OAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +384 to +386
// - Other providers on remote: use actual origin (supports PUBLIC_URL env var)
// - Localhost: use localhost:port
let redirectUri: string;
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/oauth-providers-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"];

Expand Down