Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 13a9aa2556 feat(server): pair with one-time connect links 2026-09-24 01:35:52 +05:30
25 changed files with 439 additions and 66 deletions
+1
View File
@@ -451,6 +451,7 @@ export const dict = {
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingLink, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
@@ -93,3 +93,20 @@ describe("pairing scan", () => {
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
url: "http://192.168.1.2:49374",
code: "abc_DEF-123",
})
})
test("rejects other URLs", () => {
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
})
})
@@ -1,4 +1,5 @@
import { Option, Schema } from "effect"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { normalizeServerUrl } from "@/runtime/server/registry"
@@ -59,3 +60,22 @@ export function decodePairingUrl(value: string, origin?: string) {
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
}
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
const address = serverAddress(url.origin)
if (!code || !address) return
return { url: address, code }
}
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
.server.connect({ code: link.code })
.then(
(session) => ({ urls: [link.url], password: session.token }),
() => undefined,
)
}
+12 -3
View File
@@ -3,7 +3,7 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingScan } from "./pairing"
import { decodePairingScan, pairingLink, redeemPairingLink } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
@@ -23,12 +23,21 @@ export function PairingScanner(props: {
video,
(result) => {
const pairing = decodePairingScan(result.data)
if (!pairing) {
if (pairing) {
scanner.stop()
props.onScan(pairing)
return
}
const link = pairingLink(result.data)
if (!link) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
})
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
+11 -1
View File
@@ -8,7 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
@@ -39,6 +39,16 @@ export function ConnectServerScreen(
)
const request = useMutation(() => ({
mutationFn: async () => {
const link = pairingLink(state.url)
if (link) {
const redeemed = await redeemPairingLink(link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
return
}
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
+2 -1
View File
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Pairing links must reach the server so it can set the session cookie.
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
globPatterns: ["**/*"],
globIgnores: ["**/*.map", "_headers", "_redirects"],
+2 -2
View File
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
},
}),
Spec.make("pair", {
description: "Show server pairing information",
description: "Print one-time links to connect a browser or app",
params: {
url: Flag.string("url").pipe(
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
Flag.mapTryCatch(
(value) => {
const url = new URL(value)
+21 -22
View File
@@ -2,7 +2,6 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -12,34 +11,25 @@ export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
const link = info.urls[0]
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
: undefined
: (yield* Effect.tryPromise(() => client.server.info())).urls
const pairing = yield* Effect.tryPromise(() => client.server.pair())
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
process.stdout.write(
[
"",
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
...(link
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
"",
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
...(links[0]
? [
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
renderUnicodeCompact(links[0], { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
@@ -47,8 +37,17 @@ export default Runtime.handler(
)
if (Option.isSome(input.url)) return
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
const url = new URL(endpoint.url)
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
process.stderr.write(
[
` Over SSH? Forward the port, then open the link on your machine:`,
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
"",
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
"",
].join(EOL) + EOL,
)
}),
)
+6 -1
View File
@@ -14,7 +14,12 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
if (
url.pathname === "/api" ||
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/auth/") ||
url.pathname === "/openapi.json"
)
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
+18
View File
@@ -68,6 +68,24 @@ describe("web UI", () => {
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
const pairing = yield* Effect.promise(() =>
fetch(new URL("/api/pair", origin), {
method: "POST",
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}).then((response) => response.json() as Promise<{ code: string }>),
)
const redirect = yield* Effect.promise(() =>
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
redirect: "manual",
headers: { accept: "text/html" },
}),
)
expect(redirect.status).toBe(302)
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
expect(authorized.status).toBe(200)
yield* Effect.promise(() => authorized.arrayBuffer())
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
+9
View File
@@ -47,8 +47,17 @@ export type ServerInfoOutput = {
}
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
export type ServerConnectInput = { readonly code: string }
export type ServerConnectOutput = { readonly token: string }
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
export interface ServerApi<E = never> {
readonly info: ServerInfoOperation<E>
readonly pair: ServerPairOperation<E>
readonly connect: ServerConnectOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+16 -1
View File
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -281,7 +284,19 @@ const preserveStream =
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const EndpointServerPair = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerPairOutput>()(raw["server.pair"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: ServerConnectInput) =>
preserveEffect<ServerConnectOutput>()(
raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupServer = (raw: RawClient["server.server"]) => ({
info: EndpointServerInfo(raw),
pair: EndpointServerPair(raw),
connect: EndpointServerConnect(raw),
})
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
@@ -1,5 +1,8 @@
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -416,6 +419,22 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
pair: (requestOptions?: RequestOptions) =>
request<ServerPairOutput>(
{ method: "POST", path: `/api/pair`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
connect: (input: ServerConnectInput, requestOptions?: RequestOptions) =>
request<ServerConnectOutput>(
{
method: "GET",
path: `/auth/connect/${encodeURIComponent(input.code)}`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
@@ -2,6 +2,10 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type PairingCode = { code: string; expires_in: number }
export type PairingSession = { token: string }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
export type LocationPublicRef = { directory: string }
@@ -2696,6 +2700,12 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
export type ServerConnectInput = { readonly code: { readonly code: string }["code"] }
export type ServerConnectOutput = PairingSession
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+44
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { UnauthorizedError } from "../errors.js"
export const ServerInfo = Schema.Struct({
version: Schema.String,
@@ -12,6 +13,24 @@ export const ServerInfo = Schema.Struct({
}).annotate({ identifier: "ServerInfo" })
export type ServerInfo = typeof ServerInfo.Type
export const PairingCode = Schema.Struct({
code: Schema.String,
expires_in: Schema.Int,
}).annotate({ identifier: "PairingCode" })
export type PairingCode = typeof PairingCode.Type
export const PairingSession = Schema.Struct({
token: Schema.String,
}).annotate({ identifier: "PairingSession" })
export type PairingSession = typeof PairingSession.Type
const PAIRING_CONNECT_PATH = /^\/auth\/connect\/[^/]+$/
// Authorization middleware skips credential checks for pairing links; the connect handler consumes the code instead.
export function isPairingConnectURL(url: URL) {
return PAIRING_CONNECT_PATH.test(url.pathname)
}
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.info", "/api/info", {
@@ -24,4 +43,29 @@ export const ServerGroup = HttpApiGroup.make("server.server")
}),
),
)
.add(
HttpApiEndpoint.post("server.pair", "/api/pair", {
success: PairingCode,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.pair",
summary: "Create pairing code",
description: "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
}),
),
)
.add(
HttpApiEndpoint.get("server.connect", "/auth/connect/:code", {
params: { code: Schema.String },
success: PairingSession,
error: UnauthorizedError,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.connect",
summary: "Redeem pairing code",
description:
"Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "server" }))
+36 -5
View File
@@ -1,6 +1,7 @@
export * as ServerAuth from "./auth"
import { Context, Layer, Option, Redacted } from "effect"
import { createHmac, timingSafeEqual } from "node:crypto"
export type DecodedCredentials = {
readonly username: string
@@ -12,6 +13,8 @@ export type Info = {
readonly username: string
}
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
static configLayer(input: Pick<Info, "password">) {
return Layer.succeed(this, this.of({ ...input, username: "opencode" }))
@@ -26,10 +29,38 @@ export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
// Session tokens issued by pairing links are accepted anywhere the password is.
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
if (Option.isNone(config.password) || credentials.username !== config.username) return false
const password = Redacted.value(credentials.password)
return password === config.password.value || verifySession(password, config)
}
// Sessions are signed with a key derived from the server password, so rotating the password revokes every session.
export function issueSession(config: Info, now = Date.now()) {
if (Option.isNone(config.password)) return
const expires = String(Math.floor(now / 1000) + SESSION_TTL_SECONDS)
return `${expires}.${sign(config.password.value, expires)}`
}
export function verifySession(token: string, config: Info, now = Date.now()) {
if (Option.isNone(config.password)) return false
const parts = token.split(".")
if (parts.length !== 2) return false
const expires = Number(parts[0])
if (!Number.isSafeInteger(expires) || expires * 1000 <= now) return false
const expected = Buffer.from(sign(config.password.value, parts[0]))
const actual = Buffer.from(parts[1])
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
// Browsers share cookies across ports on the same host, so the name carries the port to keep local servers apart.
export function sessionCookieName(host: string | undefined) {
const port = URL.parse(`http://${host ?? ""}`)?.port
return port ? `opencode_session_${port}` : "opencode_session"
}
function sign(password: string, payload: string) {
const key = createHmac("sha256", password).update("opencode-session-v1").digest()
return createHmac("sha256", key).update(payload).digest("base64url")
}
+48 -12
View File
@@ -1,18 +1,54 @@
import { Effect } from "effect"
import { Duration, Effect } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { UnauthorizedError } from "@opencode/protocol/errors"
import { Api } from "../api"
import { ServerAuth } from "../auth"
import { ServerInfo } from "../server-info"
import { ServerPairing } from "../pairing"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.info", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
version: info.app.version ?? "unknown",
pid: process.pid ?? 0,
urls: info.urls(),
paths: info.paths,
}
}),
),
Effect.gen(function* () {
const pairing = yield* ServerPairing.Service
const auth = yield* ServerAuth.Config
return handlers
.handle("server.info", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
version: info.app.version ?? "unknown",
pid: process.pid ?? 0,
urls: info.urls(),
paths: info.paths,
}
}),
)
.handle("server.pair", () => pairing.issue())
.handle(
"server.connect",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
// Browser navigations ask for HTML; everything else is an API client that wants the token.
const browser = request.headers.accept?.includes("text/html") === true
const token = (yield* pairing.consume(ctx.params.code)) ? ServerAuth.issueSession(auth) : undefined
if (token === undefined) {
if (!browser) return yield* new UnauthorizedError({ message: "Pairing link expired or already used" })
return HttpServerResponse.text(
"This pairing link expired or was already used. Run `opencode pair` to get a new one.",
{ status: 401 },
)
}
if (!browser) return { token }
return HttpServerResponse.redirect("/").pipe(
HttpServerResponse.setCookieUnsafe(ServerAuth.sessionCookieName(request.headers.host), token, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: Duration.seconds(ServerAuth.SESSION_TTL_SECONDS),
}),
)
}),
)
}),
)
@@ -4,6 +4,7 @@ import { Authorization } from "@opencode/protocol/middleware/authorization"
export { Authorization } from "@opencode/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -37,7 +38,18 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
}
export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
return credentialFromRequest(request).pipe(Effect.map((credential) => ServerAuth.authorized(credential, config)))
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(
@@ -48,10 +60,11 @@ export const authorizationLayer = Layer.effect(
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
// credential checks here; the connect handler consumes and validates the ticket.
// Ticketed PTY connects (browsers cannot set headers on WebSocket upgrades) and pairing links
// skip credential checks here; their handlers consume and validate the ticket or code.
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
+36
View File
@@ -0,0 +1,36 @@
export * as ServerPairing from "./pairing"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { randomBytes } from "node:crypto"
const TTL = Duration.minutes(5)
export interface Interface {
readonly issue: () => Effect.Effect<{ readonly code: string; readonly expires_in: number }>
readonly consume: (code: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ServerPairing") {}
// Codes are inserted via Cache.set and removed via invalidateWhen, so the lookup never runs.
const noLookup = () => Effect.die(new Error("ServerPairing cache must be used via set/invalidateWhen, never get"))
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cache = yield* Cache.make<string, true>({ capacity: 1_000, lookup: noLookup, timeToLive: TTL })
return Service.of({
issue: Effect.fn("ServerPairing.issue")(function* () {
const code = randomBytes(16).toString("base64url")
yield* Cache.set(cache, code, true)
return { code, expires_in: Duration.toSeconds(TTL) }
}),
consume: Effect.fn("ServerPairing.consume")(function* (code) {
return yield* Cache.invalidateWhen(cache, code, () => true)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+2
View File
@@ -6,6 +6,7 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
import { Global } from "@opencode/util/global"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -186,6 +187,7 @@ function dispatch(
return yield* infoResponse(status, version, urls, tmp)
}
if (
!isPairingConnectURL(url) &&
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
+2
View File
@@ -42,6 +42,7 @@ import { handlers } from "./handlers"
import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
import { PtyEnvironment } from "./pty-environment"
import { ServerPairing } from "./pairing"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
@@ -68,6 +69,7 @@ const applicationServiceNodes = [
Credential.node,
WellKnown.node,
PtyEnvironment.node,
ServerPairing.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
+15
View File
@@ -7,3 +7,18 @@ test("accepts only the fixed opencode username", () => {
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
})
test("session tokens expire, resist tampering, and are revoked by changing the password", () => {
const config = { password: Option.some("secret"), username: "opencode" }
const now = Date.now()
const token = ServerAuth.issueSession(config, now)
if (!token) throw new Error("Expected a session token")
expect(ServerAuth.verifySession(token, config, now)).toBe(true)
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make(token) }, config)).toBe(true)
expect(ServerAuth.verifySession(token, config, now + ServerAuth.SESSION_TTL_SECONDS * 1000)).toBe(false)
expect(ServerAuth.verifySession(`${token}x`, config, now)).toBe(false)
const parts = token.split(".")
expect(ServerAuth.verifySession(`${Number(parts[0]) + 1}.${parts[1]}`, config, now)).toBe(false)
expect(ServerAuth.verifySession(token, { ...config, password: Option.some("rotated") }, now)).toBe(false)
expect(ServerAuth.issueSession({ ...config, password: Option.none() })).toBeUndefined()
})
+56
View File
@@ -164,6 +164,62 @@ it.live("authenticates API requests behind the frontend transform while allowing
}),
)
it.live("pairing links sign in browsers with a cookie and API clients with a token", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
})
const base = HttpServer.formatAddress(server.address)
const request = (pathname: string, init?: RequestInit) =>
Effect.promise(() => fetch(new URL(pathname, base), { redirect: "manual", ...init }))
const pair = Effect.gen(function* () {
const response = yield* request("/api/pair", {
method: "POST",
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
return (yield* Effect.promise(() => response.json())) as { code: string; expires_in: number }
})
expect((yield* request("/api/pair", { method: "POST" })).status).toBe(401)
const browser = yield* pair
expect(browser.expires_in).toBe(300)
const redirect = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
expect(redirect.status).toBe(302)
expect(redirect.headers.get("location")).toBe("/")
const setCookie = redirect.headers.get("set-cookie") ?? ""
expect(setCookie).toContain(`opencode_session_${new URL(base).port}=`)
expect(setCookie).toContain("HttpOnly")
expect(setCookie).toContain("SameSite=Lax")
const cookie = setCookie.split(";")[0]
const reused = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
expect(reused.status).toBe(401)
expect(yield* Effect.promise(() => reused.text())).toContain("opencode pair")
expect((yield* request("/api/info", { headers: { cookie } })).status).toBe(200)
expect((yield* request("/api/info", { headers: { cookie, origin: base } })).status).toBe(200)
expect((yield* request("/api/info", { headers: { cookie, origin: "http://127.0.0.1:1" } })).status).toBe(401)
expect((yield* request("/api/info", { headers: { cookie: `${cookie}x` } })).status).toBe(401)
const client = yield* pair
const redeemed = yield* request(`/auth/connect/${client.code}`)
expect(redeemed.status).toBe(200)
const session = (yield* Effect.promise(() => redeemed.json())) as { token: string }
expect(
(yield* request("/api/info", { headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` } }))
.status,
).toBe(200)
expect((yield* request(`/auth/connect/${client.code}`)).status).toBe(401)
expect((yield* request("/auth/connect/unknown")).status).toBe(401)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
@@ -362,15 +362,14 @@ $ opencode serve --help
## pair
Shows server pairing information, including URLs, credentials, and a QR code. The
QR code contains a direct link to the advertised server, with credentials in the
URL fragment.
Prints one-time links, plus a QR code of the first one, that sign a browser or
app in to the server. Links expire after 5 minutes and work once.
```bash
$ opencode pair
```
Advertise an external URL in the QR code and link.
Use an external URL in the links.
```bash
$ opencode pair --url https://dev.example.com
+15 -9
View File
@@ -11,20 +11,26 @@ TUI. It's available by default and password protected.
```bash
$ opencode pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
Open a link to connect. Links work once and expire in 5 minutes.
Scan to pair
http://127.0.0.1:49374/auth/connect/...
█▀▀▀▀▀█ ...
Link http://127.0.0.1:49374/connect#...
```
The QR code and link open the advertised server directly. Connection credentials
stay in the URL fragment and are saved by the `/connect` page before it starts
authenticated requests.
Opening a link in a browser signs it in with a session cookie and loads the web
ui. Scanning the QR code from the OpenCode app, or pasting the link into its
server address field, connects the app the same way. Sessions last 30 days;
changing the server password signs every session out.
### Over SSH
When the server listens only on localhost, forward its port from your machine
and open the printed link locally:
```bash
$ ssh -L 49374:127.0.0.1:49374 my-server
```
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode service` command.