fix(app): preserve auth token credentials (#25636)
This commit is contained in:
@@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
drop?.()
|
drop?.()
|
||||||
|
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }),
|
terminalWebSocketURL({
|
||||||
|
url,
|
||||||
|
id,
|
||||||
|
directory,
|
||||||
|
cursor: seek,
|
||||||
|
sameOrigin,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
authToken: server.current?.type === "http" ? server.current.authToken : false,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
ws = socket
|
ws = socket
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { resolveServerList, ServerConnection } from "./server"
|
||||||
|
|
||||||
|
describe("resolveServerList", () => {
|
||||||
|
test("lets startup auth_token credentials override a persisted same-url server", () => {
|
||||||
|
const list = resolveServerList({
|
||||||
|
stored: [{ url: "https://server.example.test" }],
|
||||||
|
props: [
|
||||||
|
{
|
||||||
|
type: "http",
|
||||||
|
authToken: true,
|
||||||
|
http: {
|
||||||
|
url: "https://server.example.test",
|
||||||
|
username: "opencode",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
expect(list[0]?.type).toBe("http")
|
||||||
|
expect(list[0]?.http).toEqual({
|
||||||
|
url: "https://server.example.test",
|
||||||
|
username: "opencode",
|
||||||
|
password: "secret",
|
||||||
|
})
|
||||||
|
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
|
||||||
|
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps persisted credentials when startup has no auth_token", () => {
|
||||||
|
const list = resolveServerList({
|
||||||
|
stored: [
|
||||||
|
{
|
||||||
|
url: "https://server.example.test",
|
||||||
|
username: "opencode",
|
||||||
|
password: "saved",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
props: [{ type: "http", http: { url: "https://server.example.test" } }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
expect(list[0]?.type).toBe("http")
|
||||||
|
expect(list[0]?.http).toEqual({
|
||||||
|
url: "https://server.example.test",
|
||||||
|
username: "opencode",
|
||||||
|
password: "saved",
|
||||||
|
})
|
||||||
|
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
|
|||||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveServerList(input: {
|
||||||
|
props?: Array<ServerConnection.Any>
|
||||||
|
stored: StoredServer[]
|
||||||
|
}): Array<ServerConnection.Any> {
|
||||||
|
const servers = [
|
||||||
|
...input.stored.map((value) =>
|
||||||
|
typeof value === "string"
|
||||||
|
? {
|
||||||
|
type: "http" as const,
|
||||||
|
http: { url: value },
|
||||||
|
}
|
||||||
|
: value,
|
||||||
|
),
|
||||||
|
...(input.props ?? []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
|
||||||
|
for (const value of servers) {
|
||||||
|
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
|
||||||
|
deduped.set(key, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...deduped.values()]
|
||||||
|
}
|
||||||
|
|
||||||
export namespace ServerConnection {
|
export namespace ServerConnection {
|
||||||
type Base = { displayName?: string }
|
type Base = { displayName?: string }
|
||||||
|
|
||||||
@@ -46,6 +73,7 @@ export namespace ServerConnection {
|
|||||||
export type Http = {
|
export type Http = {
|
||||||
type: "http"
|
type: "http"
|
||||||
http: HttpBase
|
http: HttpBase
|
||||||
|
authToken?: boolean
|
||||||
} & Base
|
} & Base
|
||||||
|
|
||||||
export type Sidecar = {
|
export type Sidecar = {
|
||||||
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||||
|
|
||||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||||
const servers = [
|
return resolveServerList({ stored: store.list, props: props.servers })
|
||||||
...(props.servers ?? []),
|
|
||||||
...store.list.map((value) =>
|
|
||||||
typeof value === "string"
|
|
||||||
? {
|
|
||||||
type: "http" as const,
|
|
||||||
http: { url: value },
|
|
||||||
}
|
|
||||||
: value,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const deduped = new Map(
|
|
||||||
servers.map((value) => {
|
|
||||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
|
||||||
return [ServerConnection.key(conn), conn]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return [...deduped.values()]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
function add(input: ServerConnection.Http) {
|
function add(input: ServerConnection.Http) {
|
||||||
const url_ = normalizeServerUrl(input.http.url)
|
const url_ = normalizeServerUrl(input.http.url)
|
||||||
if (!url_) return
|
if (!url_) return
|
||||||
const conn = { ...input, http: { ...input.http, url: url_ } }
|
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
|
||||||
return batch(() => {
|
return batch(() => {
|
||||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||||
if (existing !== -1) {
|
if (existing !== -1) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
|
|||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as zh } from "@/i18n/zh"
|
import { dict as zh } from "@/i18n/zh"
|
||||||
import { handleNotificationClick } from "@/utils/notification-click"
|
import { handleNotificationClick } from "@/utils/notification-click"
|
||||||
|
import { authFromToken } from "@/utils/server"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { ServerConnection } from "./context/server"
|
import { ServerConnection } from "./context/server"
|
||||||
|
|
||||||
@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
|
|||||||
return getCurrentUrl()
|
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 platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (root instanceof HTMLElement) {
|
if (root instanceof HTMLElement) {
|
||||||
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||||
|
clearAuthToken()
|
||||||
|
const server: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
authToken: !!auth,
|
||||||
|
http: {
|
||||||
|
url: getCurrentUrl(),
|
||||||
|
...auth,
|
||||||
|
},
|
||||||
|
}
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { authFromToken, authTokenFromCredentials } from "./server"
|
||||||
|
|
||||||
|
describe("authFromToken", () => {
|
||||||
|
test("decodes basic auth credentials from auth_token", () => {
|
||||||
|
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults blank username to opencode", () => {
|
||||||
|
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores malformed tokens", () => {
|
||||||
|
expect(authFromToken("not base64")).toBeUndefined()
|
||||||
|
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("authTokenFromCredentials", () => {
|
||||||
|
test("encodes credentials with the default username", () => {
|
||||||
|
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
|
export function authTokenFromCredentials(input: { username?: string; password: string }) {
|
||||||
|
return btoa(`${input.username ?? "opencode"}:${input.password}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authFromToken(token: string | null) {
|
||||||
|
const decoded = decode64(token ?? undefined)
|
||||||
|
if (!decoded) return
|
||||||
|
const separator = decoded.indexOf(":")
|
||||||
|
if (separator === -1) return
|
||||||
|
return {
|
||||||
|
username: decoded.slice(0, separator) || "opencode",
|
||||||
|
password: decoded.slice(separator + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createSdkForServer({
|
export function createSdkForServer({
|
||||||
server,
|
server,
|
||||||
@@ -10,7 +26,7 @@ export function createSdkForServer({
|
|||||||
const auth = (() => {
|
const auth = (() => {
|
||||||
if (!server.password) return
|
if (!server.password) return
|
||||||
return {
|
return {
|
||||||
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
|
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => {
|
|||||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits query auth for same-origin websocket URL", () => {
|
test("omits query auth for same-origin saved credentials", () => {
|
||||||
const url = terminalWebSocketURL({
|
const url = terminalWebSocketURL({
|
||||||
url: "https://app.example.test",
|
url: "https://app.example.test",
|
||||||
id: "pty_test",
|
id: "pty_test",
|
||||||
@@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => {
|
|||||||
expect(url.protocol).toBe("wss:")
|
expect(url.protocol).toBe("wss:")
|
||||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
expect(url.searchParams.has("auth_token")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses query auth for same-origin credentials from auth_token", () => {
|
||||||
|
const url = terminalWebSocketURL({
|
||||||
|
url: "https://app.example.test",
|
||||||
|
id: "pty_test",
|
||||||
|
directory: "/tmp/project",
|
||||||
|
cursor: 10,
|
||||||
|
sameOrigin: true,
|
||||||
|
username: "opencode",
|
||||||
|
password: "secret",
|
||||||
|
authToken: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(url.protocol).toBe("wss:")
|
||||||
|
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { authTokenFromCredentials } from "@/utils/server"
|
||||||
|
|
||||||
export function terminalWebSocketURL(input: {
|
export function terminalWebSocketURL(input: {
|
||||||
url: string
|
url: string
|
||||||
id: string
|
id: string
|
||||||
@@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: {
|
|||||||
sameOrigin: boolean
|
sameOrigin: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
|
authToken?: boolean
|
||||||
}) {
|
}) {
|
||||||
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
||||||
next.searchParams.set("directory", input.directory)
|
next.searchParams.set("directory", input.directory)
|
||||||
next.searchParams.set("cursor", String(input.cursor))
|
next.searchParams.set("cursor", String(input.cursor))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (!input.sameOrigin && input.password)
|
if (input.password && (!input.sameOrigin || input.authToken))
|
||||||
next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`))
|
next.searchParams.set(
|
||||||
|
"auth_token",
|
||||||
|
authTokenFromCredentials({ username: input.username, password: input.password }),
|
||||||
|
)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user