From ac1b2c48976dd429781d1588b647e5c7672a6754 Mon Sep 17 00:00:00 2001 From: Codeplane Agent Date: Mon, 1 Jun 2026 12:37:28 +0000 Subject: [PATCH] fix(auth): use password-first remote login Replace remote custom auth header setup with username/password plus conditional OTP verification across desktop, mobile, and TUI. Centralize Basic/OTP header handling and pass verified OTP tokens through PTY websocket connections. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: codeplane-agent[bot] <287208015+codeplane-agent[bot]@users.noreply.github.com> --- packages/app/src/components/terminal.tsx | 2 + .../codeplane/src/tui/boot/remote-form.tsx | 559 +++++------------- packages/codeplane/src/tui/i18n.ts | 42 +- packages/desktop/e2e/desktop.spec.ts | 127 +++- packages/desktop/src/main/main.ts | 151 +---- packages/desktop/src/main/preload.ts | 9 +- packages/desktop/src/setup/app.tsx | 348 +++++------ .../mobile/src/components/instance-form.tsx | 206 +++++-- .../mobile/src/components/instance-list.tsx | 4 +- packages/mobile/src/platform/api.ts | 29 +- packages/mobile/src/screens/setup.tsx | 4 + packages/shared/src/remote-auth.ts | 188 ++++++ packages/shared/test/remote-auth.test.ts | 85 +++ 13 files changed, 922 insertions(+), 832 deletions(-) create mode 100644 packages/shared/src/remote-auth.ts create mode 100644 packages/shared/test/remote-auth.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 2e1136a279..54a0493061 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -177,6 +177,7 @@ export const Terminal = (props: TerminalProps) => { const auth = server.current?.http const username = auth?.username ?? "codeplane" const password = auth?.password ?? "" + const otpToken = auth?.otpToken ?? "" const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) @@ -517,6 +518,7 @@ export const Terminal = (props: TerminalProps) => { next.searchParams.set("directory", directory) next.searchParams.set("cursor", String(seek)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (otpToken) next.searchParams.set("otp_token", otpToken) if (!sameOrigin && password) { next.searchParams.set("auth_token", btoa(`${username}:${password}`)) // For same-origin requests, let the browser reuse the page's existing auth. diff --git a/packages/codeplane/src/tui/boot/remote-form.tsx b/packages/codeplane/src/tui/boot/remote-form.tsx index 98371011d1..c798a5ac4d 100644 --- a/packages/codeplane/src/tui/boot/remote-form.tsx +++ b/packages/codeplane/src/tui/boot/remote-form.tsx @@ -1,30 +1,17 @@ -// Create-/edit-remote-instance form. Functional mirror of the desktop's -// `InstanceForm` (packages/desktop/src/setup/app.tsx) for the -// remote-server case: pick a label + URL + optional Basic Auth -// (username / password) + optional free-form headers + optional -// trust-self-signed-certs toggle. Validates via `service.probe()`, -// saves, returns. -// -// In-TUI sign-in flow: the "Sign in via browser" action opens the -// instance URL in the user's default browser and accepts a pasted -// header line back. The pasted value is merged into the headers blob -// (deduped by header name) and re-probed so the user gets immediate -// confirmation that the auth header satisfies the auth proxy. -// -// Editing: pass `existing` to pre-fill all fields and update in place -// instead of allocating a new id. -import { createMemo, createSignal, For, onMount, Show } from "solid-js" +// Create-/edit-remote-instance form. Remote login is intentionally limited to +// username + password, with OTP revealed only when the server asks for it. +import { createMemo, createSignal, onMount, Show } from "solid-js" import { useKeyboard } from "@opentui/solid" -import open from "open" import type { SavedInstance } from "@codeplane-ai/shared/instance" +import { checkRemoteAuth, composeRemoteAuthHeaders, splitRemoteAuthHeaders, verifyRemoteTotp, type VerifyRemoteTotpResult } from "@codeplane-ai/shared/remote-auth" import { tuiT } from "@/tui/i18n" import type { InstanceService } from "../instance-service" import { Banner, Header, SectionHeading, StatusBar, TextField, ToggleField, useBootPalette } from "./primitives" export type RemoteFormResult = { instance: SavedInstance } | { cancel: true } -type Field = "label" | "url" | "username" | "password" | "headers" | "ignoreCert" | "clearCache" | "signin" -const FIELD_ORDER: Field[] = ["label", "url", "username", "password", "headers", "ignoreCert", "signin"] +type Field = "label" | "url" | "username" | "password" | "otp" | "ignoreCert" | "clearCache" +const FIELD_ORDER: Field[] = ["label", "url", "username", "password", "ignoreCert"] type ProbeState = | { status: "idle" } @@ -32,22 +19,6 @@ type ProbeState = | { status: "ok"; version?: string } | { status: "error"; message: string } -type SigninPhase = - | { kind: "idle" } - | { kind: "browser-open"; url: string } - | { kind: "paste"; pasted: string } - | { kind: "verifying" } - | { kind: "result"; ok: boolean; message: string } - -const sensitiveHeaderNames = new Set([ - "authorization", - "cookie", - "cf-access-client-secret", - "proxy-authorization", - "set-cookie", - "x-api-key", -]) - function slugify(input: string, fallback: string): string { const slug = input .toLowerCase() @@ -56,14 +27,6 @@ function slugify(input: string, fallback: string): string { return (slug || fallback).slice(0, 32) } -function basicAuthHeader(user: string, pass: string): string { - // Matches the desktop's `composedHeaders` formatting and the CLI's - // `composeRemoteHeaders` so a TUI-saved instance authenticates the - // same way against the same server as one created from any other - // surface. - return `Basic ${Buffer.from(`${user}:${pass}`, "utf8").toString("base64")}` -} - function isHttpUrl(input: string) { try { const parsed = new URL(input) @@ -73,88 +36,23 @@ function isHttpUrl(input: string) { } } -export function isSensitiveHeaderName(name: string) { - const normalized = name.trim().toLowerCase() - return sensitiveHeaderNames.has(normalized) || normalized.endsWith("-token") || normalized.endsWith("-secret") -} - -export function redactHeaderLineForDisplay(line: string) { - const idx = line.indexOf(":") - if (idx <= 0) return line - const name = line.slice(0, idx).trim() - if (!isSensitiveHeaderName(name)) return line - return `${name}: ` -} - -// Parse a free-form headers blob (one `Name: value` per line) into a -// case-preserving record. Blank lines and leading/trailing whitespace -// are tolerated. Lines without a colon are silently skipped — same -// behaviour as the desktop form's `parseHeaders`. -function parseHeaderLines(blob: string): Record { - const out: Record = {} - for (const raw of blob.split(/\r?\n/)) { - const line = raw.trim() - if (!line) continue - const idx = line.indexOf(":") - if (idx <= 0) continue - const name = line.slice(0, idx).trim() - const value = line.slice(idx + 1).trim() - if (!name) continue - out[name] = value - } - return out -} - -// Pretty-print a record back to a `Name: value` blob. Stable insertion -// order so re-rendering the form after edit shows the same lines the -// user (or a previous Sign-in) typed. -function formatHeaderLines(headers: Record): string { - return Object.entries(headers) - .map(([k, v]) => `${k}: ${v}`) - .join("\n") -} - -// Decompose a saved instance into form values: split the basic-auth -// `Authorization` header back out into username/password if it's in -// the canonical `Basic base64(user:pass)` form, leave anything else in -// the headers blob. function decomposeExisting(existing?: SavedInstance) { const empty = { label: "", url: "", username: "", password: "", - headersText: "", + otpToken: "", ignoreCert: false, } if (!existing) return empty - const headers = { ...existing.headers } - let username = "" - let password = "" - const authKey = Object.keys(headers).find((k) => k.toLowerCase() === "authorization") - if (authKey) { - const value = headers[authKey] ?? "" - const match = /^\s*Basic\s+(.+)\s*$/i.exec(value) - if (match) { - try { - const decoded = Buffer.from(match[1] ?? "", "base64").toString("utf8") - const colon = decoded.indexOf(":") - if (colon >= 0) { - username = decoded.slice(0, colon) - password = decoded.slice(colon + 1) - delete headers[authKey] - } - } catch { - // not valid base64 — leave the header as-is for the blob - } - } - } + const auth = splitRemoteAuthHeaders(existing.headers) return { label: existing.label ?? "", url: existing.url ?? "", - username, - password, - headersText: formatHeaderLines(headers), + username: auth.username ?? "", + password: auth.password ?? "", + otpToken: auth.otpToken ?? "", ignoreCert: !!existing.ignoreCertificateErrors, } } @@ -171,28 +69,26 @@ export function RemoteInstanceForm(props: { const [url, setUrl] = createSignal(initial.url) const [username, setUsername] = createSignal(initial.username) const [password, setPassword] = createSignal(initial.password) - const [headersText, setHeadersText] = createSignal(initial.headersText) + const [otpToken, setOtpToken] = createSignal(initial.otpToken) + const [otpCode, setOtpCode] = createSignal("") + const [otpVisible, setOtpVisible] = createSignal(false) const [ignoreCert, setIgnoreCert] = createSignal(initial.ignoreCert) const [focused, setFocused] = createSignal("label") const [error, setError] = createSignal(undefined) const [saving, setSaving] = createSignal(false) const [probe, setProbe] = createSignal({ status: "idle" }) - const [signin, setSignin] = createSignal({ kind: "idle" }) const [cacheInfo, setCacheInfo] = createSignal>>() const [cacheNotice, setCacheNotice] = createSignal<{ ok: boolean; message: string }>() const [clearingCache, setClearingCache] = createSignal(false) const cacheAvailable = createMemo(() => !!props.existing && !!cacheInfo()?.exists) const fields = createMemo(() => { - if (!cacheAvailable()) return FIELD_ORDER const next = FIELD_ORDER.slice() - next.splice(next.indexOf("signin"), 0, "clearCache") + if (otpVisible()) next.splice(next.indexOf("ignoreCert"), 0, "otp") + if (!cacheAvailable()) return next + next.splice(next.indexOf("ignoreCert"), 0, "clearCache") return next }) - const busy = createMemo(() => saving() || probe().status === "checking" || signin().kind === "verifying" || clearingCache()) - const inSignin = createMemo(() => { - const s = signin().kind - return s === "browser-open" || s === "paste" - }) + const busy = createMemo(() => saving() || probe().status === "checking" || clearingCache()) const editingLocalManaged = createMemo(() => !!props.existing?.local) const refreshCacheInfo = async () => { @@ -219,38 +115,41 @@ export function RemoteInstanceForm(props: { ? username() : f === "password" ? password() - : f === "headers" - ? headersText() + : f === "otp" + ? otpCode() : "" const setterFor = (f: Field) => f === "label" ? setLabel : f === "url" - ? setUrl + ? (v: string) => { + setUrl(v) + setOtpToken("") + setOtpCode("") + setOtpVisible(false) + } : f === "username" - ? setUsername + ? (v: string) => { + setUsername(v) + setOtpToken("") + setOtpCode("") + } : f === "password" - ? setPassword - : f === "headers" - ? setHeadersText + ? (v: string) => { + setPassword(v) + setOtpToken("") + setOtpCode("") + } + : f === "otp" + ? setOtpCode : (() => {}) as (v: string) => void - // Compose the final headers map saved on the instance: - // - Start from the parsed `headersText` blob (lets the user supply - // any number of free-form auth headers — Cookie, X-API-Key, etc.) - // - If username/password is set, overlay an `Authorization: Basic …` - // line that wins (case-insensitive) over any Authorization in - // the blob. This matches `composeRemoteHeaders` in the CLI. const composedHeaders = (): Record | undefined => { - const parsed = parseHeaderLines(headersText()) - const u = username().trim() - const p = password() - if (u || p) { - const authKey = Object.keys(parsed).find((k) => k.toLowerCase() === "authorization") - if (authKey) delete parsed[authKey] - parsed["Authorization"] = basicAuthHeader(u, p) - } - return Object.keys(parsed).length ? parsed : undefined + return composeRemoteAuthHeaders({ + username: username(), + password: password(), + otpToken: otpToken(), + }) } const buildInstance = (): SavedInstance | undefined => { @@ -291,6 +190,69 @@ export function RemoteInstanceForm(props: { } } + const headersWithoutOtp = () => + composeRemoteAuthHeaders({ + username: username(), + password: password(), + }) + + const otpFailureMessage = (reason: Extract["reason"]) => + reason === "invalid-code" + ? tuiT("boot.remote.authOtpInvalid") + : reason === "rate-limited" + ? tuiT("boot.remote.authOtpRateLimited") + : tuiT("boot.remote.authOtpFailed") + + const resolveAuthHeaders = async ( + candidate: SavedInstance, + ): Promise<{ ok: true; headers?: Record } | { ok: false; message: string }> => { + const status = await checkRemoteAuth({ url: candidate.url, headers: candidate.headers }, fetch, { timeoutMs: 8000 }) + if (!status.reachable) return { ok: true, headers: candidate.headers } + if (!status.required) { + setOtpVisible(false) + setOtpToken("") + setOtpCode("") + return { ok: true, headers: headersWithoutOtp() } + } + if (status.authenticated && !status.totpRequired) { + setOtpVisible(false) + setOtpToken("") + setOtpCode("") + return { ok: true, headers: headersWithoutOtp() } + } + if (!status.passwordValid || !status.totpRequired) { + setOtpVisible(false) + setOtpToken("") + return { ok: false, message: tuiT("boot.remote.authInvalidPassword") } + } + setOtpVisible(true) + if (!otpCode().trim()) { + setFocused("otp") + return { ok: false, message: tuiT("boot.remote.authOtpRequired") } + } + const verified = await verifyRemoteTotp( + { + url: candidate.url, + username: username(), + password: password(), + code: otpCode(), + }, + fetch, + { timeoutMs: 8000 }, + ) + if (!verified.ok) return { ok: false, message: otpFailureMessage(verified.reason) } + setOtpToken(verified.token) + setOtpCode("") + return { + ok: true, + headers: composeRemoteAuthHeaders({ + username: username(), + password: password(), + otpToken: verified.token, + }), + } + } + const probeNow = async () => { if (busy()) return setError(undefined) @@ -298,7 +260,12 @@ export function RemoteInstanceForm(props: { if (!candidate) return setProbe({ status: "checking" }) try { - const result = await props.service.probe(candidate) + const auth = await resolveAuthHeaders(candidate) + if (!auth.ok) { + setProbe({ status: "error", message: auth.message }) + return + } + const result = await props.service.probe({ ...candidate, headers: auth.headers }) if (!result.ok) { setProbe({ status: "error", @@ -322,8 +289,14 @@ export function RemoteInstanceForm(props: { if (!candidate) return setSaving(true) try { - await props.service.save(candidate) - props.onDone({ instance: candidate }) + const auth = await resolveAuthHeaders(candidate) + if (!auth.ok) { + setError(auth.message) + return + } + const instance = { ...candidate, headers: auth.headers } + await props.service.save(instance) + props.onDone({ instance }) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -331,42 +304,6 @@ export function RemoteInstanceForm(props: { } } - // Sign-In-with-Browser, in-TUI: - // 1. Validate URL. - // 2. Open the URL in the user's default browser via `open`. - // 3. Switch the form into "paste" mode: a single-line input that - // accepts a `Name: value` header line (Cookie / Authorization / - // X-API-Key / etc). Bracketed paste is preserved verbatim, so - // multi-cookie `Cookie: a=…; b=…` lines copy in cleanly. - // 4. Merge the captured header into the blob (case-insensitive - // dedupe) and re-probe to confirm the auth header gets us past - // the auth proxy. If `service.probe()` returns `ok: true` with - // a parsed version, the cookie is good and we surface a - // success banner. Failure surfaces as an error and the header - // is still saved on the form so the user can tweak. - const startSignin = async () => { - if (busy()) return - const trimmedUrl = url().trim() - if (!trimmedUrl) { - setError(tuiT("boot.remote.urlRequiredToSignIn")) - setFocused("url") - return - } - if (!isHttpUrl(trimmedUrl)) { - setError(tuiT("boot.remote.urlMustStart")) - setFocused("url") - return - } - setError(undefined) - setSignin({ kind: "browser-open", url: trimmedUrl }) - await open(trimmedUrl).catch(() => undefined) - setSignin({ kind: "paste", pasted: "" }) - } - - const cancelSignin = () => { - setSignin({ kind: "idle" }) - } - const clearCache = async () => { if (!props.existing || busy()) return setClearingCache(true) @@ -378,7 +315,7 @@ export function RemoteInstanceForm(props: { message: tuiT("boot.remote.clearCacheNotice", { size: (cleared.bytes / 1024 / 1024).toFixed(1) }), }) await refreshCacheInfo() - if (focused() === "clearCache") setFocused("signin") + if (focused() === "clearCache") setFocused("ignoreCert") } catch (err) { setCacheNotice({ ok: false, @@ -389,68 +326,6 @@ export function RemoteInstanceForm(props: { } } - const submitSigninPaste = async () => { - const phase = signin() - if (phase.kind !== "paste") return - const line = phase.pasted.trim() - if (!line) { - cancelSignin() - return - } - const colon = line.indexOf(":") - if (colon <= 0) { - setSignin({ - kind: "result", - ok: false, - message: tuiT("boot.remote.invalidHeader", { header: line.slice(0, 40) }), - }) - return - } - const name = line.slice(0, colon).trim() - const value = line.slice(colon + 1).trim() - if (!name || !value) { - setSignin({ - kind: "result", - ok: false, - message: tuiT("boot.remote.headerNameValueRequired"), - }) - return - } - // Merge into headers blob (case-insensitive dedupe — last write wins). - const parsed = parseHeaderLines(headersText()) - const existingKey = Object.keys(parsed).find((k) => k.toLowerCase() === name.toLowerCase()) - if (existingKey) delete parsed[existingKey] - parsed[name] = value - setHeadersText(formatHeaderLines(parsed)) - // Re-probe with the new headers to confirm the auth proxy lets us in. - setSignin({ kind: "verifying" }) - const candidate = buildInstance() - if (!candidate) { - setSignin({ kind: "result", ok: false, message: tuiT("boot.remote.saveFailedInvalidForm") }) - return - } - const result = await props.service.probe(candidate) - if (result.ok && result.version) { - setSignin({ kind: "result", ok: true, message: tuiT("boot.remote.authenticated", { version: result.version }) }) - return - } - if (result.ok && !result.version) { - setSignin({ - kind: "result", - ok: false, - message: tuiT("boot.remote.headerSavedButNoVersion"), - }) - return - } - setSignin({ - kind: "result", - ok: false, - message: tuiT("boot.remote.headerSavedButProbeFailed", { - message: !result.ok && result.status ? `HTTP ${result.status}` : !result.ok ? result.error : "(unknown)", - }), - }) - } - const moveFocus = (delta: 1 | -1) => { const order = fields() const idx = Math.max(0, order.indexOf(focused())) @@ -459,53 +334,14 @@ export function RemoteInstanceForm(props: { } // Single-source keyboard handler for the form. Action keys (Ctrl+S, - // Ctrl+P, Ctrl+G, Esc, Tab) work regardless of which field is - // focused; field editing keys mutate the focused field's signal. + // Ctrl+P, Esc, Tab) work regardless of which field is focused; field + // editing keys mutate the focused field's signal. useKeyboard((evt) => { if (busy()) { if (evt.ctrl && evt.name === "c") props.onDone({ cancel: true }) return } - // Sign-in paste mode owns the keyboard until cancelled or submitted. - if (inSignin()) { - const phase = signin() - if (phase.kind === "browser-open") { - // Once the browser has been launched, immediately accept paste. - setSignin({ kind: "paste", pasted: "" }) - } - const current = signin() - if (current.kind !== "paste") return - if (evt.ctrl && evt.name === "c") return cancelSignin() - if (evt.name === "escape") return cancelSignin() - if (evt.name === "return") return void submitSigninPaste() - if (evt.name === "backspace") { - setSignin({ kind: "paste", pasted: current.pasted.slice(0, -1) }) - return - } - if (evt.ctrl && evt.name === "u") { - setSignin({ kind: "paste", pasted: "" }) - return - } - if (evt.sequence && !evt.ctrl && !evt.meta) { - // Bracketed paste sends the whole pasted blob in `evt.sequence`. - // Single keystrokes also arrive as `evt.sequence` of length 1. - // Strip control chars but preserve everything printable. - const cleaned = evt.sequence.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, "") - if (cleaned) { - setSignin({ kind: "paste", pasted: current.pasted + cleaned }) - } - } - return - } - - // Result/info banner: any key dismisses. - if (signin().kind === "result") { - setSignin({ kind: "idle" }) - // fall through to normal handling for the same key — saves a - // keystroke when the user wants to immediately probe/save again. - } - if (evt.ctrl && evt.name === "c") return props.onDone({ cancel: true }) if (evt.name === "escape") return props.onDone({ cancel: true }) if (evt.name === "tab") return moveFocus(1) @@ -514,7 +350,6 @@ export function RemoteInstanceForm(props: { if (evt.ctrl && evt.name === "s") return void save() if (evt.ctrl && evt.name === "p") return void probeNow() if (evt.ctrl && evt.name === "k" && cacheAvailable()) return void clearCache() - if (evt.ctrl && evt.name === "g") return void startSignin() const f = focused() @@ -538,46 +373,7 @@ export function RemoteInstanceForm(props: { } return } - if (f === "signin") { - if (evt.name === "return" || evt.name === "space" || evt.sequence === " ") { - return void startSignin() - } - return - } - - // Headers field: multi-line text. Enter inserts a newline; pasted - // blobs (including newline characters) land verbatim. Backspace - // removes one char, including newlines, so the user can correct - // typos without leaving the field. - if (f === "headers") { - if (evt.name === "return") { - setHeadersText(headersText() + "\n") - if (probe().status !== "idle") setProbe({ status: "idle" }) - return - } - if (evt.name === "backspace") { - setHeadersText(headersText().slice(0, -1)) - if (probe().status !== "idle") setProbe({ status: "idle" }) - return - } - if (evt.ctrl && evt.name === "u") { - setHeadersText("") - if (probe().status !== "idle") setProbe({ status: "idle" }) - return - } - if (evt.sequence && !evt.ctrl && !evt.meta) { - // Preserve newlines from bracketed paste; strip other control - // chars so weird terminal artefacts don't sneak into headers. - const cleaned = evt.sequence.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "") - if (cleaned) { - setHeadersText(headersText() + cleaned) - if (probe().status !== "idle") setProbe({ status: "idle" }) - } - } - return - } - - // Single-line text fields: label / url / username / password. + // Single-line text fields: label / url / username / password / OTP. const get = valueFor const set = setterFor(f) if (evt.name === "backspace") { @@ -615,26 +411,6 @@ export function RemoteInstanceForm(props: { return value ? "•".repeat(value.length) : "" }) - const headersDisplay = createMemo(() => { - // For the TextField primitive (single-line cursor), show only the - // last line + a "(N more)" hint. Multi-line preview is rendered - // separately below. - const blob = headersText() - if (!blob) return "" - const lines = blob.split("\n") - if (lines.length === 1) return redactHeaderLineForDisplay(lines[0] ?? "") - const last = lines[lines.length - 1] ?? "" - return redactHeaderLineForDisplay(last) - }) - - const headersHint = createMemo(() => { - const blob = headersText() - const lines = blob ? blob.split("\n") : [] - const nonEmpty = lines.filter((l) => l.trim()).length - if (nonEmpty === 0) return tuiT("boot.remote.headersHintEmpty") - return tuiT(nonEmpty === 1 ? "boot.remote.headersHintCount.one" : "boot.remote.headersHintCount.other", { count: nonEmpty }) - }) - const probeBanner = createMemo(() => { const p = probe() if (p.status === "checking") return { variant: "info" as const, text: tuiT("boot.remote.probing") } @@ -648,18 +424,6 @@ export function RemoteInstanceForm(props: { if (p.status === "error") return { variant: "error" as const, text: tuiT("boot.remote.probeFailed", { message: p.message }) } return undefined }) - const signinBrowserUrl = createMemo(() => { - const current = signin() - return current.kind === "browser-open" ? current.url : undefined - }) - const signinPaste = createMemo(() => { - const current = signin() - return current.kind === "paste" ? current.pasted : undefined - }) - const signinResult = createMemo(() => { - const current = signin() - return current.kind === "result" ? current : undefined - }) return ( @@ -730,25 +494,22 @@ export function RemoteInstanceForm(props: { /> - - - l.trim()).length > 1 || (headersText().includes("\n") && focused() === "headers")}> - - - {(line) => ( - {line ? redactHeaderLineForDisplay(line) : " "} - )} - - - + + {tuiT("boot.remote.loginHint")} + + + + + + - - - {focused() === "signin" ? "▍ " : " "} - - - [ {tuiT("boot.remote.signInBrowser")} ] - - {tuiT("boot.remote.signInHint")} - - - - - {tuiT("boot.remote.signInOpened", { url: signinBrowserUrl() ?? url().trim() })} - - - - - - - {tuiT("boot.remote.signInPaste")} - - {signinPaste() ? redactHeaderLineForDisplay(signinPaste()!) : " "} - - - {tuiT("boot.remote.signInPasteHint")} - {tuiT("boot.remote.signInPasteExample")} - - - - - - {tuiT("boot.remote.signInVerifying")} - - - - - - {signinResult()?.message ?? ""} - - - - + {probeBanner()!.text} @@ -839,7 +559,6 @@ export function RemoteInstanceForm(props: { { keys: "ctrl+s", label: tuiT("common.save") }, { keys: "ctrl+p", label: tuiT("common.probe") }, ...(cacheAvailable() ? [{ keys: "ctrl+k", label: tuiT("boot.remote.clearCache") }] : []), - { keys: "ctrl+g", label: tuiT("common.signIn") }, { keys: "tab/↑↓", label: tuiT("common.navigate") }, { keys: "esc", label: tuiT("common.cancel") }, { keys: "ctrl+c", label: tuiT("common.quit") }, diff --git a/packages/codeplane/src/tui/i18n.ts b/packages/codeplane/src/tui/i18n.ts index 746f7b4931..c6e20a362a 100644 --- a/packages/codeplane/src/tui/i18n.ts +++ b/packages/codeplane/src/tui/i18n.ts @@ -24,7 +24,6 @@ const en = { "common.required": "Required", "common.save": "Save", "common.search": "Search", - "common.signIn": "Sign in", "common.spaceToToggle": "Space toggles", "common.up": "Up", "common.update": "Update", @@ -69,42 +68,33 @@ const en = { "boot.remote.url": "URL", "boot.remote.urlPlaceholder": "https://codeplane.example.com", "boot.remote.urlHint": "https:// or http://", - "boot.remote.username": "Basic Auth username", - "boot.remote.password": "Basic Auth password", - "boot.remote.headers": "Custom request headers", - "boot.remote.headersPlaceholder": "Authorization: Bearer ...", + "boot.remote.username": "Username", + "boot.remote.password": "Password", + "boot.remote.otp": "One-time code", + "boot.remote.otpHint": "shown only when the server requires a second factor", + "boot.remote.otpVerified": "OTP verified", + "boot.remote.otpVerifiedHint": "verified for this saved password", "boot.remote.ignoreCert": "Trust self-signed TLS certificates", "boot.remote.ignoreCertHint": "only enable for trusted internal / dev instances", "boot.remote.clearCache": "Clear local cache", "boot.remote.clearCacheNotice": "Cleared {{size}} MB of local cache for this instance.", "boot.remote.clearCacheSummary": "{{size}} MB cached for this instance.", - "boot.remote.signInBrowser": "Sign in via browser", - "boot.remote.signInOpened": "Opened {{url}} in your default browser. Sign in there, copy the auth header (Cookie, Authorization, ...) from DevTools, then paste below.", - "boot.remote.signInHint": "Open the sign-in page and paste the captured header back here.", - "boot.remote.signInPaste": "Paste header", - "boot.remote.signInPasteHint": "Format: Name: value · Return verifies · Esc cancels · Ctrl+U clears", - "boot.remote.signInPasteExample": "Example: Cookie: session=... or Authorization: Bearer ...", - "boot.remote.signInVerifying": "Verifying captured header against /global/version...", - "boot.remote.headersHintEmpty": "one Name: Value per line — Enter for newline", - "boot.remote.headersHintCount.one": "{{count}} header — Enter newline, Ctrl+U clear", - "boot.remote.headersHintCount.other": "{{count}} headers — Enter newline, Ctrl+U clear", - "boot.remote.probing": "Probing /global/version...", + "boot.remote.probing": "Checking auth and /global/version...", "boot.remote.probeOk": "Server reachable. Reports v{{version}}.", - "boot.remote.probeOkNoVersion": "Server reachable but did not return a version (auth proxy?). Use Ctrl+G to sign in via browser.", + "boot.remote.probeOkNoVersion": "Server reachable but did not return a version.", "boot.remote.probeFailed": "Probe failed: {{message}}", - "boot.remote.urlRequiredToSignIn": "URL required to sign in", - "boot.remote.invalidHeader": "Invalid header \"{{header}}...\". Use NAME: VALUE.", - "boot.remote.headerNameValueRequired": "Both NAME and VALUE must be non-empty.", - "boot.remote.saveFailedInvalidForm": "Save failed: invalid form state.", - "boot.remote.authenticated": "Authenticated. Server reports v{{version}}.", - "boot.remote.headerSavedButNoVersion": "Header saved but server still did not return a version (auth proxy may need more headers).", - "boot.remote.headerSavedButProbeFailed": "Header saved but probe still failed: {{message}}", "boot.remote.labelRequired": "Label is required", "boot.remote.urlRequired": "URL is required", "boot.remote.urlMustStart": "URL must start with http:// or https://", "boot.remote.optional": "(optional)", - "boot.remote.usernameHint": "leave empty if the server does not use Basic Auth", - "boot.remote.passwordHint": "leave empty if the server does not use Basic Auth", + "boot.remote.loginHint": "Use the username and password from codeplane serve --password. OTP appears only when required.", + "boot.remote.authInvalidPassword": "Username or password is incorrect.", + "boot.remote.authOtpRequired": "Enter the one-time code for this server.", + "boot.remote.authOtpInvalid": "One-time code is incorrect.", + "boot.remote.authOtpRateLimited": "Too many attempts. Try again later.", + "boot.remote.authOtpFailed": "Could not verify the one-time code.", + "boot.remote.usernameHint": "defaults to codeplane when blank", + "boot.remote.passwordHint": "leave empty if the server does not require a password", "boot.remote.passwordMaskedHint": "masked — Ctrl+U to clear", "boot.remote.localManagedHint": "This instance is still managed locally. These fields only change its saved remote access settings; use Update to change the local binary version.", diff --git a/packages/desktop/e2e/desktop.spec.ts b/packages/desktop/e2e/desktop.spec.ts index c3315a50bd..5d97f9f48a 100644 --- a/packages/desktop/e2e/desktop.spec.ts +++ b/packages/desktop/e2e/desktop.spec.ts @@ -89,6 +89,15 @@ async function attachIfExists(testInfo: TestInfo, name: string, file: string, co const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const basicAuthHeader = (username: string, password: string) => + `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}` + +async function readRequestBody(request: http.IncomingMessage) { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + return Buffer.concat(chunks).toString("utf8") +} + function createFixtureAssets(version: string, label: string) { const html = ` @@ -442,7 +451,15 @@ async function startFixtureServer( slug: string, version: string, label: string, - options?: { assetDelayMs?: number }, + options?: { + assetDelayMs?: number + auth?: { + otpCode: string + otpToken: string + password: string + username: string + } + }, ) { let currentVersion = version let currentLabel = label @@ -472,6 +489,47 @@ async function startFixtureServer( ts: new Date().toISOString(), }) + const validPassword = options?.auth + ? request.headers.authorization === basicAuthHeader(options.auth.username, options.auth.password) + : true + const otpHeader = request.headers["x-codeplane-otp"] + const validOtp = options?.auth + ? (Array.isArray(otpHeader) ? otpHeader[0] : otpHeader) === options.auth.otpToken + : true + + if (options?.auth && url.pathname === "/global/auth") { + sendJson(response, { + authenticated: validPassword && validOtp, + passwordValid: validPassword, + required: true, + totpRequired: validPassword, + }) + return + } + + if (options?.auth && url.pathname === "/global/auth/verify") { + if (!validPassword) { + response.writeHead(401, { "Content-Type": "application/json; charset=utf-8" }) + response.end(`${JSON.stringify({ totp: false })}\n`) + return + } + const body: unknown = JSON.parse((await readRequestBody(request)) || "{}") + const code = body && typeof body === "object" && "code" in body ? body.code : undefined + if (code !== options.auth.otpCode) { + response.writeHead(401, { "Content-Type": "application/json; charset=utf-8" }) + response.end(`${JSON.stringify({ totp: true })}\n`) + return + } + sendJson(response, { token: options.auth.otpToken }) + return + } + + if (options?.auth && (!validPassword || !validOtp)) { + response.writeHead(401, { "Content-Type": "application/json; charset=utf-8" }) + response.end(`${JSON.stringify({ error: "unauthorized" })}\n`) + return + } + if (url.pathname === "/global/version") { sendJson(response, { current: currentVersion }) return @@ -906,8 +964,11 @@ test("logs setup actions and opens cached desktop UI", async ({}, testInfo) => { await addInstanceButton.click() await page.locator('[data-desktop-action="pick-remote"]').click() await expect(page.getByRole("heading", { name: "Add a remote instance" })).toBeVisible() - await page.locator('[data-desktop-action="advanced-toggle"]').click() - await expect(page.locator('[data-desktop-field="instance-headers"]')).toBeVisible() + await expect(page.locator('[data-desktop-field="instance-basic-username"]')).toBeVisible() + await expect(page.locator('[data-desktop-field="instance-basic-password"]')).toBeVisible() + await expect(page.locator('[data-desktop-field="instance-otp"]')).toHaveCount(0) + await expect(page.locator('[data-desktop-field="instance-headers"]')).toHaveCount(0) + await expect(page.locator('[data-desktop-action="advanced-toggle"]')).toHaveCount(0) await page.locator('[data-desktop-action="form-cancel"]').click() await expect(page.getByText("Connect to your instance")).toBeVisible() @@ -915,9 +976,10 @@ test("logs setup actions and opens cached desktop UI", async ({}, testInfo) => { await page.locator('[data-desktop-action="pick-remote"]').click() await page.locator('[data-desktop-field="instance-name"]').fill("Primary workspace") await page.locator('[data-desktop-field="instance-url"]').fill(server.origin) + await page.locator('[data-desktop-field="instance-basic-username"]').fill("alice") + await page.locator('[data-desktop-field="instance-basic-password"]').fill("secret") await expect(page.getByText(`Reachable. Detected Codeplane ${appVersion}.`)).toBeVisible() - await page.locator('[data-desktop-action="advanced-toggle"]').click() - await page.locator('[data-desktop-field="instance-headers"]').fill("x-test-header: desktop") + await expect(page.locator('[data-desktop-field="instance-otp"]')).toHaveCount(0) await page.locator('[data-desktop-field="ignore-certificates"]').check() await page.locator('[data-desktop-action="instance-save"]').click() @@ -990,7 +1052,6 @@ test("logs setup actions and opens cached desktop UI", async ({}, testInfo) => { expect(hasAction("instance-add")).toBe(true) expect(hasAction("picker-back")).toBe(true) expect(hasAction("form-cancel")).toBe(true) - expect(hasAction("advanced-toggle")).toBe(true) expect(hasAction("instance-save")).toBe(true) expect(hasAction("desktop-update-check")).toBe(true) expect(hasAction("instance-open")).toBe(true) @@ -1038,6 +1099,60 @@ test("logs setup actions and opens cached desktop UI", async ({}, testInfo) => { } }) +test("shows OTP only after a password-protected remote server requires it", async ({}, testInfo) => { + const server = await startFixtureServer(testInfo, "otp", appVersion, "OTP workspace", { + auth: { + otpCode: "123456", + otpToken: "verified-otp-token", + password: "secret", + username: "alice", + }, + }) + let app: Awaited> | undefined + + try { + const runtime = await launchDesktop(testInfo) + app = runtime.app + let page = runtime.page + + await page.getByLabel("Add instance").first().click() + await page.locator('[data-desktop-action="pick-remote"]').click() + await page.locator('[data-desktop-field="instance-name"]').fill("OTP workspace") + await page.locator('[data-desktop-field="instance-url"]').fill(server.origin) + await expect(page.locator('[data-desktop-field="instance-otp"]')).toHaveCount(0) + + await page.locator('[data-desktop-field="instance-basic-username"]').fill("alice") + await page.locator('[data-desktop-field="instance-basic-password"]').fill("secret") + await expect(page.locator('[data-desktop-field="instance-otp"]')).toHaveCount(0) + + await page.locator('[data-desktop-action="instance-save"]').click() + await expect(page.locator('[data-desktop-field="instance-otp"]')).toBeVisible() + await expect(page.getByText("Enter the one-time code for this server.")).toBeVisible() + + await page.locator('[data-desktop-field="instance-otp"]').fill("123456") + await page.locator('[data-desktop-action="instance-save"]').click() + await expect(page.locator('[data-desktop-state="prepare"]')).toBeVisible() + await expect(page.getByText("Connect to your instance")).toBeVisible({ timeout: 15_000 }) + + const instanceWindowPromise = app.waitForEvent("window") + await page.locator('[data-desktop-action="instance-open"]').first().click() + page = await instanceWindowPromise + await page.waitForLoadState("domcontentloaded") + + await expect(page.getByTestId("fixture-server-version")).toHaveText(appVersion) + await expect(page.getByTestId("fixture-providers")).toHaveText(/^ok:/) + + const requests = await readJsonLines(server.logFile) + expect(requests.some((entry) => entry.pathname === "/global/auth")).toBe(true) + expect(requests.some((entry) => entry.pathname === "/global/auth/verify")).toBe(true) + } finally { + if (app) await app.close() + await server.close() + await attachIfExists(testInfo, "desktop-log", testInfo.outputPath("desktop-runtime/logs/desktop.log")) + await attachIfExists(testInfo, "otp-server-log", server.logFile) + } +}) + test("downloads separate cached UI bundles per server version", async ({}, testInfo) => { const primary = await startFixtureServer(testInfo, "primary", appVersion, "Primary workspace") const legacy = await startFixtureServer(testInfo, "legacy", "26.4.0", "Legacy workspace") diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 52b0b2892e..fdbc368978 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -67,6 +67,7 @@ import { import { reconnectOverlayScript } from "./reconnect-overlay" import { codeplaneDesktopReleaseTag, codeplaneReleaseTag, CodeplaneVersion } from "@codeplane-ai/shared/version" import type { SavedInstance } from "@codeplane-ai/shared/instance" +import { checkRemoteAuth, verifyRemoteTotp } from "@codeplane-ai/shared/remote-auth" /** * Codeplane desktop shell. @@ -76,9 +77,8 @@ import type { SavedInstance } from "@codeplane-ai/shared/instance" * the selected server version into a local cache, and serves that UI from * a local host for fast subsequent launches. * - * Users can additionally configure per-instance auth headers (e.g. CF - * Access service tokens, internal API keys) that get attached to every - * outbound request to that instance via the session's webRequest API. + * Users can additionally configure per-instance login credentials that are + * encoded as auth headers and attached to outbound requests to that instance. * * The desktop shell never embeds the backend. Local runtime install/update * flows are driven through the shared npm package pipeline so desktop and @@ -780,8 +780,8 @@ function ensureSession(instance: SavedInstance): Session { if (configuredPartitions.has(partition)) return ses configuredPartitions.add(partition) - // Inject per-instance auth headers (CF Access, bearer tokens, …) on all - // outbound HTTP requests for this session. We never overwrite headers the + // Inject per-instance login headers on all outbound HTTP requests for this + // session. We never overwrite headers the // page itself already set, and we skip browser-managed headers so we don't // break CORS/credentials behaviour. // @@ -2560,6 +2560,26 @@ function setupIpc() { } return ids }) + ipcMain.handle("instances:auth-status", async (_event, input: SavedInstance) => { + const target = asUrl(input.url) + if (!target) { + return { reachable: false, required: false, authenticated: false, totpRequired: false, passwordValid: false } + } + const ses = ensureSession(input) + const nativeFetch = "fetch" in ses && typeof ses.fetch === "function" ? ses.fetch.bind(ses) : fetch + return checkRemoteAuth({ url: target.toString(), headers: input.headers }, nativeFetch, { timeoutMs: 8000 }) + }) + ipcMain.handle("instances:verify-otp", async (_event, input: { instance: SavedInstance; code: string }) => { + const target = asUrl(input.instance.url) + if (!target) return { ok: false as const, reason: "unreachable" as const } + const ses = ensureSession(input.instance) + const nativeFetch = "fetch" in ses && typeof ses.fetch === "function" ? ses.fetch.bind(ses) : fetch + return verifyRemoteTotp( + { url: target.toString(), headers: input.instance.headers, code: input.code }, + nativeFetch, + { timeoutMs: 8000 }, + ) + }) ipcMain.handle("instances:probe", async (_event, input: string | DesktopHostInstance) => { const instance = typeof input === "string" @@ -2639,127 +2659,6 @@ function setupIpc() { }, ) - // Open a child BrowserWindow at the instance URL so the user can sign in - // through whatever auth proxy sits in front of it (Cloudflare Access, - // identity-aware proxy, custom SSO redirect). When the child window - // either reaches the instance origin successfully (HTTP 200 from the - // version endpoint via its own session) or the user closes it, we - // collect the cookies set on the instance origin and return them as a - // single Cookie header line. The caller (setup form) merges that into - // the saved instance's headers blob, so future requests carry the - // proof-of-auth cookies until they expire — at which point the existing - // bounce-to-Loader auth-required flow tells the user to sign in again. - ipcMain.handle( - "instances:sign-in-with-browser", - async ( - _event, - input: { id: string; url: string }, - ): Promise<{ ok: true; cookieHeader: string; cookieCount: number } | { ok: false; error: string }> => { - const target = asUrl(input.url) - if (!target) return { ok: false, error: "Invalid URL" } - logger.log("main", "instances.sign-in-with-browser.start", { id: input.id, url: target.toString() }) - - // Use the same per-instance session as the main window so any cookies - // captured here are immediately available to the production load. - const probeInstance: SavedInstance = { id: input.id, url: target.toString() } - const ses = ensureSession(probeInstance) - - const child = new BrowserWindow({ - width: 540, - height: 720, - title: `Sign in to ${target.host}`, - autoHideMenuBar: true, - webPreferences: { - session: ses, - partition: undefined, - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - }, - }) - - const collectCookieHeader = async (): Promise<{ count: number; line: string }> => { - // Cookies set on subdomains and parents both apply to the instance - // origin per RFC 6265. Pull the union and dedupe by name (last write - // wins so the freshest value from the sign-in flow wins over any - // stale one already in the jar). - const cookies = await ses.cookies.get({ url: target.toString() }) - const seen = new Map() - for (const cookie of cookies) { - if (!cookie.name) continue - seen.set(cookie.name, cookie.value) - } - const line = Array.from(seen.entries()) - .map(([name, value]) => `${name}=${value}`) - .join("; ") - return { count: seen.size, line } - } - - try { - await new Promise((resolve, reject) => { - let settled = false - const finish = () => { - if (settled) return - settled = true - resolve() - } - const fail = (error: Error) => { - if (settled) return - settled = true - reject(error) - } - child.on("closed", () => finish()) - // If the user finishes auth and lands back on the instance origin - // root, we treat that as a success signal and auto-close the - // child window after a short grace so any setting cookies from - // the redirect chain land first. - child.webContents.on("did-navigate", (_event, navigatedUrl) => { - const u = asUrl(navigatedUrl) - if (!u || u.origin !== target.origin) return - // Probe the version endpoint via the child session; if it - // returns 200 with a JSON body, the auth proof is in place. - void (async () => { - try { - const fetchFn = - "fetch" in ses && typeof ses.fetch === "function" ? ses.fetch.bind(ses) : fetch - const response = await fetchFn(new URL("global/version", target).toString(), { - method: "GET", - redirect: "follow", - }) - if (!response.ok) return - const body = (await response.json().catch(() => ({}))) as { current?: unknown } - if (typeof body.current !== "string") return - logger.log("main", "instances.sign-in-with-browser.success-detected", { - id: input.id, - url: target.toString(), - }) - setTimeout(() => { - if (!child.isDestroyed()) child.close() - }, 800) - } catch { - // Silent — keep the window open and let the user continue. - } - })() - }) - void child.loadURL(target.toString()).catch((err) => fail(err instanceof Error ? err : new Error(String(err)))) - }) - - const collected = await collectCookieHeader() - if (collected.count === 0) { - logger.log("main", "instances.sign-in-with-browser.no-cookies", { id: input.id }) - return { ok: false, error: "No cookies were set during the sign-in flow." } - } - logger.log("main", "instances.sign-in-with-browser.success", { - cookieCount: collected.count, - id: input.id, - }) - return { ok: true, cookieHeader: collected.line, cookieCount: collected.count } - } catch (error) { - logger.log("main", "instances.sign-in-with-browser.error", { error, id: input.id }) - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } - }, - ) ipcMain.handle("system-permissions:check", async () => { const permissions: DesktopSystemPermissionStatus[] = [] diff --git a/packages/desktop/src/main/preload.ts b/packages/desktop/src/main/preload.ts index 23fe11d029..6267dc7cdc 100644 --- a/packages/desktop/src/main/preload.ts +++ b/packages/desktop/src/main/preload.ts @@ -7,6 +7,7 @@ import type { PrepareProgress, SavedInstance, } from "@codeplane-ai/shared/instance" +import type { RemoteAuthStatus, VerifyRemoteTotpResult } from "@codeplane-ai/shared/remote-auth" type DesktopStorageApi = { getItem: (storageName: string | undefined, key: string) => string | null @@ -115,10 +116,10 @@ const api = { status?: number error?: string }>, - signInWithBrowser: (input: { id: string; url: string }) => - ipcRenderer.invoke("instances:sign-in-with-browser", input) as Promise< - { ok: true; cookieHeader: string; cookieCount: number } | { ok: false; error: string } - >, + authStatus: (input: SavedInstance) => + ipcRenderer.invoke("instances:auth-status", input) as Promise, + verifyOtp: (input: { instance: SavedInstance; code: string }) => + ipcRenderer.invoke("instances:verify-otp", input) as Promise, }, auth: { openExternal: (url: string) => ipcRenderer.invoke("auth:open-external", url) as Promise, diff --git a/packages/desktop/src/setup/app.tsx b/packages/desktop/src/setup/app.tsx index 1eee287d4c..e3b5cc3a5a 100644 --- a/packages/desktop/src/setup/app.tsx +++ b/packages/desktop/src/setup/app.tsx @@ -9,7 +9,7 @@ import { Switch } from "@codeplane-ai/ui/switch" import { Mark } from "@codeplane-ai/ui/logo" import { showToast } from "@codeplane-ai/ui/toast" import { instanceEditorKind, type LocalTarget, type OpenProgress, type PrepareProgress as PrepareState, type SavedInstance } from "@codeplane-ai/shared/instance" -import { formatHeaders as serializeHeaders, parseHeaders as parseHeaderInput } from "@codeplane-ai/shared/headers" +import { composeRemoteAuthHeaders, splitRemoteAuthHeaders } from "@codeplane-ai/shared/remote-auth" import type { CodeplaneDesktopAPI } from "../main/preload" type LocalInstallState = { @@ -181,9 +181,6 @@ const InstanceCacheSection: Component<{ ) } -const parseHeaders = parseHeaderInput -const formatHeaders = (headers: Record | undefined) => serializeHeaders(headers, "newline") - type NotificationSettingsState = { agent: boolean permissions: boolean @@ -1163,58 +1160,24 @@ const InstanceForm: Component<{ onCancel: () => void onSaved: () => void }> = (props) => { - // If the saved instance already has an "Authorization: Basic …" header, - // peel it off so the dedicated username/password fields can pre-fill from - // it. Anything that isn't the Basic auth header stays in the headers blob - // so existing CF Access / SSO pastes don't get clobbered. - const splitBasicAuth = (h: Record | undefined) => { - if (!h) return { user: "", pass: "", rest: undefined as Record | undefined } - const authKey = Object.keys(h).find((k) => k.toLowerCase() === "authorization") - const authVal = authKey ? h[authKey] : undefined - const m = authVal && /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(authVal.trim()) - if (!authKey || !m) return { user: "", pass: "", rest: h } - try { - const decoded = atob(m[1]) - const idx = decoded.indexOf(":") - if (idx === -1) return { user: "", pass: "", rest: h } - const rest = { ...h } - delete rest[authKey] - return { - user: decoded.slice(0, idx), - pass: decoded.slice(idx + 1), - rest: Object.keys(rest).length ? rest : undefined, - } - } catch { - return { user: "", pass: "", rest: h } - } - } - const initialAuth = splitBasicAuth(props.editing?.headers) + const initialAuth = splitRemoteAuthHeaders(props.editing?.headers) const [label, setLabel] = createSignal(props.editing?.label ?? "") const [url, setUrl] = createSignal(props.editing?.url ?? "") - const [basicUsername, setBasicUsername] = createSignal(initialAuth.user) - const [basicPassword, setBasicPassword] = createSignal(initialAuth.pass) - const [headers, setHeaders] = createSignal(formatHeaders(initialAuth.rest)) + const [basicUsername, setBasicUsername] = createSignal(initialAuth.username ?? "") + const [basicPassword, setBasicPassword] = createSignal(initialAuth.password ?? "") + const [otpToken, setOtpToken] = createSignal(initialAuth.otpToken ?? "") + const [otpCode, setOtpCode] = createSignal("") const [ignoreCert, setIgnoreCert] = createSignal(!!props.editing?.ignoreCertificateErrors) const [iconDataUrl, setIconDataUrl] = createSignal(props.editing?.iconDataUrl) - const [signingIn, setSigningIn] = createSignal(false) - const [advanced, setAdvanced] = createSignal( - !!initialAuth.rest || !!props.editing?.ignoreCertificateErrors, - ) + const [otpVisible, setOtpVisible] = createSignal(false) - // Compose the full headers map: dedicated Basic Auth fields override any - // Authorization line in the headers blob (keeps the form predictable when - // both are filled — the explicit field wins). const composedHeaders = (): Record | undefined => { - const parsed = parseHeaders(headers()) - const user = basicUsername().trim() - const pass = basicPassword() - if (user || pass) { - const authKey = Object.keys(parsed).find((k) => k.toLowerCase() === "authorization") - if (authKey) delete parsed[authKey] - parsed["Authorization"] = `Basic ${btoa(`${user}:${pass}`)}` - } - return Object.keys(parsed).length ? parsed : undefined + return composeRemoteAuthHeaders({ + username: basicUsername(), + password: basicPassword(), + otpToken: otpToken(), + }) } const [probe, setProbe] = createSignal<{ status: "idle" | "ok" | "error" | "checking"; message?: string }>({ status: "idle", @@ -1266,6 +1229,67 @@ const InstanceForm: Component<{ offPrepare() }) + const draftInstance = (headers = composedHeaders()): SavedInstance => ({ + id: props.editing?.id ?? uid(), + url: url().trim(), + label: label().trim() || undefined, + headers, + ignoreCertificateErrors: ignoreCert() || undefined, + clientCertSubject: props.editing?.clientCertSubject, + iconDataUrl: iconDataUrl() || undefined, + }) + + const resolveAuthHeaders = async (): Promise<{ ok: true; headers?: Record } | { ok: false; message: string }> => { + const base = draftInstance() + const status = await api.instances.authStatus(base) + if (!status.reachable) return { ok: true, headers: base.headers } + if (!status.required) { + setOtpVisible(false) + setOtpToken("") + setOtpCode("") + return { ok: true, headers: composeRemoteAuthHeaders({ username: basicUsername(), password: basicPassword() }) } + } + if (status.authenticated && !status.totpRequired) { + setOtpVisible(false) + setOtpToken("") + setOtpCode("") + return { ok: true, headers: composeRemoteAuthHeaders({ username: basicUsername(), password: basicPassword() }) } + } + if (!status.passwordValid) { + setOtpVisible(false) + setOtpToken("") + return { ok: false, message: "Username or password is incorrect." } + } + if (!status.totpRequired) return { ok: false, message: "Username or password is incorrect." } + setOtpVisible(true) + if (!otpCode().trim()) return { ok: false, message: "Enter the one-time code for this server." } + const verified = await api.instances.verifyOtp({ + instance: draftInstance(composeRemoteAuthHeaders({ username: basicUsername(), password: basicPassword() })), + code: otpCode(), + }) + if (!verified.ok) { + return { + ok: false, + message: + verified.reason === "invalid-code" + ? "One-time code is incorrect." + : verified.reason === "rate-limited" + ? "Too many attempts. Try again later." + : "Could not verify the one-time code.", + } + } + setOtpToken(verified.token) + setOtpCode("") + return { + ok: true, + headers: composeRemoteAuthHeaders({ + username: basicUsername(), + password: basicPassword(), + otpToken: verified.token, + }), + } + } + const triggerProbe = () => { if (probeTimer) clearTimeout(probeTimer) const value = url().trim() @@ -1277,14 +1301,12 @@ const InstanceForm: Component<{ logSetup("probe.start", { url: value }) probeTimer = setTimeout(async () => { try { - const result = await api.instances.probe({ - id: props.editing?.id ?? uid(), - url: value, - label: label().trim() || undefined, - headers: composedHeaders(), - ignoreCertificateErrors: ignoreCert() || undefined, - clientCertSubject: props.editing?.clientCertSubject, - }) + const auth = await resolveAuthHeaders() + if (!auth.ok) { + setProbe({ status: "error", message: auth.message }) + return + } + const result = await api.instances.probe(draftInstance(auth.headers)) logSetup("probe.result", result) if (result.ok) { setProbe({ @@ -1329,15 +1351,12 @@ const InstanceForm: Component<{ } setSaving(true) try { - const instance: SavedInstance = { - id: props.editing?.id ?? uid(), - url: url().trim(), - label: label().trim() || undefined, - headers: composedHeaders(), - ignoreCertificateErrors: ignoreCert() || undefined, - clientCertSubject: props.editing?.clientCertSubject, - iconDataUrl: iconDataUrl() || undefined, + const auth = await resolveAuthHeaders() + if (!auth.ok) { + setProbe({ status: "error", message: auth.message }) + return } + const instance = draftInstance(auth.headers) logSetup("instance.save", instanceSummary(instance)) setPrepareID(instance.id) setPreparing({ @@ -1493,6 +1512,9 @@ const InstanceForm: Component<{ value={url()} onInput={(event) => { setUrl(event.currentTarget.value) + setOtpToken("") + setOtpCode("") + setOtpVisible(false) triggerProbe() }} class="rounded-md border border-border-weak-base bg-surface-raised-base px-3 py-2 text-[13px] text-text-strong outline-none transition-colors placeholder:text-text-weaker focus:border-border-interactive-base disabled:opacity-60" @@ -1511,151 +1533,73 @@ const InstanceForm: Component<{ - - - -
-
- HTTP Basic Auth - - For instances behind codeplane serve --password. - Username defaults to codeplane. - -
- setBasicUsername(event.currentTarget.value)} - /> - setBasicPassword(event.currentTarget.value)} - /> -
-
- -
- Sign in with browser (Access / SSO) - - For instances behind an access gateway, identity-aware proxy, or SSO redirect. - Opens a browser window at the instance URL, you sign in, and the resulting session cookies - are saved into the headers below. Re-run when the cookie expires — the desktop will also - bounce back here automatically with a "Sign-in required" toast when the server - starts returning 401/403. - - -
- -
- Custom request headers -