fix(auth): respect server username in clients (#25596)
This commit is contained in:
@@ -4,9 +4,9 @@ import { effectCmd } from "../effect-cmd"
|
|||||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||||
import { ACP } from "@/acp/agent"
|
import { ACP } from "@/acp/agent"
|
||||||
import { Server } from "@/server/server"
|
import { Server } from "@/server/server"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "acp-command" })
|
const log = Log.create({ service: "acp-command" })
|
||||||
|
|
||||||
@@ -27,13 +27,7 @@ export const AcpCommand = effectCmd({
|
|||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
headers: ServerAuth.headers(),
|
||||||
? {
|
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`,
|
|
||||||
).toString("base64")}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const input = new WritableStream<Uint8Array>({
|
const input = new WritableStream<Uint8Array>({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
@@ -656,13 +657,7 @@ export const RunCommand = effectCmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.attach) {
|
if (args.attach) {
|
||||||
const headers = (() => {
|
const headers = ServerAuth.headers({ password: args.password })
|
||||||
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 sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||||
return await execute(sdk)
|
return await execute(sdk)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
|||||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { validateSession } from "./validate-session"
|
import { validateSession } from "./validate-session"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
|
|
||||||
export const AttachCommand = cmd({
|
export const AttachCommand = cmd({
|
||||||
command: "attach <url>",
|
command: "attach <url>",
|
||||||
@@ -38,6 +39,11 @@ export const AttachCommand = cmd({
|
|||||||
alias: ["p"],
|
alias: ["p"],
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
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) => {
|
handler: async (args) => {
|
||||||
const unguard = win32InstallCtrlCGuard()
|
const unguard = win32InstallCtrlCGuard()
|
||||||
@@ -60,12 +66,7 @@ export const AttachCommand = cmd({
|
|||||||
return args.dir
|
return args.dir
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
const headers = (() => {
|
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||||
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 config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc"
|
|||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { writeHeapSnapshot } from "node:v8"
|
import { writeHeapSnapshot } from "node:v8"
|
||||||
import { Heap } from "@/cli/heap"
|
import { Heap } from "@/cli/heap"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
@@ -50,7 +50,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
|||||||
export const rpc = {
|
export const rpc = {
|
||||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||||
const headers = { ...input.headers }
|
const headers = { ...input.headers }
|
||||||
const auth = getAuthorizationHeader()
|
const auth = ServerAuth.header()
|
||||||
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
||||||
headers["Authorization"] = auth
|
headers["Authorization"] = auth
|
||||||
}
|
}
|
||||||
@@ -102,10 +102,3 @@ export const rpc = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rpc.listen(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}`)}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Bus } from "../bus"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
@@ -124,11 +125,7 @@ export const layer = Layer.effect(
|
|||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: "http://localhost:4096",
|
baseUrl: "http://localhost:4096",
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
headers: ServerAuth.headers(),
|
||||||
? {
|
|
||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||||
})
|
})
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ConfigService } from "@/effect/config-service"
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
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>(
|
function validateCredential<A, E, R>(
|
||||||
effect: Effect.Effect<A, E, R>,
|
effect: Effect.Effect<A, E, R>,
|
||||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
credential: ServerAuth.DecodedCredentials,
|
||||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
config: ServerAuth.Info,
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (!isAuthRequired(config)) return yield* effect
|
if (!ServerAuth.required(config)) return yield* effect
|
||||||
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
|
if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
|
||||||
return yield* effect
|
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) {
|
function decodeCredential(input: string) {
|
||||||
const emptyCredential = {
|
const emptyCredential = {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -78,11 +55,11 @@ function decodeCredential(input: string) {
|
|||||||
|
|
||||||
function validateRawCredential<A, E, R>(
|
function validateRawCredential<A, E, R>(
|
||||||
effect: Effect.Effect<A, E, R>,
|
effect: Effect.Effect<A, E, R>,
|
||||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
credential: ServerAuth.DecodedCredentials,
|
||||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
config: ServerAuth.Info,
|
||||||
) {
|
) {
|
||||||
if (!isAuthRequired(config)) return effect
|
if (!ServerAuth.required(config)) return effect
|
||||||
if (!isCredentialAuthorized(credential, config))
|
if (!ServerAuth.authorized(credential, config))
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpServerResponse.empty({
|
HttpServerResponse.empty({
|
||||||
status: UNAUTHORIZED,
|
status: UNAUTHORIZED,
|
||||||
@@ -94,8 +71,8 @@ function validateRawCredential<A, E, R>(
|
|||||||
|
|
||||||
export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* ServerAuthConfig
|
const config = yield* ServerAuth.Config
|
||||||
if (!isAuthRequired(config)) return (effect) => effect
|
if (!ServerAuth.required(config)) return (effect) => effect
|
||||||
|
|
||||||
return (effect) =>
|
return (effect) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -122,7 +99,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
|||||||
export const authorizationLayer = Layer.effect(
|
export const authorizationLayer = Layer.effect(
|
||||||
Authorization,
|
Authorization,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* ServerAuthConfig
|
const config = yield* ServerAuth.Config
|
||||||
return Authorization.of({
|
return Authorization.of({
|
||||||
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
||||||
authToken: (effect, { credential }) =>
|
authToken: (effect, { credential }) =>
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ import { Worktree } from "@/worktree"
|
|||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||||
import { serveUIEffect } from "@/server/shared/ui"
|
import { serveUIEffect } from "@/server/shared/ui"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||||
import { EventApi, eventHandlers } from "./event"
|
import { EventApi, eventHandlers } from "./event"
|
||||||
import { configHandlers } from "./handlers/config"
|
import { configHandlers } from "./handlers/config"
|
||||||
import { controlHandlers } from "./handlers/control"
|
import { controlHandlers } from "./handlers/control"
|
||||||
@@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont
|
|||||||
const instanceRouterLayer = authorizationRouterMiddleware
|
const instanceRouterLayer = authorizationRouterMiddleware
|
||||||
.combine(instanceRouterMiddleware)
|
.combine(instanceRouterMiddleware)
|
||||||
.combine(workspaceRouterMiddleware)
|
.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(
|
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
|
||||||
Layer.provide(eventHandlers),
|
Layer.provide(eventHandlers),
|
||||||
Layer.provide(instanceRouterLayer),
|
Layer.provide(instanceRouterLayer),
|
||||||
@@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
|||||||
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||||
Layer.provide([
|
Layer.provide([
|
||||||
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
|
authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)),
|
||||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||||
instanceContextLayer,
|
instanceContextLayer,
|
||||||
]),
|
]),
|
||||||
@@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) =>
|
|||||||
const client = yield* HttpClient.HttpClient
|
const client = yield* HttpClient.HttpClient
|
||||||
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
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) {
|
export function createRoutes(corsOptions?: CorsOptions) {
|
||||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,11 +3,8 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect, Layer, Option, Schema } from "effect"
|
import { Effect, Layer, Option, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||||
import {
|
import { ServerAuth } from "../../src/server/auth"
|
||||||
Authorization,
|
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||||
ServerAuthConfig,
|
|
||||||
authorizationLayer,
|
|
||||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const Api = HttpApi.make("test-authorization").add(
|
const Api = HttpApi.make("test-authorization").add(
|
||||||
@@ -27,9 +24,9 @@ const apiLayer = HttpRouter.serve(
|
|||||||
{ disableListenLog: true, disableLogger: true },
|
{ disableListenLog: true, disableLogger: true },
|
||||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||||
|
|
||||||
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
|
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
|
||||||
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
|
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
|
||||||
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
|
||||||
|
|
||||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
HttpServerResponse,
|
HttpServerResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import {
|
import { ServerAuth } from "../../src/server/auth"
|
||||||
ServerAuthConfig,
|
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||||
authorizationRouterMiddleware,
|
|
||||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
import { serveUIEffect } from "../../src/server/shared/ui"
|
import { serveUIEffect } from "../../src/server/shared/ui"
|
||||||
import { Server } from "../../src/server/server"
|
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 }))
|
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
|
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||||
Layer.provide([
|
Layer.provide([
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
input?.client ?? httpClient(new Response("ui")),
|
input?.client ?? httpClient(new Response("ui")),
|
||||||
|
|||||||
Reference in New Issue
Block a user