diff --git a/changelog.d/fixes/7523-codex-oauth-remote-host.md b/changelog.d/fixes/7523-codex-oauth-remote-host.md new file mode 100644 index 0000000000..f74a47b464 --- /dev/null +++ b/changelog.d/fixes/7523-codex-oauth-remote-host.md @@ -0,0 +1 @@ +- The PKCE OAuth start (`/api/oauth/[provider]/start-callback-server`, used by Codex/Windsurf/Devin) now detects when OmniRoute is being driven from a remote host and returns a reverse-tunnel hint (`remoteHost`, `tunnelCommand`, `message`) instead of hanging silently: the callback server binds the *server's* localhost:PORT, so a browser on a different machine would redirect to its own localhost and never complete. Loopback access is unchanged (#7523). diff --git a/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts b/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts new file mode 100644 index 0000000000..e95c74255b --- /dev/null +++ b/src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts @@ -0,0 +1,31 @@ +import { isLoopbackHost } from "@/server/authz/routeGuard"; + +export type RemoteOAuthHint = + | { remoteHost: false } + | { remoteHost: true; tunnelCommand: string; message: string }; + +/** + * #7523: The PKCE callback server binds the SERVER's loopback (localhost:PORT). + * If the operator drives the OAuth flow from a different machine (OmniRoute on + * a remote host/VPS), the provider redirects the browser to the operator's OWN + * localhost:PORT, not the server's — the confirmation screen hangs forever. + * When the request's Host is non-loopback, return the reverse-tunnel hint so + * the UI can show it instead of a silent hang. + * + * The Host header is spoofable, so this drives only a UI hint, never an + * auth/security decision. + */ +export function buildRemoteOAuthHint(hostHeader: string | null, port: number): RemoteOAuthHint { + if (hostHeader == null || isLoopbackHost(hostHeader)) { + return { remoteHost: false }; + } + return { + remoteHost: true, + tunnelCommand: `ssh -L ${port}:127.0.0.1:${port} @`, + message: + `OmniRoute appears to be running on a remote host (${hostHeader}). ` + + `The OAuth callback returns to localhost:${port} on THIS machine, not the server, ` + + `so the login will hang. Open a reverse tunnel first (see tunnelCommand), then retry — ` + + `or use the token import flow instead.`, + }; +} diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 270372eb0e..0774bd8d2c 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -36,6 +36,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { keychainImportOnlyGuard } from "./keychainImportOnly"; +import { buildRemoteOAuthHint } from "./remoteOAuthHint"; // Use globalThis to persist callback server state across Next.js HMR reloads if (!globalThis.__codexCallbackState) { @@ -242,7 +243,7 @@ export async function GET( } if (action === "start-callback-server") { - return await handleStartCallbackServer(provider, searchParams); + return await handleStartCallbackServer(provider, searchParams, request); } if (action === "public-link-status") { @@ -268,7 +269,11 @@ export async function GET( * Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0). * Returns the auth URL and stores codeVerifier for later exchange. */ -async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) { +async function handleStartCallbackServer( + provider: string, + searchParams: URLSearchParams, + request?: Request +) { if (!PKCE_CALLBACK_PROVIDERS.has(provider)) { return NextResponse.json( { error: `Callback server not supported for provider: ${provider}` }, @@ -323,11 +328,23 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear } }, 300000); + // #7523: the PKCE callback server listens on the SERVER's loopback + // (localhost:PORT). When the operator drives the OAuth flow from a + // *different* machine (OmniRoute running on a remote host/VPS), the + // provider redirects the browser to the operator's own localhost:PORT, + // not the server's — so the final confirmation screen hangs forever. + // Detect a non-loopback Host and surface the reverse-tunnel instruction + // (or steer to the paste/import flow) instead of a silent hang. + const hostHeader = + request?.headers.get("x-forwarded-host") || request?.headers.get("host") || null; + const remoteHint = buildRemoteOAuthHint(hostHeader, port); + return NextResponse.json({ authUrl: authData.authUrl, codeVerifier: authData.codeVerifier, redirectUri, serverPort: port, + ...remoteHint, }); } catch (error) { console.error("OAuth start-callback-server error:", error); diff --git a/tests/unit/codex-oauth-remote-host-hint-7523.test.ts b/tests/unit/codex-oauth-remote-host-hint-7523.test.ts new file mode 100644 index 0000000000..d907077916 --- /dev/null +++ b/tests/unit/codex-oauth-remote-host-hint-7523.test.ts @@ -0,0 +1,42 @@ +// Regression test for #7523: the Codex (and Windsurf/Devin) PKCE OAuth callback +// server binds the SERVER's loopback (localhost:PORT). When OmniRoute runs on a +// remote host (e.g. the VPS) and the operator drives the browser from a different +// machine, the provider redirects to the operator's OWN localhost:PORT — the +// login confirmation screen hangs forever with no explanation. +// +// buildRemoteOAuthHint() detects a non-loopback Host and surfaces the +// reverse-tunnel instruction so the start-callback-server response carries it +// (the UI shows it instead of a silent hang). Loopback access is unaffected. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildRemoteOAuthHint } from "../../src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts"; + +test("loopback Host → no remote hint (local access is unaffected)", () => { + for (const host of ["localhost", "localhost:20128", "127.0.0.1:20128", "[::1]:20128", "::1"]) { + const hint = buildRemoteOAuthHint(host, 1455); + assert.equal(hint.remoteHost, false, `expected no hint for loopback host ${host}`); + } +}); + +test("null Host → no remote hint (fail-open: never block a local flow on a missing header)", () => { + const hint = buildRemoteOAuthHint(null, 1455); + assert.equal(hint.remoteHost, false); +}); + +test("remote Host → returns the reverse-tunnel hint with the exact callback port", () => { + const hint = buildRemoteOAuthHint("192.168.0.15:20128", 1455); + assert.equal(hint.remoteHost, true); + assert.ok(hint.remoteHost === true); // narrow the union + // The tunnel must forward the SAME port the callback server bound, both sides. + assert.equal(hint.tunnelCommand, "ssh -L 1455:127.0.0.1:1455 @"); + assert.match(hint.message, /remote host \(192\.168\.0\.15:20128\)/); + assert.match(hint.message, /hang/i); +}); + +test("remote Host honours a random callback port (Windsurf/Devin OS-assigned port)", () => { + const hint = buildRemoteOAuthHint("omniroute.example.com", 54321); + assert.ok(hint.remoteHost === true); + assert.equal(hint.tunnelCommand, "ssh -L 54321:127.0.0.1:54321 @"); +});