Skip to content

Commit ff2e1a6

Browse files
Zexiclaude
authored andcommitted
fix: serve SPA subpaths without auth block, set cookie via direct response
Replace appendPreResponseHandler (which does not fire reliably in the raw router middleware context) with direct HttpServerResponse manipulation for setting the oc_auth_token cookie when a valid auth_token URL param is present. Add uiRouterMiddleware for the SPA catch-all route (/*): always serves the app shell so the browser can load the SPA at any subpath without a 401 prompt. API routes retain their own authorizationLayer. The SPA handles auth internally and makes authenticated API calls using the stored credentials or cookie. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3f3f606 commit ff2e1a6

2 files changed

Lines changed: 48 additions & 29 deletions

File tree

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

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,19 @@ function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerReques
8080
return Effect.succeed(emptyCredential())
8181
}
8282

83-
function validateRawCredential<A, E, R>(
84-
effect: Effect.Effect<A, E, R>,
85-
credential: ServerAuth.DecodedCredentials,
86-
config: ServerAuth.Info,
87-
) {
88-
if (!ServerAuth.required(config)) return effect
89-
if (!ServerAuth.authorized(credential, config))
90-
return Effect.succeed(
91-
HttpServerResponse.empty({
92-
status: UNAUTHORIZED,
93-
headers: { "www-authenticate": WWW_AUTHENTICATE },
94-
}),
95-
)
96-
return effect
83+
function setCookieHeader(
84+
response: HttpServerResponse.HttpServerResponse,
85+
token: string,
86+
): HttpServerResponse.HttpServerResponse {
87+
return HttpServerResponse.setHeader(
88+
response,
89+
"set-cookie",
90+
`${AUTH_TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`,
91+
)
9792
}
9893

94+
// Router middleware for all routes except the SPA catch-all. Requires auth for
95+
// non-public paths and sets a persistent cookie when auth_token is in the URL.
9996
export const authorizationRouterMiddleware = HttpRouter.middleware()(
10097
Effect.gen(function* () {
10198
const config = yield* ServerAuth.Config
@@ -109,20 +106,41 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
109106
if (hasPtyConnectTicketURL(url)) return yield* effect
110107
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
111108
const credential = yield* credentialFromURL(url, request)
112-
// When auth_token comes via URL and is valid, set a persistent cookie so the
113-
// browser can navigate to SPA subpaths without repeating the query param.
114-
if (token && ServerAuth.authorized(credential, config)) {
115-
yield* HttpEffect.appendPreResponseHandler((_req, response) =>
116-
Effect.succeed(
117-
HttpServerResponse.setHeader(
118-
response,
119-
"set-cookie",
120-
`${AUTH_TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`,
121-
),
122-
),
123-
)
109+
if (!ServerAuth.authorized(credential, config)) {
110+
return HttpServerResponse.empty({
111+
status: UNAUTHORIZED,
112+
headers: { "www-authenticate": WWW_AUTHENTICATE },
113+
})
124114
}
125-
return yield* validateRawCredential(effect, credential, config)
115+
// Auth passed — get the response and attach cookie if token came via URL.
116+
// Direct header manipulation avoids appendPreResponseHandler which does not
117+
// fire reliably in the raw router middleware context.
118+
const response = yield* (effect as unknown as Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>)
119+
return token ? setCookieHeader(response, token) : response
120+
})
121+
}),
122+
)
123+
124+
// Router middleware for the SPA catch-all route (/*). Always serves content so
125+
// the browser can load the app shell at any subpath without a credential prompt.
126+
// API routes have their own auth layer; the SPA handles auth internally once loaded.
127+
// Sets a persistent cookie when a valid auth_token query param is supplied so that
128+
// subsequent SPA navigation (without the query param) remains authenticated.
129+
export const uiRouterMiddleware = HttpRouter.middleware()(
130+
Effect.gen(function* () {
131+
const config = yield* ServerAuth.Config
132+
if (!ServerAuth.required(config)) return (effect) => effect
133+
134+
return (effect) =>
135+
Effect.gen(function* () {
136+
const request = yield* HttpServerRequest.HttpServerRequest
137+
const url = new URL(request.url, "http://localhost")
138+
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
139+
// Always serve — the SPA handles auth internally via stored credentials.
140+
const response = yield* (effect as unknown as Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>)
141+
if (!token) return response
142+
const credential = yield* credentialFromURL(url, request)
143+
return ServerAuth.authorized(credential, config) ? setCookieHeader(response, token) : response
126144
})
127145
}),
128146
)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { serveUIEffect } from "@/server/shared/ui"
5757
import { ServerAuth } from "@/server/auth"
5858
import { InstanceHttpApi, RootHttpApi } from "./api"
5959
import { PublicApi } from "./public"
60-
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
60+
import { authorizationLayer, authorizationRouterMiddleware, uiRouterMiddleware } from "./middleware/authorization"
6161
import { EventApi, eventHandlers } from "./event"
6262
import { configHandlers } from "./handlers/config"
6363
import { controlHandlers } from "./handlers/control"
@@ -112,6 +112,7 @@ const cors = (corsOptions?: CorsOptions) =>
112112
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
113113
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
114114
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
115+
const uiAuthLayer = uiRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
115116
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
116117
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
117118
Layer.provide([controlHandlers, globalHandlers]),
@@ -173,7 +174,7 @@ const uiRoute = HttpRouter.use((router) =>
173174
const client = yield* HttpClient.HttpClient
174175
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
175176
}),
176-
).pipe(Layer.provide(authOnlyRouterLayer))
177+
).pipe(Layer.provide(uiAuthLayer))
177178

178179
export function createRoutes(corsOptions?: CorsOptions) {
179180
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(

0 commit comments

Comments
 (0)