mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
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/<code>` 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
This commit is contained in:
+11
-17
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+60
-20
@@ -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()
|
||||
|
||||
+34
-11
@@ -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/<code>" 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)
|
||||
|
||||
@@ -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 "<token>\n", so a faithful read must strip the trailing newline
|
||||
// (and any incidental whitespace). Otherwise the URL would carry a stray
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<code>" 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
|
||||
|
||||
Reference in New Issue
Block a user