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
+
+
+
+
+
+
+
+`
\ 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())