fix: bridge opencode iframe auth with tickets

This commit is contained in:
UniDesk Codex
2026-06-30 02:52:04 +08:00
parent b706e803ab
commit 211852ecf4
3 changed files with 197 additions and 15 deletions
+80 -12
View File
@@ -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) {