Compare commits

...
1 Commits
Author SHA1 Message Date
Adam cc49556e61 fix(console): redirect to login with a full navigation and deep link
`getActor` redirected to the relative `/auth/authorize`. When that came out
of a client-side `query` or `action`, `@solidjs/router` navigated to it in
the SPA, but `/auth/authorize` is a server-only API route, so the router
rendered the 404 page and the user was stuck. Redirect with an absolute URL
so the router performs a full navigation instead.

Carry the requested page through `?continue=` so login returns to it (for
example the Go settings linked from Zen errors) instead of the workspace
index, and validate that path on both ends of the flow. The callback used
to redirect to whatever followed `/auth/callback`, which allowed a
`//host` open redirect through an attacker-chosen `redirect_uri`.
2026-09-21 12:30:53 -05:00
5 changed files with 101 additions and 3 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { loginUrl } from "~/lib/login-redirect"
import { createClient } from "@openauthjs/openauth/client"
@@ -127,7 +128,7 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
}
}
}
throw redirect("/auth/authorize")
throw redirect(loginUrl(evt.request))
})()
return evt.locals.actor
}
@@ -0,0 +1,32 @@
// Only same-origin page paths may round-trip through the login flow. The path is
// appended to the OpenAuth callback URL, so anything that could change its origin
// (`//host`, backslashes, schemes) or its query/fragment is rejected.
export function continuePath(value: string | null | undefined) {
if (!value) return undefined
if (!value.startsWith("/") || value.startsWith("//")) return undefined
if (/[\\?#\s]/.test(value)) return undefined
if (value === "/auth" || value.startsWith("/auth/")) return undefined
return value
}
// Absolute so `@solidjs/router` performs a full navigation instead of a client-side
// route change: `/auth/authorize` is a server-only API route, and navigating to it
// in the SPA renders the 404 page.
export function loginUrl(request: Request) {
const url = new URL(request.url)
const login = new URL("/auth/authorize", url.origin)
const page = continuePath(pagePath(request, url))
if (page) login.searchParams.set("continue", page)
return login.toString()
}
// Server function calls are POSTs to `/_server`; the page they came from is only
// available through the referer, which the middleware keeps same-origin.
function pagePath(request: Request, url: URL) {
if (url.pathname !== "/_server") return url.pathname
const referer = request.headers.get("referer")
if (!referer || !URL.canParse(referer)) return undefined
const source = new URL(referer)
if (source.origin !== url.origin) return undefined
return source.pathname
}
@@ -4,6 +4,7 @@ import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { continuePath } from "~/lib/login-redirect"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -32,7 +33,7 @@ export async function GET(input: APIEvent) {
current: id,
}
})
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
const next = continuePath(url.pathname.slice("/auth/callback".length)) ?? "/auth"
return redirect(route(locale, next))
} catch (e: any) {
return new Response(
@@ -1,9 +1,10 @@
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
import { continuePath } from "~/lib/login-redirect"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
const cont = url.searchParams.get("continue") ?? ""
const cont = continuePath(url.searchParams.get("continue")) ?? ""
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
return Response.redirect(result.url, 302)
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { continuePath, loginUrl } from "../src/lib/login-redirect"
describe("continue path", () => {
test("accepts same-origin page paths", () => {
expect(continuePath("/workspace/wrk_123/go")).toBe("/workspace/wrk_123/go")
expect(continuePath("/")).toBe("/")
})
test("rejects values that could leave the origin or the login flow", () => {
const values = [
undefined,
null,
"",
"workspace/wrk_123",
"//evil.example/phishing",
"/\\evil.example",
"https://evil.example/",
"/workspace/wrk_123?x=1",
"/workspace/wrk_123#x",
"/workspace/wrk 123",
"/auth",
"/auth/authorize",
]
expect(values.map(continuePath)).toEqual(values.map(() => undefined))
})
})
describe("login url", () => {
test("returns to the requested page after login", () => {
expect(loginUrl(new Request("https://opencode.ai/workspace/wrk_123/go"))).toBe(
"https://opencode.ai/auth/authorize?continue=%2Fworkspace%2Fwrk_123%2Fgo",
)
})
test("returns to the page that called a server function", () => {
expect(
loginUrl(
new Request("https://opencode.ai/_server?id=go.referral.get", {
method: "POST",
headers: { referer: "https://opencode.ai/workspace/wrk_123/go" },
}),
),
).toBe("https://opencode.ai/auth/authorize?continue=%2Fworkspace%2Fwrk_123%2Fgo")
})
test("drops unusable return locations", () => {
const referers = ["https://evil.example/workspace/wrk_123", "not a url", undefined]
expect(
referers.map((referer) =>
loginUrl(
new Request("https://opencode.ai/_server?id=go.referral.get", {
method: "POST",
headers: referer === undefined ? undefined : { referer },
}),
),
),
).toEqual(Array(referers.length).fill("https://opencode.ai/auth/authorize"))
expect(loginUrl(new Request("https://opencode.ai/auth"))).toBe("https://opencode.ai/auth/authorize")
})
})