diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 5115f0348ad4..a34be2f9a22b 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -7,7 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" -import { authFromToken } from "@/utils/server" +import { authFromToken, authFromUrl } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -112,11 +112,14 @@ const getDefaultUrl = () => { return getCurrentUrl() } -const clearAuthToken = () => { - const params = new URLSearchParams(location.search) - if (!params.has("auth_token")) return - params.delete("auth_token") - history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) +const clearStartupAuth = () => { + const url = new URL(location.href) + const changed = url.searchParams.has("auth_token") || !!url.username || !!url.password + if (!changed) return + url.searchParams.delete("auth_token") + url.username = "" + url.password = "" + history.replaceState(null, "", url.pathname + url.search + url.hash) } const platform: Platform = { @@ -154,8 +157,8 @@ if (import.meta.env.VITE_SENTRY_DSN) { } if (root instanceof HTMLElement) { - const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) - clearAuthToken() + const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) ?? authFromUrl(location.href) + clearStartupAuth() const server: ServerConnection.Http = { type: "http", authToken: !!auth, diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts index 4666b7d6d03c..871bde1aaba0 100644 --- a/packages/app/src/utils/server.test.ts +++ b/packages/app/src/utils/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { authFromToken, authTokenFromCredentials } from "./server" +import { authFromToken, authFromUrl, authTokenFromCredentials } from "./server" describe("authFromToken", () => { test("decodes basic auth credentials from auth_token", () => { @@ -21,3 +21,22 @@ describe("authTokenFromCredentials", () => { expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) }) }) + +describe("authFromUrl", () => { + test("decodes basic auth credentials from a URL", () => { + expect(authFromUrl("https://kit:secret@example.test")).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromUrl("https://:secret@example.test")).toEqual({ username: "opencode", password: "secret" }) + }) + + test("decodes username-only credentials from a URL", () => { + expect(authFromUrl("https://kit@example.test")).toEqual({ username: "kit", password: "" }) + }) + + test("ignores URLs without credentials", () => { + expect(authFromUrl("https://example.test")).toBeUndefined() + expect(authFromUrl("not a url")).toBeUndefined() + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d42f..a7957e0c73ad 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -8,15 +8,28 @@ export function authTokenFromCredentials(input: { username?: string; password: s export function authFromToken(token: string | null) { const decoded = decode64(token ?? undefined) - if (!decoded) return + if (!decoded) return undefined const separator = decoded.indexOf(":") - if (separator === -1) return + if (separator === -1) return undefined return { username: decoded.slice(0, separator) || "opencode", password: decoded.slice(separator + 1), } } +export function authFromUrl(input: string) { + try { + const url = new URL(input) + if (!url.username && url.password === "") return undefined + return { + username: url.username ? decodeURIComponent(url.username) : "opencode", + password: decodeURIComponent(url.password), + } + } catch { + return undefined + } +} + export function createSdkForServer({ server, ...config @@ -24,7 +37,7 @@ export function createSdkForServer({ server: ServerConnection.HttpBase }) { const auth = (() => { - if (!server.password) return + if (server.password === undefined) return undefined return { Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, } diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index 5fa1506b1e65..e681b1cba0f2 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -49,4 +49,32 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("wss:") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) + + test("uses query auth for username-only startup credentials", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "kit", + password: "", + authToken: true, + }) + + expect(url.searchParams.get("auth_token")).toBe(btoa("kit:")) + }) + + test("omits query auth when password is undefined", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: false, + username: "kit", + }) + + expect(url.searchParams.has("auth_token")).toBe(false) + }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 06facdc7d245..107a6cb574bb 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -19,7 +19,8 @@ export function terminalWebSocketURL(input: { next.searchParams.set("ticket", input.ticket) return next } - if (input.password && (!input.sameOrigin || input.authToken)) + // Empty passwords are valid Basic auth credentials. + if (input.password !== undefined && (!input.sameOrigin || input.authToken)) next.searchParams.set( "auth_token", authTokenFromCredentials({ username: input.username, password: input.password }),