feat(server): pty websocket auth tickets (#25660)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -23,6 +24,7 @@ export const PtyPaths = {
|
||||
get: `${root}/:ptyID`,
|
||||
update: `${root}/:ptyID`,
|
||||
remove: `${root}/:ptyID`,
|
||||
connectToken: `${root}/:ptyID/connect-token`,
|
||||
connect: `${root}/:ptyID/connect`,
|
||||
} as const
|
||||
|
||||
@@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty")
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
||||
params: { ptyID: PtyID },
|
||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connectToken",
|
||||
summary: "Create PTY WebSocket token",
|
||||
description: "Create a short-lived ticket for opening a PTY WebSocket connection.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
@@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: HttpApiError.NotFound,
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connect",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
@@ -11,9 +18,15 @@ import { InstanceHttpApi } from "../api"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
|
||||
}
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
|
||||
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
|
||||
return yield* Effect.promise(() => Shell.list())
|
||||
@@ -54,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
return true
|
||||
})
|
||||
|
||||
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
|
||||
return yield* new HttpApiError.Forbidden({})
|
||||
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
|
||||
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("shells", shells)
|
||||
.handle("list", list)
|
||||
@@ -61,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
.handle("get", get)
|
||||
.handle("update", update)
|
||||
.handle("remove", remove)
|
||||
.handle("connectToken", connectToken)
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
@@ -75,12 +99,20 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ServerAuth } from "@/server/auth"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
@@ -55,7 +56,11 @@ function decodeCredential(input: string) {
|
||||
}
|
||||
|
||||
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
return credentialFromURL(new URL(request.url, "http://localhost"), request)
|
||||
}
|
||||
|
||||
function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) {
|
||||
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])
|
||||
@@ -86,7 +91,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
@@ -44,7 +45,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
@@ -163,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
@@ -187,6 +189,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
Layer.provideMerge(InstanceLayer.layer),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user