fix(oauth): surface tunnel hint when Codex OAuth runs on a remote host (#7523) (#7527)

The PKCE callback server binds the SERVER's loopback (localhost:PORT). When
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 — the confirmation screen hangs forever with no explanation.

start-callback-server now inspects the request Host: on a non-loopback host it
returns { remoteHost, tunnelCommand, message } so the UI can show the
'ssh -L PORT:127.0.0.1:PORT' instruction (or steer to the paste/import flow)
instead of a silent hang. Loopback access is unaffected. The Host header is
spoofable, so this drives only a UI hint — never an auth decision.

Logic extracted to remoteOAuthHint.ts (keeps the god-route under its size
budget and makes it unit-testable). TDD: 4 tests covering loopback (no hint),
null host (fail-open), and remote host (correct tunnel command for both the
fixed 1455 and OS-assigned ports).

Closes #7523
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 02:39:30 -03:00
committed by GitHub
parent a06ddb38e6
commit 197f726c62
4 changed files with 93 additions and 2 deletions

View File

@@ -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).

View File

@@ -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} <user>@<omniroute-host>`,
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.`,
};
}

View File

@@ -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);

View File

@@ -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 <user>@<omniroute-host>");
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 <user>@<omniroute-host>");
});