mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
a06ddb38e6
commit
197f726c62
1
changelog.d/fixes/7523-codex-oauth-remote-host.md
Normal file
1
changelog.d/fixes/7523-codex-oauth-remote-host.md
Normal 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).
|
||||
31
src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts
Normal file
31
src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts
Normal 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.`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
42
tests/unit/codex-oauth-remote-host-hint-7523.test.ts
Normal file
42
tests/unit/codex-oauth-remote-host-hint-7523.test.ts
Normal 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>");
|
||||
});
|
||||
Reference in New Issue
Block a user