Fix HttpApi raw route authorization (#25154)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user