fix: bridge opencode iframe auth with tickets
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -19,8 +20,13 @@ const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-hand
|
||||
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
||||
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
||||
const OPENCODE_PROXY_PREFIX = "/_opencode";
|
||||
const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket";
|
||||
const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket";
|
||||
const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000;
|
||||
const OPENCODE_TICKET_MAX_AGE_SECONDS = Math.floor(OPENCODE_TICKET_TTL_MS / 1000);
|
||||
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
|
||||
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
|
||||
const opencodeTickets = new Map();
|
||||
|
||||
export async function serveCloudWeb(options) {
|
||||
installCloudWebProcessErrorHandlers({ serviceId: options.serviceId });
|
||||
@@ -87,7 +93,7 @@ export function createCloudWebServer({
|
||||
const info = await stat(candidate);
|
||||
if (!info.isFile()) continue;
|
||||
const type = contentType(candidate);
|
||||
const body = await staticResponseBody({ candidate, relativePath, type });
|
||||
const body = await staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
const encoded = await encodeStaticBody(request, body, type);
|
||||
response.writeHead(200, {
|
||||
"content-type": type,
|
||||
@@ -124,14 +130,14 @@ export function createCloudWebServer({
|
||||
});
|
||||
}
|
||||
|
||||
async function staticResponseBody({ candidate, relativePath, type }) {
|
||||
async function staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
||||
const body = await readFile(candidate);
|
||||
if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body;
|
||||
return injectCloudWebRuntimeConfig(body);
|
||||
return await injectCloudWebRuntimeConfig(body, { request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
}
|
||||
|
||||
function injectCloudWebRuntimeConfig(body) {
|
||||
const config = runtimeConfigFromEnv();
|
||||
async function injectCloudWebRuntimeConfig(body, options = {}) {
|
||||
const config = await runtimeConfigFromEnv(options);
|
||||
if (Object.keys(config).length === 0) return body;
|
||||
const html = body.toString("utf8");
|
||||
const script = `<script>window.HWLAB_CLOUD_WEB_CONFIG=${safeInlineJson(config)};</script>`;
|
||||
@@ -140,20 +146,30 @@ function injectCloudWebRuntimeConfig(body) {
|
||||
return Buffer.from(`${html}${script}`, "utf8");
|
||||
}
|
||||
|
||||
function runtimeConfigFromEnv() {
|
||||
async function runtimeConfigFromEnv(options = {}) {
|
||||
const config = { displayTime: displayTimeConfigFromEnv() };
|
||||
const workbench = workbenchRuntimeConfigFromEnv();
|
||||
if (workbench) config.workbench = workbench;
|
||||
const opencode = opencodeRuntimeConfigFromEnv();
|
||||
const opencode = await opencodeRuntimeConfigFromEnv(options);
|
||||
if (opencode) config.opencode = opencode;
|
||||
return config;
|
||||
}
|
||||
|
||||
function opencodeRuntimeConfigFromEnv() {
|
||||
async function opencodeRuntimeConfigFromEnv(options = {}) {
|
||||
const url = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
|
||||
if (!url) return null;
|
||||
validateOpencodeUrl(url);
|
||||
return { url };
|
||||
return { url: await opencodeFrameUrl(url, options) };
|
||||
}
|
||||
|
||||
async function opencodeFrameUrl(value, { request = null, url = null, cloudApiBaseUrl = "", cloudApiProxyTimeoutMs = 0 } = {}) {
|
||||
if (!request || normalizeCloudWebRoutePath(url?.pathname || "") !== "/opencode" || !cloudApiBaseUrl || !hasCookieHeader(request.headers.cookie)) return value;
|
||||
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
if (!session.ok || session.body?.authenticated !== true) return value;
|
||||
const ticket = issueOpencodeTicket(request.headers.cookie);
|
||||
const target = new URL(value);
|
||||
target.searchParams.set(OPENCODE_TICKET_QUERY, ticket);
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function displayTimeConfigFromEnv() {
|
||||
@@ -579,7 +595,8 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
const ticketAuth = opencodeTicketAuthRequest(request, url);
|
||||
const session = await requestCloudApiJson({ request: ticketAuth.request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
if (!session.ok || session.body?.authenticated !== true) {
|
||||
sendJson(response, session.statusCode === 403 ? 403 : 401, {
|
||||
authenticated: false,
|
||||
@@ -596,6 +613,7 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
|
||||
}
|
||||
|
||||
const target = opencodeTargetUrl(url, opencodeUpstreamUrl);
|
||||
const extraResponseHeaders = ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {};
|
||||
const upstreamRequest = {
|
||||
method: request.method,
|
||||
headers: opencodeUpstreamHeaders(request, body, authorization)
|
||||
@@ -606,7 +624,8 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
|
||||
request: upstreamRequest,
|
||||
response,
|
||||
body: request.method === "GET" || request.method === "HEAD" ? "" : body,
|
||||
timeoutMs: opencodeProxyTimeoutMs
|
||||
timeoutMs: opencodeProxyTimeoutMs,
|
||||
extraResponseHeaders
|
||||
});
|
||||
} catch (error) {
|
||||
if (response.headersSent || response.writableEnded) {
|
||||
@@ -636,7 +655,56 @@ function opencodeTargetUrl(url, opencodeUpstreamUrl) {
|
||||
: url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)
|
||||
? url.pathname.slice(OPENCODE_PROXY_PREFIX.length) || "/"
|
||||
: url.pathname;
|
||||
return new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
|
||||
const target = new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
|
||||
target.searchParams.delete(OPENCODE_TICKET_QUERY);
|
||||
return target;
|
||||
}
|
||||
|
||||
function issueOpencodeTicket(cookieHeader) {
|
||||
pruneOpencodeTickets();
|
||||
const ticket = randomBytes(24).toString("base64url");
|
||||
opencodeTickets.set(ticket, {
|
||||
cookieHeader: String(cookieHeader || ""),
|
||||
expiresAtMs: Date.now() + OPENCODE_TICKET_TTL_MS
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
function opencodeTicketAuthRequest(request, url) {
|
||||
const ticket = opencodeTicketFromRequest(request, url);
|
||||
const record = ticket ? opencodeTicketRecord(ticket) : null;
|
||||
if (!record) return { request, ticket: "" };
|
||||
return {
|
||||
request: { headers: { ...request.headers, cookie: record.cookieHeader } },
|
||||
ticket
|
||||
};
|
||||
}
|
||||
|
||||
function opencodeTicketFromRequest(request, url) {
|
||||
const fromQuery = url.searchParams.get(OPENCODE_TICKET_QUERY) || "";
|
||||
if (fromQuery) return fromQuery;
|
||||
return cookiePairsFromHeader(request.headers.cookie).find((pair) => pair.name === OPENCODE_TICKET_COOKIE)?.value || "";
|
||||
}
|
||||
|
||||
function opencodeTicketRecord(ticket) {
|
||||
pruneOpencodeTickets();
|
||||
const record = opencodeTickets.get(String(ticket || ""));
|
||||
if (!record || record.expiresAtMs <= Date.now() || !record.cookieHeader) {
|
||||
opencodeTickets.delete(String(ticket || ""));
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function pruneOpencodeTickets() {
|
||||
const now = Date.now();
|
||||
for (const [ticket, record] of opencodeTickets.entries()) {
|
||||
if (!record || record.expiresAtMs <= now) opencodeTickets.delete(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
function opencodeTicketSetCookie(ticket) {
|
||||
return `${OPENCODE_TICKET_COOKIE}=${encodeURIComponent(ticket)}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${OPENCODE_TICKET_MAX_AGE_SECONDS}`;
|
||||
}
|
||||
|
||||
function opencodeUpstreamHeaders(request, body, authorization) {
|
||||
|
||||
Reference in New Issue
Block a user