Explorar el Código

fix(auth): respect server username in clients (#25596)

Shoubhit Dash hace 3 meses
padre
commit
8694c5b68f

+ 2 - 8
packages/opencode/src/cli/cmd/acp.ts

@@ -4,9 +4,9 @@ import { effectCmd } from "../effect-cmd"
 import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
 import { ACP } from "@/acp/agent"
 import { Server } from "@/server/server"
+import { ServerAuth } from "@/server/auth"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { withNetworkOptions, resolveNetworkOptions } from "../network"
-import { Flag } from "@opencode-ai/core/flag/flag"
 
 const log = Log.create({ service: "acp-command" })
 
@@ -27,13 +27,7 @@ export const AcpCommand = effectCmd({
 
     const sdk = createOpencodeClient({
       baseUrl: `http://${server.hostname}:${server.port}`,
-      headers: Flag.OPENCODE_SERVER_PASSWORD
-        ? {
-            Authorization: `Basic ${Buffer.from(
-              `${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`,
-            ).toString("base64")}`,
-          }
-        : undefined,
+      headers: ServerAuth.headers(),
     })
 
     const input = new WritableStream<Uint8Array>({

+ 2 - 7
packages/opencode/src/cli/cmd/run.ts

@@ -5,6 +5,7 @@ import { Effect } from "effect"
 import { UI } from "../ui"
 import { effectCmd } from "../effect-cmd"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { ServerAuth } from "@/server/auth"
 import { EOL } from "os"
 import { Filesystem } from "@/util/filesystem"
 import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
@@ -656,13 +657,7 @@ export const RunCommand = effectCmd({
       }
 
       if (args.attach) {
-        const headers = (() => {
-          const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
-          if (!password) return undefined
-          const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
-          const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
-          return { Authorization: auth }
-        })()
+        const headers = ServerAuth.headers({ password: args.password })
         const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
         return await execute(sdk)
       }

+ 7 - 6
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { errorMessage } from "@/util/error"
 import { validateSession } from "./validate-session"
+import { ServerAuth } from "@/server/auth"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -38,6 +39,11 @@ export const AttachCommand = cmd({
         alias: ["p"],
         type: "string",
         describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
+      })
+      .option("username", {
+        alias: ["u"],
+        type: "string",
+        describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
       }),
   handler: async (args) => {
     const unguard = win32InstallCtrlCGuard()
@@ -60,12 +66,7 @@ export const AttachCommand = cmd({
           return args.dir
         }
       })()
-      const headers = (() => {
-        const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
-        if (!password) return undefined
-        const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
-        return { Authorization: auth }
-      })()
+      const headers = ServerAuth.headers({ password: args.password, username: args.username })
       const config = await TuiConfig.get()
 
       try {

+ 2 - 9
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc"
 import { upgrade } from "@/cli/upgrade"
 import { Config } from "@/config/config"
 import { GlobalBus } from "@/bus/global"
-import { Flag } from "@opencode-ai/core/flag/flag"
+import { ServerAuth } from "@/server/auth"
 import { writeHeapSnapshot } from "node:v8"
 import { Heap } from "@/cli/heap"
 import { AppRuntime } from "@/effect/app-runtime"
@@ -50,7 +50,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
 export const rpc = {
   async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
     const headers = { ...input.headers }
-    const auth = getAuthorizationHeader()
+    const auth = ServerAuth.header()
     if (auth && !headers["authorization"] && !headers["Authorization"]) {
       headers["Authorization"] = auth
     }
@@ -102,10 +102,3 @@ export const rpc = {
 }
 
 Rpc.listen(rpc)
-
-function getAuthorizationHeader(): string | undefined {
-  const password = Flag.OPENCODE_SERVER_PASSWORD
-  if (!password) return undefined
-  const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
-  return `Basic ${btoa(`${username}:${password}`)}`
-}

+ 2 - 5
packages/opencode/src/plugin/index.ts

@@ -10,6 +10,7 @@ import { Bus } from "../bus"
 import * as Log from "@opencode-ai/core/util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { ServerAuth } from "@/server/auth"
 import { CodexAuthPlugin } from "./codex"
 import { Session } from "@/session/session"
 import { NamedError } from "@opencode-ai/core/util/error"
@@ -124,11 +125,7 @@ export const layer = Layer.effect(
         const client = createOpencodeClient({
           baseUrl: "http://localhost:4096",
           directory: ctx.directory,
-          headers: Flag.OPENCODE_SERVER_PASSWORD
-            ? {
-                Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
-              }
-            : undefined,
+          headers: ServerAuth.headers(),
           fetch: async (...args) => Server.Default().app.fetch(...args),
         })
         const cfg = yield* config.get()

+ 48 - 0
packages/opencode/src/server/auth.ts

@@ -0,0 +1,48 @@
+export * as ServerAuth from "./auth"
+
+import { ConfigService } from "@/effect/config-service"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Config as EffectConfig, Context, Option, Redacted } from "effect"
+
+export type Credentials = {
+  password?: string
+  username?: string
+}
+
+export type DecodedCredentials = {
+  readonly username: string
+  readonly password: Redacted.Redacted
+}
+
+export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
+  password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
+  username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
+}) {}
+
+export type Info = Context.Service.Shape<typeof Config>
+
+export function required(config: Info) {
+  return Option.isSome(config.password) && config.password.value !== ""
+}
+
+export function authorized(credentials: DecodedCredentials, config: Info) {
+  return (
+    Option.isSome(config.password) &&
+    credentials.username === config.username &&
+    Redacted.value(credentials.password) === config.password.value
+  )
+}
+
+export function header(credentials?: Credentials) {
+  const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
+  if (!password) return undefined
+
+  const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
+  return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+}
+
+export function headers(credentials?: Credentials) {
+  const authorization = header(credentials)
+  if (!authorization) return undefined
+  return { Authorization: authorization }
+}

+ 13 - 36
packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

@@ -1,5 +1,5 @@
-import { ConfigService } from "@/effect/config-service"
-import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
+import { ServerAuth } from "@/server/auth"
+import { Effect, Encoding, Layer, Redacted } from "effect"
 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
 
@@ -18,41 +18,18 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
   },
 ) {}
 
-export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
-  "@opencode/ExperimentalHttpApiServerAuthConfig",
-  {
-    password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
-    username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
-  },
-) {}
-
 function validateCredential<A, E, R>(
   effect: Effect.Effect<A, E, R>,
-  credential: { readonly username: string; readonly password: Redacted.Redacted },
-  config: Context.Service.Shape<typeof ServerAuthConfig>,
+  credential: ServerAuth.DecodedCredentials,
+  config: ServerAuth.Info,
 ) {
   return Effect.gen(function* () {
-    if (!isAuthRequired(config)) return yield* effect
-    if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
+    if (!ServerAuth.required(config)) return yield* effect
+    if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
     return yield* effect
   })
 }
 
-function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
-  return Option.isSome(config.password) && config.password.value !== ""
-}
-
-function isCredentialAuthorized(
-  credential: { readonly username: string; readonly password: Redacted.Redacted },
-  config: Context.Service.Shape<typeof ServerAuthConfig>,
-) {
-  return (
-    Option.isSome(config.password) &&
-    credential.username === config.username &&
-    Redacted.value(credential.password) === config.password.value
-  )
-}
-
 function decodeCredential(input: string) {
   const emptyCredential = {
     username: "",
@@ -78,11 +55,11 @@ function decodeCredential(input: string) {
 
 function validateRawCredential<A, E, R>(
   effect: Effect.Effect<A, E, R>,
-  credential: { readonly username: string; readonly password: Redacted.Redacted },
-  config: Context.Service.Shape<typeof ServerAuthConfig>,
+  credential: ServerAuth.DecodedCredentials,
+  config: ServerAuth.Info,
 ) {
-  if (!isAuthRequired(config)) return effect
-  if (!isCredentialAuthorized(credential, config))
+  if (!ServerAuth.required(config)) return effect
+  if (!ServerAuth.authorized(credential, config))
     return Effect.succeed(
       HttpServerResponse.empty({
         status: UNAUTHORIZED,
@@ -94,8 +71,8 @@ function validateRawCredential<A, E, R>(
 
 export const authorizationRouterMiddleware = HttpRouter.middleware()(
   Effect.gen(function* () {
-    const config = yield* ServerAuthConfig
-    if (!isAuthRequired(config)) return (effect) => effect
+    const config = yield* ServerAuth.Config
+    if (!ServerAuth.required(config)) return (effect) => effect
 
     return (effect) =>
       Effect.gen(function* () {
@@ -122,7 +99,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
 export const authorizationLayer = Layer.effect(
   Authorization,
   Effect.gen(function* () {
-    const config = yield* ServerAuthConfig
+    const config = yield* ServerAuth.Config
     return Authorization.of({
       basic: (effect, { credential }) => validateCredential(effect, credential, config),
       authToken: (effect, { credential }) =>

+ 5 - 4
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -46,8 +46,9 @@ import { Worktree } from "@/worktree"
 import { Workspace } from "@/control-plane/workspace"
 import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
 import { serveUIEffect } from "@/server/shared/ui"
+import { ServerAuth } from "@/server/auth"
 import { InstanceHttpApi, RootHttpApi } from "./api"
-import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
+import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
 import { EventApi, eventHandlers } from "./event"
 import { configHandlers } from "./handlers/config"
 import { controlHandlers } from "./handlers/control"
@@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont
 const instanceRouterLayer = authorizationRouterMiddleware
   .combine(instanceRouterMiddleware)
   .combine(workspaceRouterMiddleware)
-  .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer))
+  .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
 const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
   Layer.provide(eventHandlers),
   Layer.provide(instanceRouterLayer),
@@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
 const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
 const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
   Layer.provide([
-    authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
+    authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)),
     workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
     instanceContextLayer,
   ]),
@@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) =>
     const client = yield* HttpClient.HttpClient
     yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
   }),
-).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
+).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))))
 
 export function createRoutes(corsOptions?: CorsOptions) {
   return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(

+ 59 - 0
packages/opencode/test/server/auth.test.ts

@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Option, Redacted } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { ServerAuth } from "../../src/server/auth"
+
+const original = {
+  OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
+  OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
+}
+
+afterEach(() => {
+  Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
+  Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
+})
+
+describe("ServerAuth", () => {
+  test("does not emit auth headers without a password", () => {
+    Flag.OPENCODE_SERVER_PASSWORD = undefined
+    Flag.OPENCODE_SERVER_USERNAME = "alice"
+
+    expect(ServerAuth.header()).toBeUndefined()
+    expect(ServerAuth.headers()).toBeUndefined()
+  })
+
+  test("defaults to the opencode username", () => {
+    Flag.OPENCODE_SERVER_PASSWORD = "secret"
+    Flag.OPENCODE_SERVER_USERNAME = undefined
+
+    expect(ServerAuth.headers()).toEqual({
+      Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
+    })
+  })
+
+  test("uses the configured username", () => {
+    Flag.OPENCODE_SERVER_PASSWORD = "secret"
+    Flag.OPENCODE_SERVER_USERNAME = "alice"
+
+    expect(ServerAuth.headers()).toEqual({
+      Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
+    })
+  })
+
+  test("prefers explicit credentials", () => {
+    Flag.OPENCODE_SERVER_PASSWORD = "secret"
+    Flag.OPENCODE_SERVER_USERNAME = "alice"
+
+    expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
+      Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
+    })
+  })
+
+  test("validates decoded credentials against effect config", () => {
+    const config = { password: Option.some("secret"), username: "alice" }
+
+    expect(ServerAuth.required(config)).toBe(true)
+    expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
+    expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
+  })
+})

+ 5 - 8
packages/opencode/test/server/httpapi-authorization.test.ts

@@ -3,11 +3,8 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
-import {
-  Authorization,
-  ServerAuthConfig,
-  authorizationLayer,
-} from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import { ServerAuth } from "../../src/server/auth"
+import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
 import { testEffect } from "../lib/effect"
 
 const Api = HttpApi.make("test-authorization").add(
@@ -27,9 +24,9 @@ const apiLayer = HttpRouter.serve(
   { disableListenLog: true, disableLogger: true },
 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
 
-const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
-const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
-const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
+const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
+const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
+const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
 
 const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
 const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))

+ 3 - 5
packages/opencode/test/server/httpapi-ui.test.ts

@@ -12,10 +12,8 @@ import {
   HttpServerResponse,
 } from "effect/unstable/http"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import {
-  ServerAuthConfig,
-  authorizationRouterMiddleware,
-} from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import { ServerAuth } from "../../src/server/auth"
+import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { serveUIEffect } from "../../src/server/shared/ui"
 import { Server } from "../../src/server/server"
@@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
         yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
       }),
     ).pipe(
-      Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
+      Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
       Layer.provide([
         AppFileSystem.defaultLayer,
         input?.client ?? httpClient(new Response("ui")),