mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
Fix HttpApi raw route authorization (#25154)
This commit is contained in:
parent
0e9d9282c6
commit
3250b814ce
5 changed files with 176 additions and 14 deletions
|
|
@ -13,6 +13,9 @@ import { Effect, Scope } from "effect"
|
|||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { HistoryPayload, ReplayPayload } from "../groups/sync"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.sync" })
|
||||
|
||||
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -34,8 +37,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
|||
type: event.type,
|
||||
data: { ...event.data },
|
||||
}))
|
||||
const source = events[0].aggregateID
|
||||
log.info("sync replay requested", {
|
||||
sessionID: source,
|
||||
events: events.length,
|
||||
first: events[0]?.seq,
|
||||
last: events.at(-1)?.seq,
|
||||
directory: ctx.payload.directory,
|
||||
})
|
||||
SyncEvent.replayAll(events)
|
||||
return { sessionID: events[0].aggregateID }
|
||||
log.info("sync replay complete", {
|
||||
sessionID: source,
|
||||
events: events.length,
|
||||
first: events[0]?.seq,
|
||||
last: events.at(-1)?.seq,
|
||||
})
|
||||
return { sessionID: source }
|
||||
})
|
||||
|
||||
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
|
||||
},
|
||||
},
|
||||
) {}
|
||||
|
|
@ -27,18 +31,27 @@ function validateCredential<A, E, R>(
|
|||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
|
||||
|
||||
if (credential.username !== config.username) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
if (Redacted.value(credential.password) !== config.password.value) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
if (!isAuthRequired(config)) return yield* effect
|
||||
if (!isCredentialAuthorized(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: "",
|
||||
|
|
@ -62,6 +75,44 @@ 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>,
|
||||
) {
|
||||
if (!isAuthRequired(config)) return effect
|
||||
if (!isCredentialAuthorized(credential, config))
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
|
||||
return effect
|
||||
}
|
||||
|
||||
export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuthConfig
|
||||
if (!isAuthRequired(config)) return (effect) => effect
|
||||
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) {
|
||||
return yield* decodeCredential(match[1]).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) {
|
||||
return yield* decodeCredential(token).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import { Worktree } from "@/worktree"
|
|||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { isAllowedCorsOrigin } from "@/server/cors"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
|
||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { eventRoute } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
|
|
@ -104,9 +104,10 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
|||
|
||||
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
|
||||
Layer.provide(
|
||||
instanceRouterMiddleware
|
||||
authorizationRouterMiddleware
|
||||
.combine(instanceRouterMiddleware)
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)),
|
||||
),
|
||||
)
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
|
|
|
|||
89
packages/opencode/test/server/httpapi-raw-route-auth.test.ts
Normal file
89
packages/opencode/test/server/httpapi-raw-route-auth.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app(input: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input.password,
|
||||
OPENCODE_SERVER_USERNAME: input.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi raw route authorization", () => {
|
||||
test("requires configured auth before opening the raw instance event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
const missing = await server.request(EventPaths.event, { headers })
|
||||
await cancelBody(missing)
|
||||
expect(missing.status).toBe(401)
|
||||
|
||||
const authed = await server.request(EventPaths.event, {
|
||||
headers: { ...headers, authorization: basic("opencode", "secret") },
|
||||
})
|
||||
await cancelBody(authed)
|
||||
expect(authed.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires configured auth before resolving the raw PTY websocket route", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
const missing = await server.request(route, { headers })
|
||||
await cancelBody(missing)
|
||||
expect(missing.status).toBe(401)
|
||||
|
||||
const authed = await server.request(route, {
|
||||
headers: { ...headers, authorization: basic("opencode", "secret") },
|
||||
})
|
||||
await cancelBody(authed)
|
||||
expect(authed.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
|
@ -24,6 +24,7 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
|||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await Instance.disposeAll()
|
||||
|
|
@ -35,6 +36,7 @@ describe("sync HttpApi", () => {
|
|||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
|
||||
const session = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
|
|
@ -78,6 +80,8 @@ describe("sync HttpApi", () => {
|
|||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches legacy seq validation", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue