mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
51959532ad
* feat(auth): browser-based pad auth setup via /setup#token deep link (TASK-1216)
`pad auth setup` now hands the operator a deep link into the browser-based
/setup form by default, replacing the in-terminal email/name/password
prompts. The browser flow gives them password-manager support, HTML5
email validation, and the live strength meter at zero CLI cost — the
mechanism (logs-token bootstrap, /setup route, /api/v1/auth/session) was
already shipped by TASK-1167 / PLAN-1166 for the Unraid use case. This
just unifies the local-CLI install path onto the same flow.
New `internal/cli/bootstrap.go::RunBrowserBootstrap`:
- Reads <DataDir>/.bootstrap-token and prints
`<BrowserURL>/setup#token=<TOKEN>` with the token in the URL fragment
(not query) — fragments are scrubbed from the address bar by /setup's
onMount before paint, so the secret doesn't survive in browser
history (TASK-1167 F10).
- Polls /api/v1/auth/session every 2s; returns nil when
setup_required: false. Internal 5-min timeout uses a separate timer
(not context.WithTimeout) so caller-ctx cancellation surfaces as
ctx.Err() instead of being misreported as the helper's own timeout.
- Idempotent: returns early if setup is already done, without touching
the token file.
- Dispatches on session.setup_method — "logs_token" reads the token,
"open" (PAD_BYPASS_SETUP_TOKEN=true) prints a bare /setup URL,
"local_cli" / unknown returns an error directing the user to
--cli-prompt.
`pad auth setup` is rewired to call the helper, then chain doBrowserLogin
so the user ends up authenticated on the CLI — preserving the post-
condition of the legacy --cli-prompt path. Two browser approvals (admin
creation, CLI auth) but each is one click in a browser the operator
already has open.
The legacy TTY path lives on behind --cli-prompt as a zero-cost hedge
per IDEA-1179. Existing promptAndBootstrap / readPassword helpers are
left in place — TASK-1217 will audit whether they can be removed once
pad init is on the new flow too.
Tests in internal/cli/bootstrap_test.go cover: idempotent session check,
logs_token happy path, open mode, missing/empty token error paths,
local_cli + unknown method rejection, internal timeout firing with the
friendly message, caller-ctx cancellation propagating ctx.Err() (not
timeout error). bootstrapPollInterval / bootstrapPollTimeout are vars so
the timeout-branch test can run in 100ms instead of 5min.
Implements: IDEA-1179 (auth-setup half).
Out of scope: pad init integration → TASK-1217.
Out of scope: post-/setup workspace dead-end → IDEA-1215.
* docs(cli): clarify RunBrowserBootstrap caller staging across TASK-1216 / TASK-1217
Codex review (round 1) read the docstring and flagged that `pad init`
isn't on the new helper. That wiring is TASK-1217's scope by design (one
task = one PR per CONVE-2; TASK-1217 has a hard blocked-by link to
TASK-1216). Tighten the docstring to make the staging explicit so a
reader of the diff alone doesn't conclude it's a missing wire-up.
225 lines
9.3 KiB
Go
225 lines
9.3 KiB
Go
package cli
|
|
|
|
// Browser-driven first-admin bootstrap (TASK-1216 / IDEA-1179).
|
|
//
|
|
// `pad auth setup` and `pad init` previously prompted for email / name /
|
|
// password in the terminal, calling POST /api/v1/auth/bootstrap directly.
|
|
// That worked but lost out to the browser /setup flow on every UX axis:
|
|
// no password manager, no HTML5 email validation, no live strength meter.
|
|
//
|
|
// TASK-1167 / PLAN-1166 shipped a logs-token bootstrap so Docker / Unraid
|
|
// operators can claim the first admin from a remote browser. That same
|
|
// token sits at <DataDir>/.bootstrap-token on a local install, and the
|
|
// CLI runs as the same UID as the server, so we can read it directly and
|
|
// hand the operator a deep link into the same browser flow.
|
|
//
|
|
// RunBrowserBootstrap is the helper. It checks /api/v1/auth/session, reads
|
|
// the token if one is configured, prints the /setup URL, and polls until
|
|
// the session check reports setup_required: false (or 5 minutes elapses).
|
|
// Caller (setupCmd / pad init) is responsible for wiring up SIGINT to the
|
|
// passed-in context and for any post-bootstrap login plumbing.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/config"
|
|
)
|
|
|
|
// bootstrapTokenFilename mirrors internal/server/bootstrap.go's constant of
|
|
// the same name. Kept in sync rather than imported because internal/cli
|
|
// otherwise has no dependency on internal/server, and the filename is a
|
|
// trivial stable contract — both places own it together. If this ever
|
|
// drifts, the symptom is "bootstrap token file not found" on a server that
|
|
// did generate one, which is an immediate, loud failure.
|
|
const bootstrapTokenFilename = ".bootstrap-token"
|
|
|
|
// bootstrapPollInterval is how often the helper re-checks /api/v1/auth/session
|
|
// once the URL has been printed. 2s matches doBrowserLogin's CLI auth poll
|
|
// cadence — slow enough to not hammer the server, fast enough that the
|
|
// "✓ Setup complete" line lands within a couple seconds of the operator
|
|
// finishing the form. var (not const) so the test suite can shrink it for
|
|
// timing-sensitive assertions without making real users wait minutes.
|
|
var bootstrapPollInterval = 2 * time.Second
|
|
|
|
// bootstrapPollTimeout caps how long RunBrowserBootstrap waits for the
|
|
// browser side to finish. 5 minutes is generous for an interactive admin
|
|
// form and short enough that an abandoned terminal doesn't sit waiting
|
|
// indefinitely. The caller's SIGINT path can cut this short via ctx.
|
|
// var (not const) so tests can exercise the timeout branch in
|
|
// milliseconds.
|
|
var bootstrapPollTimeout = 5 * time.Minute
|
|
|
|
// RunBrowserBootstrap walks the operator through the browser-based first-
|
|
// admin bootstrap. Returns nil on success (server has flipped to
|
|
// setup_required: false), an error if the helper can't proceed (no token
|
|
// configured, token file unreadable, timeout, ctx cancelled).
|
|
//
|
|
// Callers in this PR: only setupCmd (`pad auth setup`). TASK-1217 adds
|
|
// `pad init` as the second caller — until that lands, `pad init` keeps
|
|
// using the legacy promptAndBootstrap path. Splitting the wiring across
|
|
// two PRs is deliberate (CONVE-2 "tasks should be PR-sized").
|
|
//
|
|
// On success the server has a first admin but the CLI has not been issued
|
|
// any credentials — the browser owns the session cookie. Callers that
|
|
// want the CLI to be authenticated afterwards should chain a CLI-auth-
|
|
// session login (see doBrowserLogin in cmd/pad/main.go) once this returns.
|
|
//
|
|
// The helper is idempotent: if the server already reports
|
|
// setup_required: false on entry, it returns nil immediately without
|
|
// touching the token file or printing anything. That matters for any
|
|
// caller invoking it against a server where setup is already done.
|
|
func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config) error {
|
|
if client == nil {
|
|
return errors.New("RunBrowserBootstrap: nil client")
|
|
}
|
|
if cfg == nil {
|
|
return errors.New("RunBrowserBootstrap: nil config")
|
|
}
|
|
|
|
session, err := client.CheckSession()
|
|
if err != nil {
|
|
return fmt.Errorf("check server status: %w", err)
|
|
}
|
|
if !session.SetupRequired {
|
|
// Already bootstrapped — nothing to do.
|
|
return nil
|
|
}
|
|
|
|
url, err := buildBootstrapURL(cfg, session.SetupMethod)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bold := color.New(color.Bold).SprintFunc()
|
|
fmt.Println()
|
|
fmt.Println(" Open this URL in your browser to finish setup:")
|
|
fmt.Println()
|
|
fmt.Printf(" %s\n", bold(url))
|
|
fmt.Println()
|
|
fmt.Println(" Waiting for setup to complete (Ctrl+C to cancel)...")
|
|
|
|
if err := pollUntilSetupDone(ctx, client); err != nil {
|
|
return err
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf(" %s Setup complete\n", green("✓"))
|
|
return nil
|
|
}
|
|
|
|
// buildBootstrapURL constructs the /setup URL the operator should open.
|
|
//
|
|
// setup_method dispatch (kept in sync with handleSessionCheck in
|
|
// internal/server/handlers_auth.go):
|
|
//
|
|
// - "logs_token" — server generated a one-time token and persisted it to
|
|
// <DataDir>/.bootstrap-token. We read it and hand the operator a deep
|
|
// link with the token in the URL fragment (#token=...). The fragment
|
|
// is scrubbed from the address bar by /setup's onMount before paint
|
|
// so the secret doesn't survive in browser history (TASK-1167 F10).
|
|
//
|
|
// - "open" — operator started the server with PAD_BYPASS_SETUP_TOKEN=true
|
|
// on a self-host deployment. The /setup form works directly, no token
|
|
// needed. We just print the bare /setup URL.
|
|
//
|
|
// - "local_cli" or "" — the server failed to provision a bootstrap token
|
|
// (read-only DataDir, etc.) and the bypass flag isn't set, so the only
|
|
// working path is the loopback-gated POST /api/v1/auth/bootstrap. The
|
|
// browser flow can't proceed; tell the user to use --cli-prompt.
|
|
//
|
|
// - anything else — newer server speaking a method this CLI doesn't know.
|
|
// Bail loudly with the same --cli-prompt fallback hint.
|
|
func buildBootstrapURL(cfg *config.Config, setupMethod string) (string, error) {
|
|
base := cfg.BrowserURL()
|
|
|
|
switch setupMethod {
|
|
case "logs_token":
|
|
token, err := readBootstrapToken(cfg.DataDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%s/setup#token=%s", base, token), nil
|
|
|
|
case "open":
|
|
return base + "/setup", nil
|
|
|
|
case "", "local_cli":
|
|
return "", fmt.Errorf("server has no bootstrap token configured (setup_method=%q); re-run with --cli-prompt to use the legacy TTY flow", setupMethod)
|
|
|
|
default:
|
|
return "", fmt.Errorf("server reported unknown setup_method=%q; this CLI may be older than the server. Re-run with --cli-prompt to use the legacy TTY flow", setupMethod)
|
|
}
|
|
}
|
|
|
|
// readBootstrapToken reads <DataDir>/.bootstrap-token. The file is created
|
|
// by EnsureBootstrapToken in internal/server/bootstrap.go with mode 0600
|
|
// and contains the base64url-encoded token followed by a trailing newline.
|
|
//
|
|
// Errors are wrapped with the absolute path so the operator can find the
|
|
// file (or confirm it's actually missing) without guessing where DataDir
|
|
// resolves to. The "--cli-prompt" hint is appended because that's the
|
|
// recoverable fallback for every failure mode here (file consumed already,
|
|
// permissions wrong, DataDir on a read-only mount).
|
|
func readBootstrapToken(dataDir string) (string, error) {
|
|
path := filepath.Join(dataDir, bootstrapTokenFilename)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", fmt.Errorf("bootstrap token file %s not found — the server may have already consumed it, or token generation failed at startup. Re-run with --cli-prompt to use the legacy TTY flow", path)
|
|
}
|
|
return "", fmt.Errorf("read bootstrap token %s: %w (re-run with --cli-prompt to use the legacy TTY flow)", path, err)
|
|
}
|
|
token := strings.TrimSpace(string(data))
|
|
if token == "" {
|
|
return "", fmt.Errorf("bootstrap token file %s is empty; delete it and restart the server, or re-run with --cli-prompt to use the legacy TTY flow", path)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// pollUntilSetupDone tickets every bootstrapPollInterval and returns nil
|
|
// the first time CheckSession reports setup_required: false. Returns
|
|
// ctx.Err() on caller cancellation, a wrapped timeout error after
|
|
// bootstrapPollTimeout elapses inside the helper. Transient CheckSession
|
|
// errors are tolerated — we keep polling, since a momentary network blip
|
|
// during the human form-filling window is almost always recoverable.
|
|
// Only ctx.Done() and the timeout end the loop.
|
|
//
|
|
// The internal timeout is a separate timer rather than a wrapped
|
|
// context.WithTimeout so caller-ctx cancellation surfaces as ctx.Err()
|
|
// (DeadlineExceeded or Canceled) instead of being misreported as the
|
|
// helper's own 5-minute timeout.
|
|
func pollUntilSetupDone(ctx context.Context, client *Client) error {
|
|
timeout := time.NewTimer(bootstrapPollTimeout)
|
|
defer timeout.Stop()
|
|
|
|
ticker := time.NewTicker(bootstrapPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timeout.C:
|
|
return fmt.Errorf("timed out waiting for setup after %s. Re-run when finished, or use --cli-prompt for the legacy TTY flow", bootstrapPollTimeout)
|
|
case <-ticker.C:
|
|
session, err := client.CheckSession()
|
|
if err != nil {
|
|
// Transient — keep polling. A stable disconnect will surface as
|
|
// the timeout above; a one-off will recover on the next tick.
|
|
continue
|
|
}
|
|
if !session.SetupRequired {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|