Skip to content

Commit ca6150d

Browse files
authored
fix(app): preserve auth token credentials (#25636)
1 parent 825ab2e commit ca6150d

8 files changed

Lines changed: 176 additions & 27 deletions

File tree

packages/app/src/components/terminal.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => {
503503
drop?.()
504504

505505
const socket = new WebSocket(
506-
terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }),
506+
terminalWebSocketURL({
507+
url,
508+
id,
509+
directory,
510+
cursor: seek,
511+
sameOrigin,
512+
username,
513+
password,
514+
authToken: server.current?.type === "http" ? server.current.authToken : false,
515+
}),
507516
)
508517
socket.binaryType = "arraybuffer"
509518
ws = socket
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { resolveServerList, ServerConnection } from "./server"
3+
4+
describe("resolveServerList", () => {
5+
test("lets startup auth_token credentials override a persisted same-url server", () => {
6+
const list = resolveServerList({
7+
stored: [{ url: "https://server.example.test" }],
8+
props: [
9+
{
10+
type: "http",
11+
authToken: true,
12+
http: {
13+
url: "https://server.example.test",
14+
username: "opencode",
15+
password: "secret",
16+
},
17+
},
18+
],
19+
})
20+
21+
expect(list).toHaveLength(1)
22+
expect(list[0]?.type).toBe("http")
23+
expect(list[0]?.http).toEqual({
24+
url: "https://server.example.test",
25+
username: "opencode",
26+
password: "secret",
27+
})
28+
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
29+
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
30+
})
31+
32+
test("keeps persisted credentials when startup has no auth_token", () => {
33+
const list = resolveServerList({
34+
stored: [
35+
{
36+
url: "https://server.example.test",
37+
username: "opencode",
38+
password: "saved",
39+
},
40+
],
41+
props: [{ type: "http", http: { url: "https://server.example.test" } }],
42+
})
43+
44+
expect(list).toHaveLength(1)
45+
expect(list[0]?.type).toBe("http")
46+
expect(list[0]?.http).toEqual({
47+
url: "https://server.example.test",
48+
username: "opencode",
49+
password: "saved",
50+
})
51+
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
52+
})
53+
})

packages/app/src/context/server.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
3333
if (host === "localhost" || host === "127.0.0.1") return "local"
3434
}
3535

36+
export function resolveServerList(input: {
37+
props?: Array<ServerConnection.Any>
38+
stored: StoredServer[]
39+
}): Array<ServerConnection.Any> {
40+
const servers = [
41+
...input.stored.map((value) =>
42+
typeof value === "string"
43+
? {
44+
type: "http" as const,
45+
http: { url: value },
46+
}
47+
: value,
48+
),
49+
...(input.props ?? []),
50+
]
51+
52+
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
53+
for (const value of servers) {
54+
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
55+
const key = ServerConnection.key(conn)
56+
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
57+
deduped.set(key, conn)
58+
}
59+
60+
return [...deduped.values()]
61+
}
62+
3663
export namespace ServerConnection {
3764
type Base = { displayName?: string }
3865

@@ -46,6 +73,7 @@ export namespace ServerConnection {
4673
export type Http = {
4774
type: "http"
4875
http: HttpBase
76+
authToken?: boolean
4977
} & Base
5078

5179
export type Sidecar = {
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
113141
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
114142

115143
const allServers = createMemo((): Array<ServerConnection.Any> => {
116-
const servers = [
117-
...(props.servers ?? []),
118-
...store.list.map((value) =>
119-
typeof value === "string"
120-
? {
121-
type: "http" as const,
122-
http: { url: value },
123-
}
124-
: value,
125-
),
126-
]
127-
128-
const deduped = new Map(
129-
servers.map((value) => {
130-
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
131-
return [ServerConnection.key(conn), conn]
132-
}),
133-
)
134-
135-
return [...deduped.values()]
144+
return resolveServerList({ stored: store.list, props: props.servers })
136145
})
137146

138147
const [state, setState] = createStore({
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
174183
function add(input: ServerConnection.Http) {
175184
const url_ = normalizeServerUrl(input.http.url)
176185
if (!url_) return
177-
const conn = { ...input, http: { ...input.http, url: url_ } }
186+
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
178187
return batch(() => {
179188
const existing = store.list.findIndex((x) => url(x) === url_)
180189
if (existing !== -1) {

packages/app/src/entry.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
77
import { dict as en } from "@/i18n/en"
88
import { dict as zh } from "@/i18n/zh"
99
import { handleNotificationClick } from "@/utils/notification-click"
10+
import { authFromToken } from "@/utils/server"
1011
import pkg from "../package.json"
1112
import { ServerConnection } from "./context/server"
1213

@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
111112
return getCurrentUrl()
112113
}
113114

115+
const clearAuthToken = () => {
116+
const params = new URLSearchParams(location.search)
117+
if (!params.has("auth_token")) return
118+
params.delete("auth_token")
119+
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
120+
}
121+
114122
const platform: Platform = {
115123
platform: "web",
116124
version: pkg.version,
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
146154
}
147155

148156
if (root instanceof HTMLElement) {
149-
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
157+
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
158+
clearAuthToken()
159+
const server: ServerConnection.Http = {
160+
type: "http",
161+
authToken: !!auth,
162+
http: {
163+
url: getCurrentUrl(),
164+
...auth,
165+
},
166+
}
150167
render(
151168
() => (
152169
<PlatformProvider value={platform}>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { authFromToken, authTokenFromCredentials } from "./server"
3+
4+
describe("authFromToken", () => {
5+
test("decodes basic auth credentials from auth_token", () => {
6+
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
7+
})
8+
9+
test("defaults blank username to opencode", () => {
10+
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
11+
})
12+
13+
test("ignores malformed tokens", () => {
14+
expect(authFromToken("not base64")).toBeUndefined()
15+
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
16+
})
17+
})
18+
19+
describe("authTokenFromCredentials", () => {
20+
test("encodes credentials with the default username", () => {
21+
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
22+
})
23+
})

packages/app/src/utils/server.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
22
import type { ServerConnection } from "@/context/server"
3+
import { decode64 } from "@/utils/base64"
4+
5+
export function authTokenFromCredentials(input: { username?: string; password: string }) {
6+
return btoa(`${input.username ?? "opencode"}:${input.password}`)
7+
}
8+
9+
export function authFromToken(token: string | null) {
10+
const decoded = decode64(token ?? undefined)
11+
if (!decoded) return
12+
const separator = decoded.indexOf(":")
13+
if (separator === -1) return
14+
return {
15+
username: decoded.slice(0, separator) || "opencode",
16+
password: decoded.slice(separator + 1),
17+
}
18+
}
319

420
export function createSdkForServer({
521
server,
@@ -10,7 +26,7 @@ export function createSdkForServer({
1026
const auth = (() => {
1127
if (!server.password) return
1228
return {
13-
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
29+
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
1430
}
1531
})()
1632

packages/app/src/utils/terminal-websocket-url.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => {
1919
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
2020
})
2121

22-
test("omits query auth for same-origin websocket URL", () => {
22+
test("omits query auth for same-origin saved credentials", () => {
2323
const url = terminalWebSocketURL({
2424
url: "https://app.example.test",
2525
id: "pty_test",
@@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => {
3333
expect(url.protocol).toBe("wss:")
3434
expect(url.searchParams.has("auth_token")).toBe(false)
3535
})
36+
37+
test("uses query auth for same-origin credentials from auth_token", () => {
38+
const url = terminalWebSocketURL({
39+
url: "https://app.example.test",
40+
id: "pty_test",
41+
directory: "/tmp/project",
42+
cursor: 10,
43+
sameOrigin: true,
44+
username: "opencode",
45+
password: "secret",
46+
authToken: true,
47+
})
48+
49+
expect(url.protocol).toBe("wss:")
50+
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
51+
})
3652
})
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { authTokenFromCredentials } from "@/utils/server"
2+
13
export function terminalWebSocketURL(input: {
24
url: string
35
id: string
@@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: {
68
sameOrigin: boolean
79
username: string
810
password?: string
11+
authToken?: boolean
912
}) {
1013
const next = new URL(`${input.url}/pty/${input.id}/connect`)
1114
next.searchParams.set("directory", input.directory)
1215
next.searchParams.set("cursor", String(input.cursor))
1316
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
14-
if (!input.sameOrigin && input.password)
15-
next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`))
17+
if (input.password && (!input.sameOrigin || input.authToken))
18+
next.searchParams.set(
19+
"auth_token",
20+
authTokenFromCredentials({ username: input.username, password: input.password }),
21+
)
1622
return next
1723
}

0 commit comments

Comments
 (0)