From b954a3a60fb3a9f3ca1d8584666c39ab01115d16 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:27:53 -0300 Subject: [PATCH] fix(oauth): warn instead of silently opening unreachable localhost redirect for LAN-IP Codex/xAI/Grok OAuth (#8046) (#8152) --- changelog.d/fixes/8046-codex-oauth-lan-ip.md | 1 + src/lib/oauth/utils/pkceLoopbackWarning.ts | 40 ++++++++++++ src/shared/components/OAuthModal.tsx | 26 ++++---- .../oauth-modal-codex-lan-ip-8046.test.ts | 61 +++++++++++++++++++ 4 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/8046-codex-oauth-lan-ip.md create mode 100644 src/lib/oauth/utils/pkceLoopbackWarning.ts create mode 100644 tests/unit/oauth-modal-codex-lan-ip-8046.test.ts diff --git a/changelog.d/fixes/8046-codex-oauth-lan-ip.md b/changelog.d/fixes/8046-codex-oauth-lan-ip.md new file mode 100644 index 0000000000..e259c3ed6c --- /dev/null +++ b/changelog.d/fixes/8046-codex-oauth-lan-ip.md @@ -0,0 +1 @@ +- fix(oauth): warn instead of silently opening an unreachable localhost:1455 redirect for Codex/xAI/Grok CLI OAuth when the dashboard is reached from a LAN IP (#8046) diff --git a/src/lib/oauth/utils/pkceLoopbackWarning.ts b/src/lib/oauth/utils/pkceLoopbackWarning.ts new file mode 100644 index 0000000000..45b52df045 --- /dev/null +++ b/src/lib/oauth/utils/pkceLoopbackWarning.ts @@ -0,0 +1,40 @@ +/** + * #8046: PKCE_CALLBACK_SERVER_PROVIDERS (codex/xai-oauth/grok-cli) register a FIXED + * loopback redirect_uri (e.g. http://localhost:1455/auth/callback for codex) with the + * upstream OAuth app. That redirect only resolves on the machine actually running the + * browser, not on whatever host serves the OmniRoute dashboard. + * + * OAuthModal's `isTrueLocalhost` check (hostname === "localhost" || "127.0.0.1") only + * covers one such case. A dashboard reached via a LAN IP (192.168.*, 10.*, 172.16-31.*) + * is `isLocalhost: true, isTrueLocalhost: false` — the callback-server branch was falling + * straight through to the standard authorize flow and window.open()ing an authUrl whose + * embedded redirect_uri can never resolve, with zero warning (reported as a silent + * Auth0 `invalid_state` failure with no server-side log line). + * + * Extracted out of OAuthModal.tsx (frozen file-size baseline) so the guard has an + * isolated, unit-testable home. Mirrors the messaging pattern of the sibling + * remote-origin hint added for #7523 (`remoteOAuthHint.ts::buildRemoteOAuthHint`). + */ + +const PKCE_LOOPBACK_REDIRECT_HINT: Record = { + codex: "http://localhost:1455/auth/callback", + "xai-oauth": "http://127.0.0.1:56121/callback", + "grok-cli": "http://127.0.0.1:56122/callback", +}; + +/** + * Build the warning shown instead of silently opening the provider's authorize URL + * when the dashboard is reached from a LAN IP (isLocalhost but not isTrueLocalhost) + * for a PKCE_CALLBACK_SERVER_PROVIDERS provider. + */ +export function buildPkceLoopbackMismatchWarning(provider: string): string { + const redirect = PKCE_LOOPBACK_REDIRECT_HINT[provider] ?? "a fixed localhost callback URL"; + return ( + `OmniRoute is being accessed from a LAN IP, not true localhost. ${provider}'s OAuth app ` + + `is registered with a fixed loopback redirect (${redirect}) that only resolves on the ` + + "machine running this browser tab, not on the OmniRoute server — the login will silently " + + "fail on the provider's side. Open the OmniRoute dashboard from true localhost instead " + + "(SSH port-forward: ssh -L :127.0.0.1: @, then browse to " + + "http://localhost:), or use the token-import flow for this provider if available." + ); +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index ea0f7e1c30..a925c16a9d 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/oauth/utils/codexSessionImport"; import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep"; import { parseGrokCliPasteToken } from "@/lib/oauth/utils/grokCliAuthJson"; +import { buildPkceLoopbackMismatchWarning } from "@/lib/oauth/utils/pkceLoopbackWarning"; export { formatDeviceCodeRemaining } from "./OAuthModalPanels"; @@ -451,10 +452,8 @@ export default function OAuthModal({ forceManual = true; } - // PKCE callback server providers (Codex, Windsurf, Devin CLI): - // On localhost, spin up a local callback server and poll for the result. - // Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port. - // On remote the server is unreachable — fall through to standard manual flow. + // PKCE callback server providers (Codex, Windsurf, Devin CLI): true localhost spins + // up a callback server + polls; a LAN IP warns (#8046); remote falls through below. if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) { if (isTrueLocalhost) { try { @@ -508,21 +507,22 @@ export default function OAuthModal({ setPolling(false); forceManual = true; } + } else if (isLocalhost) { + setError(buildPkceLoopbackMismatchWarning(provider)); + setStep("error"); + return; } - // Remote: fall through to standard auth code flow below + // Remote (non-LAN): fall through to standard auth code flow below } // Authorization code flow // Redirect URI strategy: // - 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/agy): 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. + // - Windsurf/Devin CLI (remote fallback; true localhost handled above): localhost:port + // - Google OAuth providers (antigravity/agy): default to loopback (127.0.0.1 preferred — + // Google docs flag localhost firewall/name-resolution edge cases) so bundled + // native/desktop credentials keep working; the authorize route upgrades this to the + // public callback when custom Google web credentials + a public base URL are configured. // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) // - Localhost: use localhost:port let redirectUri: string; diff --git a/tests/unit/oauth-modal-codex-lan-ip-8046.test.ts b/tests/unit/oauth-modal-codex-lan-ip-8046.test.ts new file mode 100644 index 0000000000..ff3a510951 --- /dev/null +++ b/tests/unit/oauth-modal-codex-lan-ip-8046.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { buildPkceLoopbackMismatchWarning } from "../../src/lib/oauth/utils/pkceLoopbackWarning"; + +// #8046: Codex (and the other PKCE_CALLBACK_SERVER_PROVIDERS: xai-oauth, grok-cli) +// register a FIXED loopback redirect_uri with the upstream OAuth app. On a LAN-IP +// origin (isLocalhost true, isTrueLocalhost false — e.g. 192.168.*/10.*/172.16-31.*), +// OAuthModal used to fall straight through to the standard authorize flow and +// window.open() an authUrl whose embedded redirect_uri can never resolve back to the +// dashboard, with zero warning — surfacing as a silent Auth0 `invalid_state` failure. +// +// Source-level guard (like the sibling oauth-modal-grok-cli-browser-login-7013.test.ts): +// OAuthModal is a "use client" component with heavy runtime deps (next-intl, +// popup/fetch orchestration); pinning the fix by source inspection + a direct unit +// test of the extracted warning-message helper is the reliable check here. +const here = dirname(fileURLToPath(import.meta.url)); +const modal = readFileSync(resolve(here, "../../src/shared/components/OAuthModal.tsx"), "utf8"); + +function extractSet(constName: string): string[] { + const match = modal.match(new RegExp(`const ${constName} = new Set\\(\\[([^\\]]*)\\]\\)`)); + assert.ok(match, `expected to find ${constName} in OAuthModal.tsx`); + return match![1] + .split(",") + .map((s) => s.trim().replace(/^"|"$/g, "")) + .filter(Boolean); +} + +test("PKCE_CALLBACK_SERVER_PROVIDERS still includes codex (anchor)", () => { + assert.ok(extractSet("PKCE_CALLBACK_SERVER_PROVIDERS").includes("codex")); +}); + +test("codex/openai redirectUri is still hardcoded to localhost:1455 (anchor, unchanged)", () => { + assert.match(modal, /redirectUri = "http:\/\/localhost:1455\/auth\/callback"/); +}); + +test("PKCE callback-server providers now warn (not window.open) on a LAN-IP origin (#8046 fix)", () => { + // The callback-server branch must gain an isLocalhost-but-not-isTrueLocalhost arm + // that surfaces the loopback-mismatch warning instead of falling through silently. + assert.match( + modal, + /else if \(isLocalhost\) \{[\s\S]{0,200}buildPkceLoopbackMismatchWarning/, + "OAuthModal.tsx should warn via buildPkceLoopbackMismatchWarning() for isLocalhost && !isTrueLocalhost " + + "inside the PKCE_CALLBACK_SERVER_PROVIDERS branch, instead of silently falling through to " + + "window.open() an authUrl with an unreachable hardcoded loopback redirect_uri." + ); +}); + +test("buildPkceLoopbackMismatchWarning mentions the fixed redirect and a way forward", () => { + const msg = buildPkceLoopbackMismatchWarning("codex"); + assert.match(msg, /localhost:1455/); + assert.match(msg, /LAN IP/i); + assert.match(msg, /localhost/i); +}); + +test("buildPkceLoopbackMismatchWarning has a generic fallback for unknown providers", () => { + const msg = buildPkceLoopbackMismatchWarning("some-future-pkce-provider"); + assert.match(msg, /fixed localhost callback URL/); +});