import { ServerAuth } from "../auth" import { UnauthorizedError } from "@opencode/protocol/errors" import { Authorization } from "@opencode/protocol/middleware/authorization" export { Authorization } from "@opencode/protocol/middleware/authorization" import { PTY_CONNECT_PATH, PTY_CONNECT_TICKET_QUERY } from "@opencode/protocol/groups/pty" import { PERSISTENT_PTY_CONNECT_PATH } from "@opencode/protocol/groups/persistent-pty" import { MCP_CLIENT_PATH } from "@opencode/protocol/groups/mcp" import { PAIRING_CONNECT_PATH } from "@opencode/protocol/groups/server" import { Effect, Layer, Redacted } from "effect" import { Base64 } from "effect/encoding" import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" const AUTH_TOKEN_QUERY = "auth_token" const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' function emptyCredential() { return { username: "", password: Redacted.make("") } } function decodeCredential(input: string) { return Effect.fromResult(Base64.decodeString(input)).pipe( Effect.match({ onFailure: emptyCredential, onSuccess: (header) => { const separator = header.indexOf(":") if (separator === -1) return emptyCredential() return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) } }, }), ) } function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { const url = new URL(request.url, "http://localhost") const token = url.searchParams.get(AUTH_TOKEN_QUERY) if (token) return decodeCredential(token) const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") if (match) return decodeCredential(match[1]) return Effect.succeed(emptyCredential()) } const UNAUTHORIZED_MESSAGE = "Authentication required" // Browsers show a native credentials prompt for a Basic challenge even on fetch, which would stall the web app // before it can show its own sign-in screen. Only non-browser clients and page navigations get the challenge. function challengeRequest(request: HttpServerRequest.HttpServerRequest) { const mode = request.headers["sec-fetch-mode"] return mode === undefined || mode === "navigate" } // Matches what the Authorization middleware encodes, for requests rejected before the HttpApi runs. export function unauthorizedResponse(request: HttpServerRequest.HttpServerRequest) { return HttpServerResponse.jsonUnsafe( { _tag: "UnauthorizedError", message: UNAUTHORIZED_MESSAGE }, { status: 401, headers: challengeRequest(request) ? { "www-authenticate": WWW_AUTHENTICATE } : undefined }, ) } export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) { return credentialFromRequest(request).pipe( Effect.map((credential) => ServerAuth.authorized(credential, config) || authorizedSessionCookie(request, config)), ) } function authorizedSessionCookie(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) { const token = request.cookies[ServerAuth.sessionCookieName(request.headers.host)] if (!token) return false // Same-site pages on other ports still send this cookie, so only same-origin requests may use it. const origin = request.headers.origin if (origin !== undefined && URL.parse(origin)?.host !== request.headers.host) return false return ServerAuth.verifySession(token, config) } export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { const config = yield* ServerAuth.Config if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const route = yield* HttpRouter.RouteContext // Ticketed PTY and client MCP connects (browsers cannot set headers on WebSocket upgrades) and pairing links // skip credential checks here; their handlers consume and validate the ticket or code. Use the // route selected by Effect so authorization and dispatch cannot disagree about the request path. const url = new URL(request.url, "http://localhost") if ( ((route.route.path === PTY_CONNECT_PATH || route.route.path === PERSISTENT_PTY_CONNECT_PATH || route.route.path === MCP_CLIENT_PATH) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)) || route.route.path === PAIRING_CONNECT_PATH ) return yield* effect if (yield* authorizedRequest(request, config)) return yield* effect if (challengeRequest(request)) yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), ) return yield* new UnauthorizedError({ message: UNAUTHORIZED_MESSAGE }) }), ) }), )