Refactor HttpApi auth middleware wiring (#24168)
This commit is contained in:
parent
0405bc74e9
commit
435becbea0
10 changed files with 102 additions and 80 deletions
66
packages/opencode/src/server/routes/instance/httpapi/auth.ts
Normal file
66
packages/opencode/src/server/routes/instance/httpapi/auth.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
|
||||||
|
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
|
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||||
|
"Unauthorized",
|
||||||
|
{ message: Schema.String },
|
||||||
|
{ httpApiStatus: 401 },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
|
||||||
|
error: Unauthorized,
|
||||||
|
security: {
|
||||||
|
basic: HttpApiSecurity.basic,
|
||||||
|
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
|
||||||
|
},
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const emptyCredential = {
|
||||||
|
username: "",
|
||||||
|
password: Redacted.make(""),
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCredential<A, E, R>(
|
||||||
|
effect: Effect.Effect<A, E, R>,
|
||||||
|
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
|
||||||
|
) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||||
|
|
||||||
|
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
|
||||||
|
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
||||||
|
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
return yield* effect
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCredential(input: string) {
|
||||||
|
return Encoding.decodeBase64String(input).asEffect().pipe(
|
||||||
|
Effect.match({
|
||||||
|
onFailure: () => emptyCredential,
|
||||||
|
onSuccess: (header) => {
|
||||||
|
const parts = header.split(":")
|
||||||
|
if (parts.length !== 2) return emptyCredential
|
||||||
|
return {
|
||||||
|
username: parts[0],
|
||||||
|
password: Redacted.make(parts[1]),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authorizationLayer = Layer.succeed(
|
||||||
|
Authorization,
|
||||||
|
Authorization.of({
|
||||||
|
basic: (effect, { credential }) => validateCredential(effect, credential),
|
||||||
|
authToken: (effect, { credential }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ import { Config } from "@/config"
|
||||||
import { Provider } from "@/provider"
|
import { Provider } from "@/provider"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/config"
|
const root = "/config"
|
||||||
|
|
||||||
|
|
@ -33,7 +34,8 @@ export const ConfigApi = HttpApi.make("config")
|
||||||
title: "config",
|
title: "config",
|
||||||
description: "Experimental HttpApi config routes.",
|
description: "Experimental HttpApi config routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const FileQuery = Schema.Struct({
|
const FileQuery = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
|
|
@ -51,7 +52,8 @@ export const FileApi = HttpApi.make("file")
|
||||||
title: "file",
|
title: "file",
|
||||||
description: "Experimental HttpApi file routes.",
|
description: "Experimental HttpApi file routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
export const McpPaths = {
|
export const McpPaths = {
|
||||||
status: "/mcp",
|
status: "/mcp",
|
||||||
|
|
@ -25,7 +26,8 @@ export const McpApi = HttpApi.make("mcp")
|
||||||
title: "mcp",
|
title: "mcp",
|
||||||
description: "Experimental HttpApi MCP routes.",
|
description: "Experimental HttpApi MCP routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/permission"
|
const root = "/permission"
|
||||||
|
|
||||||
|
|
@ -35,7 +36,8 @@ export const PermissionApi = HttpApi.make("permission")
|
||||||
title: "permission",
|
title: "permission",
|
||||||
description: "Experimental HttpApi permission routes.",
|
description: "Experimental HttpApi permission routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Instance } from "@/project/instance"
|
||||||
import { Project } from "@/project"
|
import { Project } from "@/project"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/project"
|
const root = "/project"
|
||||||
|
|
||||||
|
|
@ -33,7 +34,8 @@ export const ProjectApi = HttpApi.make("project")
|
||||||
title: "project",
|
title: "project",
|
||||||
description: "Experimental HttpApi project routes.",
|
description: "Experimental HttpApi project routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ProviderID } from "@/provider/schema"
|
||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/provider"
|
const root = "/provider"
|
||||||
|
|
||||||
|
|
@ -59,7 +60,8 @@ export const ProviderApi = HttpApi.make("provider")
|
||||||
title: "provider",
|
title: "provider",
|
||||||
description: "Experimental HttpApi provider routes.",
|
description: "Experimental HttpApi provider routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Question } from "@/question"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/question"
|
const root = "/question"
|
||||||
|
|
||||||
|
|
@ -45,7 +46,8 @@ export const QuestionApi = HttpApi.make("question")
|
||||||
title: "question",
|
title: "question",
|
||||||
description: "Question routes.",
|
description: "Question routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { Effect, Layer, Redacted, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||||
import { Observability } from "@/effect"
|
import { Observability } from "@/effect"
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
|
import { authorizationLayer } from "./auth"
|
||||||
import { ConfigApi, configHandlers } from "./config"
|
import { ConfigApi, configHandlers } from "./config"
|
||||||
import { FileApi, fileHandlers } from "./file"
|
import { FileApi, fileHandlers } from "./file"
|
||||||
import { McpApi, mcpHandlers } from "./mcp"
|
import { McpApi, mcpHandlers } from "./mcp"
|
||||||
|
|
@ -38,56 +38,6 @@ function decode(input: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
|
||||||
"Unauthorized",
|
|
||||||
{ message: Schema.String },
|
|
||||||
{ httpApiStatus: 401 },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
|
|
||||||
error: Unauthorized,
|
|
||||||
security: {
|
|
||||||
basic: HttpApiSecurity.basic,
|
|
||||||
},
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const normalize = HttpRouter.middleware()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
return (effect) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
|
||||||
if (!query.auth_token) return yield* effect
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const next = req.modify({
|
|
||||||
headers: {
|
|
||||||
...req.headers,
|
|
||||||
authorization: `Basic ${query.auth_token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
).layer
|
|
||||||
|
|
||||||
const auth = Layer.succeed(
|
|
||||||
Authorization,
|
|
||||||
Authorization.of({
|
|
||||||
basic: (effect, { credential }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
|
||||||
|
|
||||||
const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
||||||
if (credential.username !== user) {
|
|
||||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
|
||||||
}
|
|
||||||
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
|
||||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
|
||||||
}
|
|
||||||
return yield* effect
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const instance = HttpRouter.middleware()(
|
const instance = HttpRouter.middleware()(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (effect) =>
|
return (effect) =>
|
||||||
|
|
@ -110,27 +60,17 @@ const instance = HttpRouter.middleware()(
|
||||||
}),
|
}),
|
||||||
).layer
|
).layer
|
||||||
|
|
||||||
const QuestionSecured = QuestionApi.middleware(Authorization)
|
|
||||||
const PermissionSecured = PermissionApi.middleware(Authorization)
|
|
||||||
const ProjectSecured = ProjectApi.middleware(Authorization)
|
|
||||||
const ProviderSecured = ProviderApi.middleware(Authorization)
|
|
||||||
const ConfigSecured = ConfigApi.middleware(Authorization)
|
|
||||||
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
|
|
||||||
const FileSecured = FileApi.middleware(Authorization)
|
|
||||||
const McpSecured = McpApi.middleware(Authorization)
|
|
||||||
|
|
||||||
export const routes = Layer.mergeAll(
|
export const routes = Layer.mergeAll(
|
||||||
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
|
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
|
||||||
HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)),
|
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
|
||||||
HttpApiBuilder.layer(McpSecured).pipe(Layer.provide(mcpHandlers)),
|
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
|
||||||
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
|
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
|
||||||
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
|
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
||||||
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
|
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
||||||
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
|
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
||||||
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
|
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provide(auth),
|
Layer.provide(authorizationLayer),
|
||||||
Layer.provide(normalize),
|
|
||||||
Layer.provide(instance),
|
Layer.provide(instance),
|
||||||
Layer.provide(HttpServer.layerServices),
|
Layer.provide(HttpServer.layerServices),
|
||||||
Layer.provideMerge(Observability.layer),
|
Layer.provideMerge(Observability.layer),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/experimental/workspace"
|
const root = "/experimental/workspace"
|
||||||
export const WorkspacePaths = {
|
export const WorkspacePaths = {
|
||||||
|
|
@ -49,7 +50,8 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
title: "workspace",
|
title: "workspace",
|
||||||
description: "Experimental HttpApi workspace routes.",
|
description: "Experimental HttpApi workspace routes.",
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue