Skip to content

Commit cee9610

Browse files
authored
refactor: use Effect config for HttpApi authorization (anomalyco#25035)
1 parent 38adc13 commit cee9610

6 files changed

Lines changed: 178 additions & 92 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Config, Context, Effect, Layer } from "effect"
2+
3+
type ConfigMap = Record<string, Config.Config<unknown>>
4+
5+
/**
6+
* The service shape inferred from an object of Effect `Config` definitions.
7+
*/
8+
export type Shape<Fields extends ConfigMap> = {
9+
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
10+
}
11+
12+
/**
13+
* A Context service class with generated layers for config-backed services.
14+
*/
15+
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
16+
/** Provide already-parsed config, useful in tests. */
17+
readonly layer: (input: Service) => Layer.Layer<Self>
18+
/** Parse config once from the active Effect ConfigProvider and provide the service. */
19+
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
20+
}
21+
22+
/**
23+
* Create a Context service whose implementation is derived from Effect `Config`.
24+
*
25+
* This keeps Effect `Config` as the source of truth for env names, defaults, and
26+
* validation while generating a typed service plus convenient production/test
27+
* layers.
28+
*
29+
* ```ts
30+
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
31+
* "@opencode/ServerAuthConfig",
32+
* {
33+
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
34+
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
35+
* },
36+
* ) {}
37+
*
38+
* const live = ServerAuthConfig.defaultLayer
39+
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
40+
* ```
41+
*/
42+
export const Service =
43+
<Self>() =>
44+
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
45+
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
46+
static layer(input: Shape<Fields>) {
47+
return Layer.succeed(this, this.of(input))
48+
}
49+
50+
static get defaultLayer() {
51+
return Layer.effect(
52+
this,
53+
Config.all(fields)
54+
.asEffect()
55+
.pipe(
56+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
57+
Effect.map((config) => this.of(config as Shape<Fields>)),
58+
),
59+
)
60+
}
61+
}
62+
63+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
64+
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
65+
}
66+
67+
export * as ConfigService from "./config-service"
Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,50 @@
1-
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
2-
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
3-
import { Flag } from "@opencode-ai/core/flag/flag"
4-
5-
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
6-
"Unauthorized",
7-
{ message: Schema.String },
8-
{ httpApiStatus: 401 },
9-
) {}
1+
import { ConfigService } from "@/effect/config-service"
2+
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
3+
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
104

115
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
126
"@opencode/ExperimentalHttpApiAuthorization",
137
{
14-
error: Unauthorized,
8+
error: HttpApiError.UnauthorizedNoContent,
159
security: {
1610
basic: HttpApiSecurity.basic,
1711
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
1812
},
1913
},
2014
) {}
2115

22-
const emptyCredential = {
23-
username: "",
24-
password: Redacted.make(""),
25-
}
16+
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
17+
"@opencode/ExperimentalHttpApiServerAuthConfig",
18+
{
19+
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
20+
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
21+
},
22+
) {}
2623

2724
function validateCredential<A, E, R>(
2825
effect: Effect.Effect<A, E, R>,
29-
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
26+
credential: { readonly username: string; readonly password: Redacted.Redacted },
27+
config: Context.Service.Shape<typeof ServerAuthConfig>,
3028
) {
3129
return Effect.gen(function* () {
32-
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
30+
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
3331

34-
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
35-
return yield* new Unauthorized({ message: "Unauthorized" })
32+
if (credential.username !== config.username) {
33+
return yield* new HttpApiError.Unauthorized({})
3634
}
37-
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
38-
return yield* new Unauthorized({ message: "Unauthorized" })
35+
if (Redacted.value(credential.password) !== config.password.value) {
36+
return yield* new HttpApiError.Unauthorized({})
3937
}
4038
return yield* effect
4139
})
4240
}
4341

4442
function decodeCredential(input: string) {
43+
const emptyCredential = {
44+
username: "",
45+
password: Redacted.make(""),
46+
}
47+
4548
return Encoding.decodeBase64String(input)
4649
.asEffect()
4750
.pipe(
@@ -59,13 +62,16 @@ function decodeCredential(input: string) {
5962
)
6063
}
6164

62-
export const authorizationLayer = Layer.succeed(
65+
export const authorizationLayer = Layer.effect(
6366
Authorization,
64-
Authorization.of({
65-
basic: (effect, { credential }) => validateCredential(effect, credential),
66-
authToken: (effect, { credential }) =>
67-
Effect.gen(function* () {
68-
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
69-
}),
67+
Effect.gen(function* () {
68+
const config = yield* ServerAuthConfig
69+
return Authorization.of({
70+
basic: (effect, { credential }) => validateCredential(effect, credential, config),
71+
authToken: (effect, { credential }) =>
72+
decodeCredential(Redacted.value(credential)).pipe(
73+
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
74+
),
75+
})
7076
}),
7177
)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy"
3232
import { Vcs } from "@/project/vcs"
3333
import { Worktree } from "@/worktree"
3434
import { InstanceHttpApi, RootHttpApi } from "./api"
35-
import { authorizationLayer } from "./middleware/authorization"
35+
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
3636
import { eventRoute } from "./event"
3737
import { configHandlers } from "./handlers/config"
3838
import { controlHandlers } from "./handlers/control"
@@ -56,7 +56,7 @@ import { disposeMiddleware } from "./lifecycle"
5656
import { memoMap } from "@opencode-ai/core/effect/memo-map"
5757
import * as ServerBackend from "@/server/backend"
5858

59-
export const context = Context.empty() as Context.Context<unknown>
59+
export const context = Context.makeUnsafe<unknown>(new Map())
6060

6161
const runtime = HttpRouter.middleware()(
6262
Effect.succeed((effect) =>
@@ -97,7 +97,7 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
9797
)
9898
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
9999
Layer.provide([
100-
authorizationLayer,
100+
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
101101
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
102102
instanceContextLayer,
103103
]),

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

Lines changed: 16 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { NodeHttpServer } from "@effect/platform-node"
2-
import { Flag } from "@opencode-ai/core/flag/flag"
32
import { describe, expect } from "bun:test"
4-
import { Effect, Layer, Schema } from "effect"
3+
import { Effect, Layer, Option, Schema } from "effect"
54
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
65
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
7-
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
6+
import {
7+
Authorization,
8+
ServerAuthConfig,
9+
authorizationLayer,
10+
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
811
import { testEffect } from "../lib/effect"
912

1013
const Api = HttpApi.make("test-authorization").add(
@@ -24,48 +27,19 @@ const apiLayer = HttpRouter.serve(
2427
{ disableListenLog: true, disableLogger: true },
2528
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
2629

27-
const testStateLayer = Layer.effectDiscard(
28-
Effect.gen(function* () {
29-
const original = {
30-
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
31-
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
32-
}
33-
Flag.OPENCODE_SERVER_PASSWORD = undefined
34-
Flag.OPENCODE_SERVER_USERNAME = undefined
35-
yield* Effect.addFinalizer(() =>
36-
Effect.sync(() => {
37-
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
38-
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
39-
}),
40-
)
41-
}),
42-
)
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" })
4333

44-
const it = testEffect(apiLayer.pipe(Layer.provideMerge(testStateLayer)))
34+
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
35+
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
36+
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
4537

4638
const basic = (username: string, password: string) =>
4739
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
4840

4941
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
5042

51-
const useAuth = (input: { password: string; username?: string }) =>
52-
Effect.acquireRelease(
53-
Effect.sync(() => {
54-
const original = {
55-
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
56-
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
57-
}
58-
Flag.OPENCODE_SERVER_PASSWORD = input.password
59-
Flag.OPENCODE_SERVER_USERNAME = input.username
60-
return original
61-
}),
62-
(original) =>
63-
Effect.sync(() => {
64-
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
65-
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
66-
}),
67-
)
68-
6943
const getProbe = (headers?: Record<string, string>) =>
7044
HttpClientRequest.get("/probe").pipe(
7145
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
@@ -82,10 +56,8 @@ describe("HttpApi authorization middleware", () => {
8256
}),
8357
)
8458

85-
it.live("requires configured password for basic auth", () =>
59+
itSecret.live("requires configured password for basic auth", () =>
8660
Effect.gen(function* () {
87-
yield* useAuth({ password: "secret" })
88-
8961
const [missing, badPassword, good] = yield* Effect.all(
9062
[
9163
getProbe(),
@@ -101,10 +73,8 @@ describe("HttpApi authorization middleware", () => {
10173
}),
10274
)
10375

104-
it.live("respects configured basic auth username", () =>
76+
itKitSecret.live("respects configured basic auth username", () =>
10577
Effect.gen(function* () {
106-
yield* useAuth({ username: "kit", password: "secret" })
107-
10878
const [defaultUser, configuredUser] = yield* Effect.all(
10979
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
11080
{ concurrency: "unbounded" },
@@ -115,20 +85,16 @@ describe("HttpApi authorization middleware", () => {
11585
}),
11686
)
11787

118-
it.live("accepts auth token query credentials", () =>
88+
itSecret.live("accepts auth token query credentials", () =>
11989
Effect.gen(function* () {
120-
yield* useAuth({ password: "secret" })
121-
12290
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
12391

12492
expect(response.status).toBe(200)
12593
}),
12694
)
12795

128-
it.live("rejects malformed auth token query credentials", () =>
96+
itSecret.live("rejects malformed auth token query credentials", () =>
12997
Effect.gen(function* () {
130-
yield* useAuth({ password: "secret" })
131-
13298
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
13399

134100
expect(response.status).toBe(401)

0 commit comments

Comments
 (0)