From 06c64d34de800637af604c31e9be091748e6c16b Mon Sep 17 00:00:00 2001 From: wangyudong Date: Tue, 20 Jan 2026 14:10:11 +0800 Subject: [PATCH] feat(opencode): replace native Basic Auth prompt with custom login UI in web application --- .../opencode/src/server/routes/auth-html.ts | 239 ++++++++++++++++++ packages/opencode/src/server/routes/auth.ts | 56 ++++ packages/opencode/src/server/server.ts | 41 ++- 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/server/routes/auth-html.ts create mode 100644 packages/opencode/src/server/routes/auth.ts diff --git a/packages/opencode/src/server/routes/auth-html.ts b/packages/opencode/src/server/routes/auth-html.ts new file mode 100644 index 00000000000..8f3f556b9b5 --- /dev/null +++ b/packages/opencode/src/server/routes/auth-html.ts @@ -0,0 +1,239 @@ +export const HTML_LOGIN = ` + + + + + OpenCode Login + + + +
+
+ +
+
+
+ +
+
+ +
+ +
Invalid credentials
+
+
+ + + +` \ No newline at end of file diff --git a/packages/opencode/src/server/routes/auth.ts b/packages/opencode/src/server/routes/auth.ts new file mode 100644 index 00000000000..41a622d82eb --- /dev/null +++ b/packages/opencode/src/server/routes/auth.ts @@ -0,0 +1,56 @@ +import { Hono } from "hono" +import { getCookie, setCookie } from "hono/cookie" +import { Flag } from "../../flag/flag" +import { Log } from "../../util/log" +import { HTML_LOGIN } from "./auth-html" + +const log = Log.create({ service: "server.auth" }) + +export function AuthRoutes() { + const app = new Hono() + + app.get("/login", (c) => { + return c.html(HTML_LOGIN) + }) + + app.post("/verify", async (c) => { + const authHeader = c.req.header("Authorization") + if (!authHeader) { + return c.json({ error: "Missing authorization header" }, 401) + } + + const match = authHeader.match(/^Basic (.+)$/) + if (!match) { + return c.json({ error: "Invalid authorization format" }, 401) + } + + const credentials = atob(match[1]) + const [username, password] = credentials.split(":") + + const expectedUsername = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + const expectedPassword = Flag.OPENCODE_SERVER_PASSWORD + + if (!expectedPassword) { + // If no password set, always valid (though server config usually prevents this route being hit if no password) + return c.json({ success: true }) + } + + if (username === expectedUsername && password === expectedPassword) { + // Set a cookie for session persistence + // We'll store the basic auth token directly or a simple marker + // Storing the basic auth token allows us to reuse the basic auth logic in middleware + setCookie(c, "opencode_auth", match[1], { + path: "/", + httpOnly: true, // Not accessible via JS + secure: false, // Localhost usually plain HTTP + sameSite: "Lax", + maxAge: 60 * 60 * 24 * 7 // 7 days + }) + return c.json({ success: true }) + } + + return c.json({ error: "Invalid credentials" }, 401) + }) + + return app +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 28dec7f4043..7b82efe436f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,6 +41,9 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" +import { AuthRoutes } from "./routes/auth" +import { getCookie } from "hono/cookie" + // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -82,11 +85,44 @@ export namespace Server { status: 500, }) }) - .use((c, next) => { + .use(async (c, next) => { const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + + // Allow access to auth routes and favicon + if (c.req.path.startsWith("/auth") || c.req.path === "/login" || c.req.path === "/favicon.ico") + return next() + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - return basicAuth({ username, password })(c, next) + + // Check for Basic Auth header + const authHeader = c.req.header("Authorization") + if (authHeader) { + return await basicAuth({ username, password })(c, next) + } + + // Check for Cookie + const authCookie = getCookie(c, "opencode_auth") + if (authCookie) { + // Validate cookie content (which is base64 user:pass) + try { + const credentials = atob(authCookie) + const [u, p] = credentials.split(":") + if (u === username && p === password) { + return next() + } + } catch (e) { + // Invalid cookie, proceed to challenge + } + } + + // If browser request, redirect to login + const accept = c.req.header("Accept") + if (accept && accept.includes("text/html")) { + return c.redirect("/auth/login?redirect=" + encodeURIComponent(c.req.path)) + } + + return c.json({ error: "Unauthorized" }, 401) }) .use(async (c, next) => { const skipLogging = c.req.path === "/log" @@ -162,6 +198,7 @@ export namespace Server { .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) + .route("/auth", AuthRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes())