From 22d901c823a8eec767d87f06bfb9d59bf13f0110 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 20 Jun 2026 23:51:43 -0400 Subject: [PATCH] fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh instance, `pad init` / `pad auth setup` created the admin account in the browser and dropped the operator on the console, then printed a SECOND "authorize the CLI" URL back in the terminal that a user who'd moved to the browser never saw — forcing a ctrl-C + re-run. Collapse it into a single browser tab: the CLI mints the pending CLI auth session up front and hands /setup a validated `next=/auth/cli/` target, so account creation flows straight into the approval page where the just-bootstrapped admin approves in one click and the CLI connects. - internal/cli/bootstrap.go: thread `next` into the /setup URL (query before the #token fragment); raise bootstrapPollTimeout to 20m to match the setup session TTL. - cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates the session and polls it; `pad workspace init` drives local setup inline. - cmd/pad/init.go: `pad init` routes through the unified handoff. - internal/store + internal/server: grant a setup-specific 20m CLI auth session TTL when UserCount==0 so the combined create-account + approve window can't expire mid-flow; normal logins keep the 5m default. - web/src/routes/setup: honor a validated local `next` redirect (open- redirect guarded), preserved across the token-fragment scrub. Reviewed via Codex loop (3 rounds → clean). Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ --- cmd/pad/init.go | 28 ++++------ cmd/pad/main.go | 80 +++++++++++++++++++++------- internal/cli/bootstrap.go | 45 ++++++++++++---- internal/cli/bootstrap_test.go | 55 +++++++++++++++---- internal/server/handlers_cli_auth.go | 14 ++++- internal/store/cli_auth_sessions.go | 28 ++++++++-- web/src/routes/setup/+page.svelte | 41 ++++++++++++-- 7 files changed, 225 insertions(+), 66 deletions(-) diff --git a/cmd/pad/init.go b/cmd/pad/init.go index e5a9c0ae..20b3ac79 100644 --- a/cmd/pad/init.go +++ b/cmd/pad/init.go @@ -171,27 +171,21 @@ Examples: fmt.Printf("Admin account created — logged in as %s (%s)\n", resp.User.Name, resp.User.Email) fmt.Println() } else { - // Default browser path. RunBrowserBootstrap returns once - // the server reports setup_required: false but the CLI - // has no credentials — the browser owns the session - // cookie. Chain doBrowserLogin afterwards so the rest - // of pad init (workspace creation, etc.) can hit - // authenticated endpoints. + // Default browser path: a SINGLE handoff that creates the + // admin account AND authorizes this CLI in the same + // browser tab. runBrowserSetup mints the pending CLI auth + // session first, hands /setup a next= target pointing at + // its approval page, then polls until approved — so the + // operator never has to return to the terminal to copy a + // second URL. BUG-1843. // // SIGINT is already handled by installInitCancelHandler - // at the top of this RunE — it short-circuits the whole - // process via os.Exit(130), so the helper doesn't need - // its own signal-aware context. Background suffices. - if err := cli.RunBrowserBootstrap(context.Background(), client, cfg); err != nil { + // at the top of this RunE (os.Exit(130)); runBrowserSetup + // installs a redundant handler of its own, which is + // harmless. + if err := runBrowserSetup(context.Background(), cfg, client); err != nil { return err } - green.Print("✓ ") - fmt.Println("First admin account created") - fmt.Println() - fmt.Println(" Authenticating the CLI…") - if err := doBrowserLogin(client, cfg); err != nil { - return fmt.Errorf("login: %w", err) - } fmt.Println() } diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 193ae8ef..26726c5c 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -1167,7 +1167,11 @@ legacy in-terminal email/name/password prompts.`, if cliPrompt { return runCLISetup(cfg, client) } - return runBrowserSetup(cmd.Context(), cfg, client) + if err := runBrowserSetup(cmd.Context(), cfg, client); err != nil { + return err + } + printPostSetupNextStepsHint() + return nil }, } // --cli-prompt is the deliberate hedge from IDEA-1179 / TASK-1216: we @@ -1181,14 +1185,18 @@ legacy in-terminal email/name/password prompts.`, return cmd } -// runBrowserSetup drives the browser-based first-admin bootstrap and then -// chains a CLI auth-session login so the user ends up authenticated on -// the CLI — mirroring the post-condition of the legacy --cli-prompt path -// (Bootstrap returns a token and we save credentials in one shot). Two -// browser approvals — one to create the admin, one to authorize the CLI -// — but each is a single click in a browser the operator already has -// open, and the alternative (telling them to manually run `pad auth -// login` afterwards) is a worse UX. +// runBrowserSetup drives the browser-based first-admin bootstrap and the +// CLI authorization in a SINGLE browser handoff. Before printing anything +// it creates a pending CLI auth session, then hands /setup a next= target +// pointing at that session's approval page. The operator opens one URL, +// creates the admin account, and the browser auto-navigates to the +// "Authorize CLI" page in the same tab — where, already authenticated by +// the bootstrap they just completed, a single click finishes login. The +// CLI polls that pre-created session and saves credentials on approval. +// +// This closes BUG-1843: the pre-fix flow created the admin, dropped the +// operator on the console, and only THEN printed a second auth URL back in +// the terminal — which a user who'd moved to the browser never saw. func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client) error { if ctx == nil { ctx = context.Background() @@ -1206,7 +1214,17 @@ func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client } }() - if err := cli.RunBrowserBootstrap(bootstrapCtx, client, cfg); err != nil { + // Create the CLI auth session FIRST so we know the approval-page path + // to hand /setup as its post-bootstrap redirect target. CreateCLIAuthSession + // is unauthenticated (it mints a pending request), so it works on a + // fresh instance with no users yet. + sess, err := client.CreateCLIAuthSession() + if err != nil { + return fmt.Errorf("failed to start login session: %w", err) + } + next := "/auth/cli/" + sess.SessionCode + + if err := cli.RunBrowserBootstrap(bootstrapCtx, client, cfg, next); err != nil { // Map ctx cancellation to the canonical errCancelled sentinel so // the deferred isCancellation() check in the parent RunE routes // us through "Cancelled." + exit 130 instead of cobra's generic @@ -1220,12 +1238,8 @@ func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client green := color.New(color.FgGreen).SprintFunc() fmt.Printf(" %s First admin account created\n", green("✓")) fmt.Println() - fmt.Println(" Authenticating the CLI…") - if err := doBrowserLogin(client, cfg); err != nil { - return err - } - printPostSetupNextStepsHint() - return nil + fmt.Println(" Authorizing the CLI… approve the request in the browser tab that just opened.") + return pollAndSaveCLIAuth(bootstrapCtx, client, cfg, sess) } // runCLISetup is the legacy in-terminal admin bootstrap, reachable via @@ -1366,6 +1380,18 @@ func doBrowserLogin(client *cli.Client, cfg *config.Config) error { cancel() }() + return pollAndSaveCLIAuth(ctx, client, cfg, sess) +} + +// pollAndSaveCLIAuth polls a pending CLI auth session until it is approved, +// then persists the issued token as credentials for cfg.BaseURL(). It is the +// shared tail of every browser auth flow: doBrowserLogin (which prints its +// own /auth/cli URL) and the first-run setup handoff (where the browser +// auto-navigates to that same approval page from /setup, so no URL is +// printed — BUG-1843). The caller owns ctx and its SIGINT wiring; on +// cancellation this returns errCancelled so the standard "Cancelled." + +// exit-130 path fires. +func pollAndSaveCLIAuth(ctx context.Context, client *cli.Client, cfg *config.Config, sess *cli.CLIAuthSessionResponse) error { // Poll until approved, expired, or cancelled ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -2228,10 +2254,24 @@ a workspace in one step.`, return fmt.Errorf("failed to check auth status: %w", err) } if session.SetupRequired { - printSetupRequiredHint(cfg) - dim := color.New(color.Faint) - fmt.Println(dim.Sprint("\nTip: Run 'pad init' to set up everything at once.")) - return fmt.Errorf("this Pad instance has not been initialized yet") + // Remote/cloud instances can only be bootstrapped from the + // server host — a client machine can't create the first + // admin. Keep the pointing-at-the-host hint for those. + switch cfg.Mode { + case config.ModeRemote, config.ModeCloud: + printSetupRequiredHint(cfg) + return fmt.Errorf("this Pad instance has not been initialized yet") + } + // Fresh local instance: drive the full first-run setup + // (create the first admin + authorize this CLI) inline so + // `pad init` is a genuine one-shot rather than bouncing the + // user to `pad auth setup` and back. BUG-1843. + fmt.Println("This Pad instance hasn't been set up yet — let's create your admin account.") + if err := runBrowserSetup(cmd.Context(), cfg, client); err != nil { + return err + } + fmt.Println() + client = cli.NewClientFromURL(cfg.BaseURL()) } else if !session.Authenticated { fmt.Println("Log in to continue.") fmt.Println() diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 48d6397d..8fccca3c 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -23,6 +23,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -50,12 +51,17 @@ const bootstrapTokenFilename = ".bootstrap-token" 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 +// browser side to finish. It MUST stay >= the server's setup-session TTL +// (cliAuthSetupSessionTTL, 20m in internal/store/cli_auth_sessions.go): +// the unified setup handoff (BUG-1843) pre-creates a 20-minute CLI auth +// session, so the terminal must keep polling for setup at least that long +// — otherwise a user who takes >5m on the admin form would have the CLI +// give up here while the browser session is still valid, exactly the +// half-fixed expiry class this aligns away. Still finite so an abandoned +// terminal doesn't wait forever; the caller's SIGINT path cuts it short +// via ctx. var (not const) so tests can exercise the timeout branch in // milliseconds. -var bootstrapPollTimeout = 5 * time.Minute +var bootstrapPollTimeout = 20 * time.Minute // RunBrowserBootstrap walks the operator through the browser-based first- // admin bootstrap. Returns nil on success (server has flipped to @@ -72,11 +78,18 @@ var bootstrapPollTimeout = 5 * time.Minute // want the CLI to be authenticated afterwards should chain a CLI-auth- // session login (see doBrowserLogin in cmd/pad/main.go) once this returns. // +// next, when non-empty, is a local path the /setup page navigates to after +// the admin account is created (instead of dropping the operator at the +// console). Callers pass "/auth/cli/" for a pre-created CLI auth +// session so account creation flows straight into the CLI-authorize step +// in the SAME browser tab — no second URL to copy back in the terminal +// (BUG-1843). +// // 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 { +func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config, next string) error { if client == nil { return errors.New("RunBrowserBootstrap: nil client") } @@ -93,7 +106,7 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config return nil } - url, err := buildBootstrapURL(cfg, session.SetupMethod) + setupURL, err := buildBootstrapURL(cfg, session.SetupMethod, next) if err != nil { return err } @@ -102,7 +115,7 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config fmt.Println() fmt.Println(" Open this URL in your browser to finish setup:") fmt.Println() - fmt.Printf(" %s\n", bold(url)) + fmt.Printf(" %s\n", bold(setupURL)) fmt.Println() fmt.Println(" Waiting for setup to complete (Ctrl+C to cancel)...") @@ -137,19 +150,29 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config // // - 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) { +func buildBootstrapURL(cfg *config.Config, setupMethod, next string) (string, error) { base := cfg.BrowserURL() + // The next= handoff target rides as a query param, which MUST sit + // before any #token fragment (a query after the fragment would be + // parsed as part of the fragment and never reach the page's + // searchParams). url.QueryEscape keeps the leading slash and any + // nested path safe to round-trip through the address bar. + query := "" + if next != "" { + query = "?next=" + url.QueryEscape(next) + } + 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 + return fmt.Sprintf("%s/setup%s#token=%s", base, query, token), nil case "open": - return base + "/setup", nil + return base + "/setup" + query, 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) diff --git a/internal/cli/bootstrap_test.go b/internal/cli/bootstrap_test.go index 70a506f5..23191b59 100644 --- a/internal/cli/bootstrap_test.go +++ b/internal/cli/bootstrap_test.go @@ -70,7 +70,7 @@ func TestRunBrowserBootstrap_Idempotent(t *testing.T) { // Deliberately do NOT write a token file. If the helper short-circuits // correctly on setup_required: false, it should never need to read it. - if err := RunBrowserBootstrap(context.Background(), client, cfg); err != nil { + if err := RunBrowserBootstrap(context.Background(), client, cfg, ""); err != nil { t.Fatalf("RunBrowserBootstrap returned %v on already-bootstrapped server, want nil", err) } } @@ -99,7 +99,7 @@ func TestRunBrowserBootstrap_LogsTokenSuccess(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := RunBrowserBootstrap(ctx, client, cfg) + err := RunBrowserBootstrap(ctx, client, cfg, "") if err != nil { t.Fatalf("RunBrowserBootstrap: %v", err) } @@ -131,7 +131,7 @@ func TestRunBrowserBootstrap_OpenMode(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := RunBrowserBootstrap(ctx, client, cfg); err != nil { + if err := RunBrowserBootstrap(ctx, client, cfg, ""); err != nil { t.Fatalf("RunBrowserBootstrap (open mode): %v", err) } } @@ -147,7 +147,7 @@ func TestRunBrowserBootstrap_TokenMissing(t *testing.T) { // No writeBootstrapToken — file is absent. client := NewClientFromURL(srv.URL) - err := RunBrowserBootstrap(context.Background(), client, cfg) + err := RunBrowserBootstrap(context.Background(), client, cfg, "") if err == nil { t.Fatal("RunBrowserBootstrap returned nil with missing token, want error") } @@ -172,7 +172,7 @@ func TestRunBrowserBootstrap_TokenEmpty(t *testing.T) { writeBootstrapToken(t, cfg.DataDir, " ") client := NewClientFromURL(srv.URL) - err := RunBrowserBootstrap(context.Background(), client, cfg) + err := RunBrowserBootstrap(context.Background(), client, cfg, "") if err == nil { t.Fatal("RunBrowserBootstrap returned nil with empty token, want error") } @@ -192,7 +192,7 @@ func TestRunBrowserBootstrap_LocalCLIMethod(t *testing.T) { cfg := newTestConfig(t, srv.URL) client := NewClientFromURL(srv.URL) - err := RunBrowserBootstrap(context.Background(), client, cfg) + err := RunBrowserBootstrap(context.Background(), client, cfg, "") if err == nil { t.Fatal("RunBrowserBootstrap returned nil with setup_method=local_cli, want error") } @@ -211,7 +211,7 @@ func TestRunBrowserBootstrap_UnknownMethod(t *testing.T) { cfg := newTestConfig(t, srv.URL) client := NewClientFromURL(srv.URL) - err := RunBrowserBootstrap(context.Background(), client, cfg) + err := RunBrowserBootstrap(context.Background(), client, cfg, "") if err == nil { t.Fatal("RunBrowserBootstrap returned nil with unknown setup_method, want error") } @@ -253,7 +253,7 @@ func TestRunBrowserBootstrap_TimeoutFires(t *testing.T) { writeBootstrapToken(t, cfg.DataDir, "tok") client := NewClientFromURL(srv.URL) - err := RunBrowserBootstrap(context.Background(), client, cfg) + err := RunBrowserBootstrap(context.Background(), client, cfg, "") if err == nil { t.Fatal("RunBrowserBootstrap returned nil after internal timeout, want error") } @@ -292,7 +292,7 @@ func TestRunBrowserBootstrap_ContextCancelled(t *testing.T) { defer cancel() start := time.Now() - err := RunBrowserBootstrap(ctx, client, cfg) + err := RunBrowserBootstrap(ctx, client, cfg, "") elapsed := time.Since(start) if err == nil { @@ -319,7 +319,7 @@ func TestBuildBootstrapURL_LogsTokenFragment(t *testing.T) { cfg := newTestConfig(t, "http://example.test:7777") writeBootstrapToken(t, cfg.DataDir, "abc123") - got, err := buildBootstrapURL(cfg, "logs_token") + got, err := buildBootstrapURL(cfg, "logs_token", "") if err != nil { t.Fatalf("buildBootstrapURL: %v", err) } @@ -329,6 +329,41 @@ func TestBuildBootstrapURL_LogsTokenFragment(t *testing.T) { } } +// TestBuildBootstrapURL_NextBeforeFragment locks down the BUG-1843 handoff +// shape: the next= query param MUST sit before the #token fragment. +// A query placed after the fragment would be swallowed by it and never +// reach the /setup page's searchParams, breaking the post-bootstrap +// redirect to the CLI-authorize page. The path is URL-escaped so the +// leading slash round-trips intact. +func TestBuildBootstrapURL_NextBeforeFragment(t *testing.T) { + cfg := newTestConfig(t, "http://example.test:7777") + writeBootstrapToken(t, cfg.DataDir, "abc123") + + got, err := buildBootstrapURL(cfg, "logs_token", "/auth/cli/XYZ") + if err != nil { + t.Fatalf("buildBootstrapURL: %v", err) + } + want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ#token=abc123" + if got != want { + t.Errorf("buildBootstrapURL = %q, want %q", got, want) + } +} + +// TestBuildBootstrapURL_OpenModeNext — in open mode (no token) the next= +// query still attaches, with no fragment. +func TestBuildBootstrapURL_OpenModeNext(t *testing.T) { + cfg := newTestConfig(t, "http://example.test:7777") + + got, err := buildBootstrapURL(cfg, "open", "/auth/cli/XYZ") + if err != nil { + t.Fatalf("buildBootstrapURL: %v", err) + } + want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ" + if got != want { + t.Errorf("buildBootstrapURL = %q, want %q", got, want) + } +} + // TestReadBootstrapToken_TrimsTrailingNewline — EnsureBootstrapToken // writes "\n", so a faithful read must strip the trailing newline // (and any incidental whitespace). Otherwise the URL would carry a stray diff --git a/internal/server/handlers_cli_auth.go b/internal/server/handlers_cli_auth.go index 704449a9..1d4f14a2 100644 --- a/internal/server/handlers_cli_auth.go +++ b/internal/server/handlers_cli_auth.go @@ -14,7 +14,19 @@ import ( // The CLI calls this, then presents the auth URL to the user. // POST /api/v1/auth/cli/sessions func (s *Server) handleCreateCLIAuthSession(w http.ResponseWriter, r *http.Request) { - sess, err := s.store.CreateCLIAuthSession() + // On a fresh instance (no users yet) this session is part of the + // first-run setup handoff: the CLI mints it BEFORE the operator creates + // the admin account in the browser, so /setup can redirect straight to + // the approval page (BUG-1843). Grant the longer setup TTL so the + // combined account-creation + approval window doesn't expire mid-flow. + // Once users exist (normal `pad auth login`), use the shorter default. + // A failed count falls back to the default TTL rather than blocking login. + create := s.store.CreateCLIAuthSession + if count, err := s.store.UserCount(); err == nil && count == 0 { + create = s.store.CreateCLIAuthSessionForSetup + } + + sess, err := create() if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create CLI auth session") return diff --git a/internal/store/cli_auth_sessions.go b/internal/store/cli_auth_sessions.go index a6eacf15..06043f75 100644 --- a/internal/store/cli_auth_sessions.go +++ b/internal/store/cli_auth_sessions.go @@ -10,6 +10,16 @@ import ( const cliAuthSessionTTL = 5 * time.Minute +// cliAuthSetupSessionTTL is the longer window granted to a CLI auth session +// minted during first-run setup (no users exist yet). The setup handoff +// creates the session BEFORE the operator fills out the admin-account form +// in the browser (so /setup can redirect straight to the approval page — +// BUG-1843), so its clock must cover account creation AND the approval +// click, not just the click. 5 minutes was tight for that combined window; +// 20 gives comfortable headroom. Normal `pad auth login` (users already +// exist) keeps the shorter default. +const cliAuthSetupSessionTTL = 20 * time.Minute + // CLIAuthSession represents a pending or approved CLI login session. type CLIAuthSession struct { Code string `json:"code"` @@ -21,9 +31,21 @@ type CLIAuthSession struct { } // CreateCLIAuthSession generates a new pending CLI auth session with a -// cryptographically random code. Returns the session including the code -// the CLI should present to the user. +// cryptographically random code and the default TTL. Returns the session +// including the code the CLI should present to the user. func (s *Store) CreateCLIAuthSession() (*CLIAuthSession, error) { + return s.createCLIAuthSession(cliAuthSessionTTL) +} + +// CreateCLIAuthSessionForSetup is CreateCLIAuthSession with the longer +// first-run window. The setup handoff mints the session before the admin +// account is even created (BUG-1843), so its clock must cover account +// creation as well as the approval click. +func (s *Store) CreateCLIAuthSessionForSetup() (*CLIAuthSession, error) { + return s.createCLIAuthSession(cliAuthSetupSessionTTL) +} + +func (s *Store) createCLIAuthSession(ttl time.Duration) (*CLIAuthSession, error) { // Clean up expired sessions opportunistically _, _ = s.db.Exec(s.q(` DELETE FROM cli_auth_sessions WHERE expires_at < ? @@ -37,7 +59,7 @@ func (s *Store) CreateCLIAuthSession() (*CLIAuthSession, error) { code := hex.EncodeToString(raw) ts := now() - expiresAt := time.Now().UTC().Add(cliAuthSessionTTL).Format(time.RFC3339) + expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339) _, err := s.db.Exec(s.q(` INSERT INTO cli_auth_sessions (code, status, created_at, expires_at) diff --git a/web/src/routes/setup/+page.svelte b/web/src/routes/setup/+page.svelte index f4e1b056..1fd98801 100644 --- a/web/src/routes/setup/+page.svelte +++ b/web/src/routes/setup/+page.svelte @@ -37,6 +37,25 @@ let token = $state(''); let pastedToken = $state(''); + // nextPath is the local path to navigate to after the admin account is + // created, passed by the CLI as ?next=… (BUG-1843). The CLI hands us + // "/auth/cli/" so account creation flows straight into the + // "Authorize CLI" page in the same tab instead of dumping the operator + // on the console while a second auth URL waits unseen in the terminal. + // Empty unless a SAFE local path is supplied — see isSafeLocalPath. + // Plain (non-reactive) let: it's resolved once during synchronous init + // and only read later inside onMount/handleSubmit closures, never in the + // template, so it doesn't need to be a $state rune. + let nextPath = ''; + + // isSafeLocalPath gates the next= target to same-origin paths so the + // redirect can't be turned into an open redirect (//evil.com, /\evil, + // or an absolute http(s):// URL). Must be a single leading slash + // followed by a non-slash, non-backslash char. + function isSafeLocalPath(p: string): boolean { + return /^\/[^/\\]/.test(p); + } + // openMode is true when the server has been started with // PAD_BYPASS_SETUP_TOKEN=true on a self-host deployment with no users // yet. The /setup form works directly — no paste-token step, no @@ -47,6 +66,13 @@ let openMode = $state(false); if (typeof window !== 'undefined') { + // Capture next= BEFORE any fragment scrub below — the scrub rewrites + // the URL and would otherwise drop the query string with it. + const rawNext = new URLSearchParams(window.location.search).get('next'); + if (rawNext && isSafeLocalPath(rawNext)) { + nextPath = rawNext; + } + const hash = window.location.hash; if (hash.startsWith('#token=')) { const raw = hash.slice('#token='.length); @@ -62,8 +88,10 @@ // Scrub the fragment from the URL bar before paint so the // secret doesn't survive in browser history, screen recordings, // or screenshots (F10). replaceState keeps the navigation - // entry — we just rewrite its URL. - history.replaceState({}, '', '/setup'); + // entry — we just rewrite its URL. Preserve next= so a reload + // (or the post-bootstrap redirect) still has its handoff target. + const scrubbed = nextPath ? `/setup?next=${encodeURIComponent(nextPath)}` : '/setup'; + history.replaceState({}, '', scrubbed); } } @@ -84,7 +112,10 @@ try { const session = await authStore.ensureLoaded(); if (session?.authenticated) { - await goto('/'); + // Already signed in — honor the CLI's next= handoff (e.g. a + // reload after the admin was created) so a pending CLI auth + // session still lands on its approval page. BUG-1843. + await goto(nextPath || '/'); return; } // Self-host operators who set PAD_BYPASS_SETUP_TOKEN=true get @@ -137,7 +168,9 @@ // Bootstrap success — server has set the session cookie. Reload // the auth store so subsequent navigations see the new session. await authStore.load(); - await goto('/'); + // Hand off to the CLI's next= target when present (the "Authorize + // CLI" page), otherwise land on the console. BUG-1843. + await goto(nextPath || '/'); } catch (err: unknown) { // 403: token rejected (expired / already used / wrong). Clear // the in-memory token, drop back to the paste prompt, and