Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions packages/opencode/src/server/routes/auth-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
export const HTML_LOGIN = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenCode Login</title>
<style>
:root {
--font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

/* Light theme variables (default) */
--background-base: #f8f7f7;
--background-strong: #fcfcfc;
--surface-base: rgba(0, 0, 0, 0.05);
--text-base: #6e6a6a;
--text-strong: #151313;
--text-weak: #8e8b8b;
--primary: #dcde8d;
--border: rgba(0, 0, 0, 0.1);
--error: #fc533a;
--button-text: #151313;
--input-bg: rgba(0, 0, 0, 0.03);
--input-border: rgba(0, 0, 0, 0.08);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}

@media (prefers-color-scheme: dark) {
:root {
--background-base: #131010;
--background-strong: #191515;
--surface-base: rgba(255, 255, 255, 0.05);
--text-base: #b1acaa;
--text-strong: #f6f3f3;
--text-weak: #716c6b;
--primary: #fab283;
--border: rgba(255, 255, 255, 0.1);
--error: #fc533a;
--button-text: #131010;
--input-bg: rgba(255, 255, 255, 0.03);
--input-border: rgba(255, 255, 255, 0.08);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
font-family: var(--font-family-sans);
background-color: var(--background-base);
color: var(--text-base);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}

.login-container {
width: 100%;
max-width: 360px;
padding: 2.5rem;
background-color: var(--background-strong);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}

.header {
margin-bottom: 2rem;
text-align: center;
}

.logo {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
color: var(--text-strong);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}

.logo svg {
width: 24px;
height: 24px;
color: var(--text-strong);
}

.subtitle {
color: var(--text-weak);
font-size: 0.875rem;
}

.form-group {
margin-bottom: 1rem;
}

label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-strong);
}

input {
width: 100%;
padding: 0.625rem 0.75rem;
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
color: var(--text-strong);
font-size: 0.875rem;
transition: all 0.15s ease;
font-family: inherit;
}

input:focus {
outline: none;
border-color: var(--text-strong);
background-color: var(--background-strong);
}

input::placeholder {
color: var(--text-weak);
opacity: 0.7;
}

button {
width: 100%;
padding: 0.625rem;
background-color: var(--text-strong);
color: var(--background-base);
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
margin-top: 1rem;
font-family: inherit;
}

button:hover {
opacity: 0.9;
}

button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

.error-message {
color: var(--error);
font-size: 0.8125rem;
margin-top: 1rem;
text-align: center;
display: none;
padding: 0.5rem;
background-color: rgba(252, 83, 58, 0.1);
border-radius: 4px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="header">
<div class="logo">
<svg viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 32H8V16H24V32Z" fill="currentColor" fill-opacity="0.25"/>
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="currentColor"/>
</svg>
<span class="logo-text">OpenCode</span>
</div>
</div>
<form id="loginForm">
<div class="form-group">
<input type="text" id="username" name="username" value="opencode" required autocomplete="username" placeholder="Enter username">
</div>
<div class="form-group">
<input type="password" id="password" name="password" required autocomplete="current-password" autofocus placeholder="Enter password">
</div>
<button type="submit" id="submitBtn">Sign in</button>
<div class="error-message" id="errorMessage">Invalid credentials</div>
</form>
</div>

<script>
const form = document.getElementById('loginForm');
const errorMsg = document.getElementById('errorMessage');
const submitBtn = document.getElementById('submitBtn');

form.addEventListener('submit', async (e) => {
e.preventDefault();
errorMsg.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Verifying...';

const username = form.username.value;
const password = form.password.value;
const credentials = btoa(username + ':' + password);

try {
const response = await fetch('/auth/verify', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + credentials,
'Content-Type': 'application/json'
}
});

if (response.ok) {
// Redirect to home or original destination
const urlParams = new URLSearchParams(window.location.search);
const redirect = urlParams.get('redirect') || '/';
window.location.href = redirect;
} else {
errorMsg.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
}
} catch (err) {
console.error('Login error:', err);
errorMsg.textContent = 'Connection failed';
errorMsg.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
}
});
</script>
</body>
</html>`
56 changes: 56 additions & 0 deletions packages/opencode/src/server/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 39 additions & 2 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down