feat(server): add authenticated remote access and desktop bootstrap

Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
This commit is contained in:
Shantur Rathore
2026-01-14 18:18:14 +00:00
parent 927e4e1281
commit 40634138bc
27 changed files with 1721 additions and 160 deletions

View File

@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }

View File

@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const refreshMeta = async () => {
setLoading(true)
setError(null)
setPasswordError(null)
try {
const result = await serverApi.fetchServerMeta()
setMeta(result)
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
}
}
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError("Password must be at least 8 characters.")
return
}
if (next !== confirm) {
setPasswordError("Passwords do not match.")
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return (
<Dialog
open={props.open}
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Server password</p>
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
</div>
</div>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">Authentication status unavailable.</div>}
>
<div class="remote-card">
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
<p class="remote-help">
{authStatus()!.passwordUserProvided
? "A password is set for remote access."
: "No memorable password is set yet. Set one to allow remote handover logins."}
</p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? "Cancel"
: authStatus()!.passwordUserProvided
? "Change password"
: "Set password"}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">New password</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder="At least 8 characters"
/>
</div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">Confirm password</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
</Show>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
disabled={savingPassword()}
onClick={() => void handleSubmitPassword()}
>
{savingPassword() ? "Saving…" : "Save password"}
</button>
</div>
</Show>
</div>
</Show>
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Wifi class="remote-icon" />

View File

@@ -103,7 +103,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
logHttp(`${method} ${path}`)
try {
const response = await fetch(url, { ...init, headers })
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
@@ -135,6 +135,15 @@ export const serverApi = {
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
method: "POST",
body: JSON.stringify({ password }),
})
},
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
@@ -270,7 +279,7 @@ export const serverApi = {
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
sseLogger.info(`Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL)
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload