From 72eff76910a3cf0ca27d048db1328a199acdd426 Mon Sep 17 00:00:00 2001 From: phatchau036 <93959815+phatchau036@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:56:00 +0700 Subject: [PATCH] fix(oauth): route zed-hosted native-app callback back to the dashboard port (#10517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * Hide health-check excluded models from /v1/models catalog (#10026) Mirror the request-time exclusion rule (provider_specific_data.excludedModels) in the unified catalog builder: a model is hidden when its provider has connections but none of them is eligible for it. Applied across the PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops so ghost models no longer appear as available. Co-authored-by: ritheshcn25 * fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055) * fix(models): memoize getModelsDevPricing for /v1/models catalog resolveCatalogPricing called getModelsDevPricing once per model while building GET /v1/models. Each call re-scanned models_dev_pricing and JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging the event loop so even /healthz timed out (#9685, #10052). Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing and add a unit test for invalidation. Signed-off-by: Ravi Tharuma * fix(db): invalidate modelsDevPricing cache on DB reset (#10055) Copilot review fixes: 1. Register invalidateModelsDevPricingCache() with DB state reset system so resetDbInstance() clears the process-local memo, preventing stale pricing data from surviving across DB reset/restore operations. 2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055). The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing() results until saveModelsDevPricing()/clearModelsDevPricing() to avoid re-scanning all pricing rows on every /v1/models request. Without this hook, backup restore and test DB resets would serve stale cached data from the previous connection. Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts --------- Signed-off-by: Ravi Tharuma Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent * fix(oauth): route Zed hosted sign-in callback back to the dashboard port Zed's native-app sign-in always redirects the browser to the loopback port sent as native_app_port (hardcoded default 58443), where nothing listens: the browser shows "site can't be reached" and the login looks broken even though the token is in the URL. The manual paste fallback was broken too - handleManualSubmit requires a ?code= param that Zed's callback (user_id + access_token) never carries, so the flow could never complete. - zed-hosted: derive native_app_port from the dashboard's own loopback port so the redirect lands back on OmniRoute; remote/LAN origins keep the old default port and the paste flow - app root: forward ?user_id=...&access_token=... to the /callback relay instead of dropping the query string on the /dashboard redirect - /callback relay: recognize the Zed payload (no code param) and relay the full URL as the exchange payload; allow postMessage to both loopback spellings (localhost/127.0.0.1) of the same port - OAuthModal: zed-hosted popup auto-completes on true localhost; the manual paste path passes the full URL through to the exchange instead of erroring with "No authorization code found" - manual input panel: zed-hosted-specific placeholder and hint - tests: extend the postMessage scope guard with the loopback same-port trusted origins * changelog: fragment for #10517 * fix(oauth): derive Zed native_app_port from server config, not browser scheme/port resolveDashboardLoopbackPort() previously re-derived the dashboard's loopback port from the browser-supplied redirectUri (window.location.port || protocol === "https:" ? "443" : "80"), which produced http://127.0.0.1:443/ native-app redirects when the dashboard was reached over HTTPS on its implicit default port (e.g. behind a local TLS-terminating reverse proxy) - a scheme/port mismatch, since Zed's own redirect is always plain http and nothing serves plain HTTP on 443 in that scenario. This code runs server-side (in the OAuth authorize API route), so once the redirect URI's hostname is confirmed loopback it now uses the OmniRoute process's own authoritative listening port via getRuntimePorts() (OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead of re-deriving it from the browser-observed scheme/port. Non-loopback (remote/LAN) redirect URIs still return null and fall back to the manual paste flow. Adds tests/unit/zed-hosted-loopback-port-derivation.test.ts (8 cases) covering the port-derivation logic directly, including the HTTPS-default-port mismatch scenario that motivated this fix, env-var precedence, IPv6 loopback, non-loopback/remote fallback, and buildAuthUrl's native_app_port wiring. Also rebaselines config/quality/file-size-baseline.json for OAuthModal.tsx's own growth from this PR's earlier commit (1134->1149 gate units) - legitimate zed-hosted callback wiring at the existing provider-switch chokepoint, not extractable without a broader modal decomposition (tracked in #3501). The live Zed OAuth handshake itself (root -> /callback -> OAuthModal exchange against the real zed.dev endpoint) still needs a documented VPS smoke test per Hard Rule #18; this fix covers the TDD-able port-derivation logic that motivated the change. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Signed-off-by: Ravi Tharuma Co-authored-by: diegosouzapw Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw Co-authored-by: ritheshcn25 Co-authored-by: ritheshcn25 Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../10517-zed-hosted-oauth-callback-port.md | 1 + config/quality/file-size-baseline.json | 3 +- src/app/callback/page.tsx | 19 ++- src/app/page.tsx | 28 +++- src/lib/oauth/providers/zed-hosted.ts | 51 ++++++- src/shared/components/OAuthModal.tsx | 27 +++- src/shared/components/OAuthModalPanels.tsx | 12 +- tests/unit/modelsDevSync-extended.test.ts | 39 ++++++ .../oauth-callback-postmessage-scope.test.tsx | 17 ++- ...ed-hosted-loopback-port-derivation.test.ts | 128 ++++++++++++++++++ 10 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md create mode 100644 tests/unit/zed-hosted-loopback-port-derivation.test.ts diff --git a/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md new file mode 100644 index 0000000000..af2a0d6b4c --- /dev/null +++ b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md @@ -0,0 +1 @@ +- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036 \ No newline at end of file diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d688cfe70d..05323c8002 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).", "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", "_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", "_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.", @@ -582,7 +583,7 @@ "src/lib/memory/retrieval.ts": "1073", "src/lib/tailscaleTunnel.ts": "1202", "src/lib/usage/providerLimits.ts": "1013", - "src/shared/components/OAuthModal.tsx": "1134", + "src/shared/components/OAuthModal.tsx": "1146", "src/shared/components/RequestLoggerV2.tsx": "1629", "src/shared/components/analytics/charts.tsx": "1035", "src/shared/services/cliRuntime.ts": "1122", diff --git a/src/app/callback/page.tsx b/src/app/callback/page.tsx index 3f5dd678a1..3be2d56b29 100644 --- a/src/app/callback/page.tsx +++ b/src/app/callback/page.tsx @@ -23,11 +23,21 @@ export default function CallbackPage() { useEffect(() => { const params = new URLSearchParams(window.location.search); - const code = params.get("code"); + let code = params.get("code"); const state = params.get("state"); const error = params.get("error"); const errorDescription = params.get("error_description"); + // Zed native-app sign-in: the redirect carries user_id + access_token and no + // ?code= — the FULL URL is the exchange payload (zed-hosted's exchangeToken + // parses and RSA-decrypts it server-side). Rewritten here from `/` by the + // root page handler so the waiting OAuth modal receives it via the same + // postMessage/BroadcastChannel/localStorage relay as every other provider. + const zedAccessToken = params.get("access_token") || params.get("accessToken"); + if (!code && zedAccessToken && (params.get("user_id") || params.get("userId"))) { + code = window.location.href; + } + const callbackData = { code, state, @@ -63,6 +73,13 @@ export default function CallbackPage() { // same-origin fallback when the opener was severed by COOP. const trustedTargetOrigins = [ window.location.origin, // Same origin (dashboard popup mode). + // Loopback hostname variants of the same port: the dashboard may be open + // on 127.0.0.1:PORT while Zed's redirect (or vice versa) lands on + // localhost:PORT — both names are the operator's own machine, so the + // callback may be delivered to either. Same rationale as the 1455 entries. + ...(window.location.port + ? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`] + : []), "http://localhost:1455", // Codex helper (fixed loopback port). "http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form. ]; diff --git a/src/app/page.tsx b/src/app/page.tsx index d594875903..cbf119edb8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,31 @@ import { redirect } from "next/navigation"; -export default function InitPage() { +/** + * Root entry. Zed's native-app sign-in always redirects the browser to the + * loopback ROOT (`http://127.0.0.1:/?user_id=...&access_token=...`), + * ignoring any path — when the dashboard port is reused as native_app_port + * (see zed-hosted.ts), that redirect lands HERE. Forward the payload to the + * /callback relay (which postMessages it to the waiting OAuth modal) instead of + * the plain /dashboard redirect below, which would drop the query string. + */ +export default async function InitPage({ + searchParams, +}: { + searchParams?: Promise>; +}) { + const params = (await searchParams) || {}; + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (typeof value === "string") { + query.set(key, value); + } else if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === "string") query.append(key, item); + } + } + } + if (query.get("user_id") && query.get("access_token")) { + redirect(`/callback?${query.toString()}`); + } redirect("/dashboard"); } diff --git a/src/lib/oauth/providers/zed-hosted.ts b/src/lib/oauth/providers/zed-hosted.ts index db263a509f..46a4c1b320 100644 --- a/src/lib/oauth/providers/zed-hosted.ts +++ b/src/lib/oauth/providers/zed-hosted.ts @@ -1,4 +1,5 @@ import { ZED_HOSTED_CONFIG } from "../constants/oauth"; +import { getRuntimePorts } from "../../runtime/ports"; import { createZedNativeAuthData, parseZedCallbackPayload, @@ -21,15 +22,55 @@ import { * * `code` at exchange time is the pasted native-app callback URL/query string * (`http://127.0.0.1:/?user_id=...&access_token=...`) — Zed always - * redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd - * send, so `redirectUri` here is unused by exchangeToken (kept only to - * satisfy OAuthModal's generic "session must have a redirectUri" guard). + * redirects to loopback + native_app_port, ignoring any path we'd send. When + * the dashboard itself listens on a loopback port, `buildAuthUrl` reuses it as + * native_app_port so the redirect lands back on OmniRoute (auto-completed via + * the /callback relay); otherwise the dead default port is used and the user + * completes the flow by pasting the browser's full URL. */ +/** + * Extract the dashboard's loopback port so Zed's browser redirect can land back + * on OmniRoute itself. Zed always redirects to `http://127.0.0.1:/` + * — it ignores any path/redirect_uri — so reusing the dashboard's own loopback + * port (e.g. 20128) turns the dead "site can't be reached" page into a loadable + * `/callback` relay (the root page forwards ?user_id=...&access_token=... there). + * + * The redirect URI only tells us WHICH HOSTNAME the browser used (loopback vs. + * LAN/remote) — its scheme and port reflect what the *browser* sees, which can + * differ from what the OmniRoute Node process actually listens on (e.g. a local + * TLS-terminating reverse proxy fronting the dashboard on 443 while the real + * process listens on 20128 in plain HTTP). Trusting the browser-supplied port + * previously produced `http://127.0.0.1:443/` redirects that nothing serves in + * plain HTTP. This runs server-side, so once the hostname is confirmed loopback + * (any scheme — Zed's own redirect is always plain http regardless of how the + * dashboard was reached), use the server's own authoritative listening port + * (`getRuntimePorts()`, sourced from OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead + * of re-deriving it from the client-observed scheme/port. Non-loopback redirect + * URIs (remote/LAN deployments) return null → keep the default port and rely on + * the manual paste flow. + */ +function resolveDashboardLoopbackPort(redirectUri: unknown): number | null { + try { + const url = new URL(String(redirectUri)); + if (!/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return null; + const { dashboardPort } = getRuntimePorts(); + return Number.isInteger(dashboardPort) && dashboardPort > 0 ? dashboardPort : null; + } catch { + return null; + } +} + +// Exported for direct unit coverage of the port-derivation logic without +// exercising the live Zed OAuth handshake (see resolveDashboardLoopbackPort.test.ts). +export const __test__ = { resolveDashboardLoopbackPort }; + export const zedHosted = { config: ZED_HOSTED_CONFIG, flowType: "authorization_code", - buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => { - const authData = createZedNativeAuthData(config); + buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG, redirectUri?: string) => { + const nativeAppPort = + resolveDashboardLoopbackPort(redirectUri) || config.defaultNativeAppPort || 58443; + const authData = createZedNativeAuthData(config, { nativeAppPort }); return { authUrl: authData.authUrl, codeVerifier: authData.privateKeyVerifier, diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index aab8007e2d..5cc190c5f8 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -456,11 +456,17 @@ export default function OAuthModal({ // Claude Code and Cline OAuth flows can finish on provider-hosted pages that // show an auth code instead of redirecting back to OmniRoute. // Start directly in manual mode so users always have an input to paste code/url. - // zed-hosted's native-app sign-in always redirects the browser to a local - // 127.0.0.1: callback that OmniRoute never listens on (the port is - // arbitrary and unrelated to the dashboard's own port) — nothing can - // auto-close the popup, so always show the manual paste-URL input. - if (provider === "claude" || provider === "cline" || provider === "zed-hosted") { + // zed-hosted's native-app sign-in redirects the browser to a local + // 127.0.0.1: callback. On true localhost that port IS the + // dashboard's own (buildAuthUrl reuses it), so the redirect lands on the + // /callback relay and the popup flow auto-completes. Elsewhere (LAN/remote) + // the port is unreachable — nothing can auto-close the popup, so always + // show the manual paste-URL input. + if ( + provider === "claude" || + provider === "cline" || + (provider === "zed-hosted" && !isTrueLocalhost) + ) { forceManual = true; } @@ -880,6 +886,17 @@ export default function OAuthModal({ } const input = callbackUrl.trim(); + + // zed-hosted: the native-app callback (http://127.0.0.1:/?user_id=...&access_token=...) + // carries no ?code= param — the FULL pasted URL (or JSON/query blob) is the + // payload. zed-hosted's exchangeToken parses user_id/access_token out of it + // and RSA-decrypts the token with the private key held in codeVerifier, so + // skip the generic code/state extraction below. + if (provider === "zed-hosted") { + await exchangeTokens(input, authData?.state || null); + return; + } + let code = null; let state = authData?.state || null; let errorParam = null; diff --git a/src/shared/components/OAuthModalPanels.tsx b/src/shared/components/OAuthModalPanels.tsx index ea203b5607..a691a20a39 100644 --- a/src/shared/components/OAuthModalPanels.tsx +++ b/src/shared/components/OAuthModalPanels.tsx @@ -364,13 +364,23 @@ export function OAuthManualInputPanel({ code: (chunks) => {chunks}, })}

+ {provider === "zed-hosted" && ( +

+ After signing in, Zed redirects to a local address like{" "} + http://127.0.0.1:<port>/?user_id=... which the + browser may show as unreachable — that is expected. Copy the FULL URL from the + browser address bar (the access token is inside it) and paste it above. +

+ )} onCallbackUrlChange(event.target.value)} placeholder={ provider === "claude" || provider === "cline" ? "code#state or /callback?code=..." - : placeholderUrl + : provider === "zed-hosted" + ? "http://127.0.0.1:/?user_id=...&access_token=..." + : placeholderUrl } className="font-mono text-xs" /> diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index d08dd840fa..3d0f26266e 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -261,6 +261,45 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => { assert.deepEqual(modelsDev.getModelsDevPricing(), {}); }); + test("getModelsDevPricing memoizes until save/clear (#9685)", async () => { + const modelsDev = await importFresh("pricing-memo"); + const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA); + modelsDev.saveModelsDevPricing(pricing); + + const first = modelsDev.getModelsDevPricing(); + const second = modelsDev.getModelsDevPricing(); + assert.equal(first, second, "repeated reads must return the same memoized object"); + + // Mutating DB under the cache must not be visible until invalidation. + const db = core.getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run(); + assert.equal( + modelsDev.getModelsDevPricing(), + first, + "raw SQL without save/clear must not bypass the memo" + ); + + modelsDev.clearModelsDevPricing(); + assert.deepEqual(modelsDev.getModelsDevPricing(), {}); + + modelsDev.saveModelsDevPricing(pricing); + const afterSave = modelsDev.getModelsDevPricing(); + assert.notEqual(afterSave, first, "save must invalidate the memo"); + assert.equal(afterSave.openai["gpt-4o"].input, 2.5); + + // Copilot review: DB reset must invalidate the memo so import/restore doesn't serve stale pricing. + const beforeReset = modelsDev.getModelsDevPricing(); + core.resetDbInstance(); + const afterReset = modelsDev.getModelsDevPricing(); + assert.notEqual( + afterReset, + beforeReset, + "resetDbInstance must invalidate the memo (Copilot #10055)" + ); + // Data is still on disk after resetDbInstance(), but the cache was cleared and re-read from fresh DB. + assert.equal(afterReset.openai["gpt-4o"].input, 2.5, "DB reset re-reads from fresh connection"); + }); + test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => { const modelsDev = await importFresh("capabilities-storage"); const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA); diff --git a/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx b/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx index 0fcab8e857..19cb6fd8f3 100644 --- a/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx +++ b/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx @@ -15,8 +15,11 @@ import CallbackPage from "@/app/callback/page"; * Regression guard for ported upstream PR decolua/9router#998 (security): * the OAuth callback page must never relay {code, state} to a wildcard * postMessage target ("*"), as a hostile opener can read the code/state and - * complete the OAuth flow as the user. Only the same-origin parent and - * Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets. + * complete the OAuth flow as the user. Trusted targets are the same-origin + * parent, the loopback hostname variants of the same port (localhost vs + * 127.0.0.1 — Zed native-app redirects may land on the other spelling than the + * dashboard the modal was opened from; same port means the same OmniRoute + * server), and Codex's fixed loopback helper (127.0.0.1:1455). */ describe("OAuth callback page — postMessage target origin scope (#998)", () => { let container: HTMLDivElement; @@ -81,7 +84,15 @@ describe("OAuth callback page — postMessage target origin scope (#998)", () => await Promise.resolve(); }); - const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]); + const loopbackSamePort = window.location.port + ? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`] + : []; + const trusted = new Set([ + window.location.origin, + ...loopbackSamePort, + "http://localhost:1455", + "http://127.0.0.1:1455", + ]); const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]); expect(targetOrigins.length).toBeGreaterThan(0); for (const origin of targetOrigins) { diff --git a/tests/unit/zed-hosted-loopback-port-derivation.test.ts b/tests/unit/zed-hosted-loopback-port-derivation.test.ts new file mode 100644 index 0000000000..b9cc1b8c94 --- /dev/null +++ b/tests/unit/zed-hosted-loopback-port-derivation.test.ts @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +/** + * Regression coverage for #10517. + * + * Zed's native-app sign-in always redirects the browser to + * `http://127.0.0.1:/`, ignoring any path/redirect_uri we send. + * `zed-hosted.ts::buildAuthUrl` reuses the dashboard's own loopback port as + * native_app_port so that redirect lands back on OmniRoute instead of a dead + * "site can't be reached" page. + * + * Before this fix, the port was re-derived from the browser-supplied + * `redirectUri` string (`OAuthModal.tsx`'s `window.location.port || + * (protocol === "https:" ? "443" : "80")` fallback), which produced + * `http://127.0.0.1:443/` when the dashboard was reached over HTTPS on its + * default port (e.g. behind a local TLS-terminating reverse proxy) — a scheme + * mismatch, since nothing serves plain HTTP on 443 and Zed's redirect is + * always plain http regardless of how the browser reached the dashboard. + * + * The fix runs server-side (this code executes in the Next.js API route, not + * the browser) and derives the port from the OmniRoute process's own + * authoritative listening port (`getRuntimePorts()`, sourced from + * OMNIROUTE_PORT/PORT/DASHBOARD_PORT) once the redirect URI's hostname is + * confirmed loopback — no longer trusting the browser-observed scheme/port. + */ + +const originalEnv = { + OMNIROUTE_PORT: process.env.OMNIROUTE_PORT, + PORT: process.env.PORT, + DASHBOARD_PORT: process.env.DASHBOARD_PORT, +}; + +function resetPortEnv() { + delete process.env.OMNIROUTE_PORT; + delete process.env.PORT; + delete process.env.DASHBOARD_PORT; +} + +test.after(() => { + resetPortEnv(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value; + } +}); + +const { __test__ } = await import("../../src/lib/oauth/providers/zed-hosted.ts"); +const { resolveDashboardLoopbackPort } = __test__; + +test("resolveDashboardLoopbackPort: loopback hostname over HTTPS on the default port resolves via server config, not a guessed 443", () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "20128"; + + // This is the exact shape OAuthModal.tsx's buggy fallback used to produce + // for the true-localhost + default-port case (scheme hardcoded to "http" + // regardless of the real protocol, port guessed from the protocol default). + // Even with a scheme/port combination that does not reflect reality, the + // hostname alone is enough — the real port comes from server config. + const port = resolveDashboardLoopbackPort("http://localhost:443/callback"); + assert.equal(port, 20128, "must use the server's own configured port, never the guessed 443"); +}); + +test("resolveDashboardLoopbackPort: respects OMNIROUTE_PORT override", () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "31415"; + + assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 31415); + assert.equal(resolveDashboardLoopbackPort("http://localhost/callback"), 31415); +}); + +test("resolveDashboardLoopbackPort: falls back to PORT then DASHBOARD_PORT precedence like getRuntimePorts", () => { + resetPortEnv(); + process.env.PORT = "9000"; + assert.equal(resolveDashboardLoopbackPort("http://localhost:20128/callback"), 9000); + + resetPortEnv(); + process.env.DASHBOARD_PORT = "9500"; + assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 9500); +}); + +test("resolveDashboardLoopbackPort: IPv6 loopback literal resolves to the server port", () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "20128"; + assert.equal(resolveDashboardLoopbackPort("http://[::1]:20128/callback"), 20128); +}); + +test("resolveDashboardLoopbackPort: non-loopback (remote/LAN) redirect URIs return null", () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "20128"; + + assert.equal(resolveDashboardLoopbackPort("https://omniroute.example.com/callback"), null); + assert.equal(resolveDashboardLoopbackPort("http://192.168.1.50:20128/callback"), null); +}); + +test("resolveDashboardLoopbackPort: malformed/missing redirect URIs return null", () => { + resetPortEnv(); + assert.equal(resolveDashboardLoopbackPort(undefined), null); + assert.equal(resolveDashboardLoopbackPort("not a url"), null); +}); + +test("zedHosted.buildAuthUrl: reuses the server's configured port as native_app_port for a loopback redirect, regardless of the browser-observed scheme", async () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "20128"; + + const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts"); + const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts"); + + // Simulate the redirect URI OAuthModal.tsx sends when the dashboard is + // reached over HTTPS on its implicit default port (window.location.port is + // empty): hostname is loopback, but scheme/port do not reflect the real + // OmniRoute listener. + const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "http://localhost:443/callback"); + assert.equal(built.redirectUri, "http://127.0.0.1:20128/"); + + const url = new URL(built.authUrl); + assert.equal(url.searchParams.get("native_app_port"), "20128"); +}); + +test("zedHosted.buildAuthUrl: remote/LAN redirect URIs keep the configured default native app port", async () => { + resetPortEnv(); + process.env.OMNIROUTE_PORT = "20128"; + + const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts"); + const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts"); + + const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "https://omniroute.example.com/callback"); + assert.equal(built.redirectUri, `http://127.0.0.1:${ZED_HOSTED_CONFIG.defaultNativeAppPort}/`); +});