Skip to content

Commit 8694c5b

Browse files
authored
fix(auth): respect server username in clients (#25596)
1 parent 0a7d02c commit 8694c5b

11 files changed

Lines changed: 148 additions & 88 deletions

File tree

packages/opencode/src/cli/cmd/acp.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { effectCmd } from "../effect-cmd"
44
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
55
import { ACP } from "@/acp/agent"
66
import { Server } from "@/server/server"
7+
import { ServerAuth } from "@/server/auth"
78
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
89
import { withNetworkOptions, resolveNetworkOptions } from "../network"
9-
import { Flag } from "@opencode-ai/core/flag/flag"
1010

1111
const log = Log.create({ service: "acp-command" })
1212

@@ -27,13 +27,7 @@ export const AcpCommand = effectCmd({
2727

2828
const sdk = createOpencodeClient({
2929
baseUrl: `http://${server.hostname}:${server.port}`,
30-
headers: Flag.OPENCODE_SERVER_PASSWORD
31-
? {
32-
Authorization: `Basic ${Buffer.from(
33-
`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`,
34-
).toString("base64")}`,
35-
}
36-
: undefined,
30+
headers: ServerAuth.headers(),
3731
})
3832

3933
const input = new WritableStream<Uint8Array>({

packages/opencode/src/cli/cmd/run.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Effect } from "effect"
55
import { UI } from "../ui"
66
import { effectCmd } from "../effect-cmd"
77
import { Flag } from "@opencode-ai/core/flag/flag"
8+
import { ServerAuth } from "@/server/auth"
89
import { EOL } from "os"
910
import { Filesystem } from "@/util/filesystem"
1011
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
@@ -656,13 +657,7 @@ export const RunCommand = effectCmd({
656657
}
657658

658659
if (args.attach) {
659-
const headers = (() => {
660-
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
661-
if (!password) return undefined
662-
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
663-
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
664-
return { Authorization: auth }
665-
})()
660+
const headers = ServerAuth.headers({ password: args.password })
666661
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
667662
return await execute(sdk)
668663
}

packages/opencode/src/cli/cmd/tui/attach.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
55
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
66
import { errorMessage } from "@/util/error"
77
import { validateSession } from "./validate-session"
8+
import { ServerAuth } from "@/server/auth"
89

910
export const AttachCommand = cmd({
1011
command: "attach <url>",
@@ -38,6 +39,11 @@ export const AttachCommand = cmd({
3839
alias: ["p"],
3940
type: "string",
4041
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
42+
})
43+
.option("username", {
44+
alias: ["u"],
45+
type: "string",
46+
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
4147
}),
4248
handler: async (args) => {
4349
const unguard = win32InstallCtrlCGuard()
@@ -60,12 +66,7 @@ export const AttachCommand = cmd({
6066
return args.dir
6167
}
6268
})()
63-
const headers = (() => {
64-
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
65-
if (!password) return undefined
66-
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
67-
return { Authorization: auth }
68-
})()
69+
const headers = ServerAuth.headers({ password: args.password, username: args.username })
6970
const config = await TuiConfig.get()
7071

7172
try {

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc"
77
import { upgrade } from "@/cli/upgrade"
88
import { Config } from "@/config/config"
99
import { GlobalBus } from "@/bus/global"
10-
import { Flag } from "@opencode-ai/core/flag/flag"
10+
import { ServerAuth } from "@/server/auth"
1111
import { writeHeapSnapshot } from "node:v8"
1212
import { Heap } from "@/cli/heap"
1313
import { AppRuntime } from "@/effect/app-runtime"
@@ -50,7 +50,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
5050
export const rpc = {
5151
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
5252
const headers = { ...input.headers }
53-
const auth = getAuthorizationHeader()
53+
const auth = ServerAuth.header()
5454
if (auth && !headers["authorization"] && !headers["Authorization"]) {
5555
headers["Authorization"] = auth
5656
}
@@ -102,10 +102,3 @@ export const rpc = {
102102
}
103103

104104
Rpc.listen(rpc)
105-
106-
function getAuthorizationHeader(): string | undefined {
107-
const password = Flag.OPENCODE_SERVER_PASSWORD
108-
if (!password) return undefined
109-
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
110-
return `Basic ${btoa(`${username}:${password}`)}`
111-
}

packages/opencode/src/plugin/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Bus } from "../bus"
1010
import * as Log from "@opencode-ai/core/util/log"
1111
import { createOpencodeClient } from "@opencode-ai/sdk"
1212
import { Flag } from "@opencode-ai/core/flag/flag"
13+
import { ServerAuth } from "@/server/auth"
1314
import { CodexAuthPlugin } from "./codex"
1415
import { Session } from "@/session/session"
1516
import { NamedError } from "@opencode-ai/core/util/error"
@@ -124,11 +125,7 @@ export const layer = Layer.effect(
124125
const client = createOpencodeClient({
125126
baseUrl: "http://localhost:4096",
126127
directory: ctx.directory,
127-
headers: Flag.OPENCODE_SERVER_PASSWORD
128-
? {
129-
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
130-
}
131-
: undefined,
128+
headers: ServerAuth.headers(),
132129
fetch: async (...args) => Server.Default().app.fetch(...args),
133130
})
134131
const cfg = yield* config.get()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export * as ServerAuth from "./auth"
2+
3+
import { ConfigService } from "@/effect/config-service"
4+
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
6+
7+
export type Credentials = {
8+
password?: string
9+
username?: string
10+
}
11+
12+
export type DecodedCredentials = {
13+
readonly username: string
14+
readonly password: Redacted.Redacted
15+
}
16+
17+
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
18+
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
19+
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
20+
}) {}
21+
22+
export type Info = Context.Service.Shape<typeof Config>
23+
24+
export function required(config: Info) {
25+
return Option.isSome(config.password) && config.password.value !== ""
26+
}
27+
28+
export function authorized(credentials: DecodedCredentials, config: Info) {
29+
return (
30+
Option.isSome(config.password) &&
31+
credentials.username === config.username &&
32+
Redacted.value(credentials.password) === config.password.value
33+
)
34+
}
35+
36+
export function header(credentials?: Credentials) {
37+
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
38+
if (!password) return undefined
39+
40+
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
41+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
42+
}
43+
44+
export function headers(credentials?: Credentials) {
45+
const authorization = header(credentials)
46+
if (!authorization) return undefined
47+
return { Authorization: authorization }
48+
}

packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ConfigService } from "@/effect/config-service"
2-
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
1+
import { ServerAuth } from "@/server/auth"
2+
import { Effect, Encoding, Layer, Redacted } from "effect"
33
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
44
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
55

@@ -18,41 +18,18 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
1818
},
1919
) {}
2020

21-
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
22-
"@opencode/ExperimentalHttpApiServerAuthConfig",
23-
{
24-
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
25-
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
26-
},
27-
) {}
28-
2921
function validateCredential<A, E, R>(
3022
effect: Effect.Effect<A, E, R>,
31-
credential: { readonly username: string; readonly password: Redacted.Redacted },
32-
config: Context.Service.Shape<typeof ServerAuthConfig>,
23+
credential: ServerAuth.DecodedCredentials,
24+
config: ServerAuth.Info,
3325
) {
3426
return Effect.gen(function* () {
35-
if (!isAuthRequired(config)) return yield* effect
36-
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
27+
if (!ServerAuth.required(config)) return yield* effect
28+
if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
3729
return yield* effect
3830
})
3931
}
4032

41-
function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
42-
return Option.isSome(config.password) && config.password.value !== ""
43-
}
44-
45-
function isCredentialAuthorized(
46-
credential: { readonly username: string; readonly password: Redacted.Redacted },
47-
config: Context.Service.Shape<typeof ServerAuthConfig>,
48-
) {
49-
return (
50-
Option.isSome(config.password) &&
51-
credential.username === config.username &&
52-
Redacted.value(credential.password) === config.password.value
53-
)
54-
}
55-
5633
function decodeCredential(input: string) {
5734
const emptyCredential = {
5835
username: "",
@@ -78,11 +55,11 @@ function decodeCredential(input: string) {
7855

7956
function validateRawCredential<A, E, R>(
8057
effect: Effect.Effect<A, E, R>,
81-
credential: { readonly username: string; readonly password: Redacted.Redacted },
82-
config: Context.Service.Shape<typeof ServerAuthConfig>,
58+
credential: ServerAuth.DecodedCredentials,
59+
config: ServerAuth.Info,
8360
) {
84-
if (!isAuthRequired(config)) return effect
85-
if (!isCredentialAuthorized(credential, config))
61+
if (!ServerAuth.required(config)) return effect
62+
if (!ServerAuth.authorized(credential, config))
8663
return Effect.succeed(
8764
HttpServerResponse.empty({
8865
status: UNAUTHORIZED,
@@ -94,8 +71,8 @@ function validateRawCredential<A, E, R>(
9471

9572
export const authorizationRouterMiddleware = HttpRouter.middleware()(
9673
Effect.gen(function* () {
97-
const config = yield* ServerAuthConfig
98-
if (!isAuthRequired(config)) return (effect) => effect
74+
const config = yield* ServerAuth.Config
75+
if (!ServerAuth.required(config)) return (effect) => effect
9976

10077
return (effect) =>
10178
Effect.gen(function* () {
@@ -122,7 +99,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
12299
export const authorizationLayer = Layer.effect(
123100
Authorization,
124101
Effect.gen(function* () {
125-
const config = yield* ServerAuthConfig
102+
const config = yield* ServerAuth.Config
126103
return Authorization.of({
127104
basic: (effect, { credential }) => validateCredential(effect, credential, config),
128105
authToken: (effect, { credential }) =>

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ import { Worktree } from "@/worktree"
4646
import { Workspace } from "@/control-plane/workspace"
4747
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
4848
import { serveUIEffect } from "@/server/shared/ui"
49+
import { ServerAuth } from "@/server/auth"
4950
import { InstanceHttpApi, RootHttpApi } from "./api"
50-
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
51+
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
5152
import { EventApi, eventHandlers } from "./event"
5253
import { configHandlers } from "./handlers/config"
5354
import { controlHandlers } from "./handlers/control"
@@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont
9798
const instanceRouterLayer = authorizationRouterMiddleware
9899
.combine(instanceRouterMiddleware)
99100
.combine(workspaceRouterMiddleware)
100-
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer))
101+
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
101102
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
102103
Layer.provide(eventHandlers),
103104
Layer.provide(instanceRouterLayer),
@@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
125126
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
126127
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
127128
Layer.provide([
128-
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
129+
authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)),
129130
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
130131
instanceContextLayer,
131132
]),
@@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) =>
137138
const client = yield* HttpClient.HttpClient
138139
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
139140
}),
140-
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
141+
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))))
141142

142143
export function createRoutes(corsOptions?: CorsOptions) {
143144
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Option, Redacted } from "effect"
3+
import { Flag } from "@opencode-ai/core/flag/flag"
4+
import { ServerAuth } from "../../src/server/auth"
5+
6+
const original = {
7+
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
8+
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
9+
}
10+
11+
afterEach(() => {
12+
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
13+
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
14+
})
15+
16+
describe("ServerAuth", () => {
17+
test("does not emit auth headers without a password", () => {
18+
Flag.OPENCODE_SERVER_PASSWORD = undefined
19+
Flag.OPENCODE_SERVER_USERNAME = "alice"
20+
21+
expect(ServerAuth.header()).toBeUndefined()
22+
expect(ServerAuth.headers()).toBeUndefined()
23+
})
24+
25+
test("defaults to the opencode username", () => {
26+
Flag.OPENCODE_SERVER_PASSWORD = "secret"
27+
Flag.OPENCODE_SERVER_USERNAME = undefined
28+
29+
expect(ServerAuth.headers()).toEqual({
30+
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
31+
})
32+
})
33+
34+
test("uses the configured username", () => {
35+
Flag.OPENCODE_SERVER_PASSWORD = "secret"
36+
Flag.OPENCODE_SERVER_USERNAME = "alice"
37+
38+
expect(ServerAuth.headers()).toEqual({
39+
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
40+
})
41+
})
42+
43+
test("prefers explicit credentials", () => {
44+
Flag.OPENCODE_SERVER_PASSWORD = "secret"
45+
Flag.OPENCODE_SERVER_USERNAME = "alice"
46+
47+
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
48+
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
49+
})
50+
})
51+
52+
test("validates decoded credentials against effect config", () => {
53+
const config = { password: Option.some("secret"), username: "alice" }
54+
55+
expect(ServerAuth.required(config)).toBe(true)
56+
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
57+
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
58+
})
59+
})

packages/opencode/test/server/httpapi-authorization.test.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import { describe, expect } from "bun:test"
33
import { Effect, Layer, Option, Schema } from "effect"
44
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
55
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
6-
import {
7-
Authorization,
8-
ServerAuthConfig,
9-
authorizationLayer,
10-
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
6+
import { ServerAuth } from "../../src/server/auth"
7+
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
118
import { testEffect } from "../lib/effect"
129

1310
const Api = HttpApi.make("test-authorization").add(
@@ -27,9 +24,9 @@ const apiLayer = HttpRouter.serve(
2724
{ disableListenLog: true, disableLogger: true },
2825
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
2926

30-
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
31-
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
32-
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
27+
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
28+
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
29+
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
3330

3431
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
3532
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))

0 commit comments

Comments
 (0)