Explorar el Código

fix(app): preserve auth token credentials (#25636)

Kit Langton hace 4 meses
padre
commit
ca6150d6f0

+ 10 - 1
packages/app/src/components/terminal.tsx

@@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => {
         drop?.()
 
         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"
         ws = socket

+ 53 - 0
packages/app/src/context/server.test.ts

@@ -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()
+  })
+})

+ 30 - 21
packages/app/src/context/server.tsx

@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
   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 {
   type Base = { displayName?: string }
 
@@ -46,6 +73,7 @@ export namespace ServerConnection {
   export type Http = {
     type: "http"
     http: HttpBase
+    authToken?: boolean
   } & Base
 
   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 allServers = createMemo((): Array<ServerConnection.Any> => {
-      const 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()]
+      return resolveServerList({ stored: store.list, props: props.servers })
     })
 
     const [state, setState] = createStore({
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     function add(input: ServerConnection.Http) {
       const url_ = normalizeServerUrl(input.http.url)
       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(() => {
         const existing = store.list.findIndex((x) => url(x) === url_)
         if (existing !== -1) {

+ 18 - 1
packages/app/src/entry.tsx

@@ -7,6 +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 pkg from "../package.json"
 import { ServerConnection } from "./context/server"
 
@@ -111,6 +112,13 @@ 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 platform: Platform = {
   platform: "web",
   version: pkg.version,
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
 }
 
 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(
     () => (
       <PlatformProvider value={platform}>

+ 23 - 0
packages/app/src/utils/server.test.ts

@@ -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"))
+  })
+})

+ 17 - 1
packages/app/src/utils/server.ts

@@ -1,5 +1,21 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 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({
   server,
@@ -10,7 +26,7 @@ export function createSdkForServer({
   const auth = (() => {
     if (!server.password) return
     return {
-      Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
+      Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
     }
   })()
 

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

@@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => {
     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({
       url: "https://app.example.test",
       id: "pty_test",
@@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => {
     expect(url.protocol).toBe("wss:")
     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"))
+  })
 })

+ 8 - 2
packages/app/src/utils/terminal-websocket-url.ts

@@ -1,3 +1,5 @@
+import { authTokenFromCredentials } from "@/utils/server"
+
 export function terminalWebSocketURL(input: {
   url: string
   id: string
@@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: {
   sameOrigin: boolean
   username: string
   password?: string
+  authToken?: boolean
 }) {
   const next = new URL(`${input.url}/pty/${input.id}/connect`)
   next.searchParams.set("directory", input.directory)
   next.searchParams.set("cursor", String(input.cursor))
   next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
-  if (!input.sameOrigin && input.password)
-    next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`))
+  if (input.password && (!input.sameOrigin || input.authToken))
+    next.searchParams.set(
+      "auth_token",
+      authTokenFromCredentials({ username: input.username, password: input.password }),
+    )
   return next
 }