Files
pad/internal/server/handlers_cli_auth.go
xarmian 22d901c823 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
2026-06-20 23:51:43 -04:00

187 lines
6.4 KiB
Go

package server
import (
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// handleCreateCLIAuthSession creates a new pending CLI auth session.
// 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) {
// 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
}
// Build the auth URL that the user will open in their browser.
// Use the request's Host header to construct the URL so it works
// regardless of whether the server is at localhost, a VPS, or cloud.
//
// Security: derive the scheme from r.TLS first. Any HTTP client can
// inject X-Forwarded-Proto, so trust it ONLY when the request arrived
// from a peer in PAD_TRUSTED_PROXIES. A forged https:// in the CLI
// auth URL is low-impact phishing (user clicks a link in their own
// terminal), but the safe default is to ignore unauthenticated proxy
// headers on direct-exposed deployments.
scheme := cliAuthScheme(r, s.trustedProxyCIDRs)
authURL := fmt.Sprintf("%s://%s/auth/cli/%s", scheme, r.Host, sess.Code)
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_code": sess.Code,
"auth_url": authURL,
"expires_at": sess.ExpiresAt,
})
}
// handlePollCLIAuthSession checks the status of a CLI auth session.
// The CLI polls this until the session is approved or expired.
// GET /api/v1/auth/cli/sessions/{code}
func (s *Server) handlePollCLIAuthSession(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing session code")
return
}
sess, err := s.store.GetCLIAuthSession(code)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check CLI auth session")
return
}
if sess == nil {
writeError(w, http.StatusNotFound, "not_found", "CLI auth session not found")
return
}
response := map[string]interface{}{
"status": sess.Status,
}
// Only include the token and user info when approved
if sess.Status == "approved" && sess.Token != "" {
response["token"] = sess.Token
// Look up user info to return alongside the token
if sess.UserID != "" {
user, err := s.store.GetUser(sess.UserID)
if err == nil && user != nil {
response["user"] = sessionUserPayload(user)
}
}
// Clean up the session after it's been consumed
_ = s.store.DeleteCLIAuthSession(code)
}
writeJSON(w, http.StatusOK, response)
}
// handleApproveCLIAuthSession approves a pending CLI auth session.
// Called from the browser by an authenticated user.
// POST /api/v1/auth/cli/sessions/{code}/approve
func (s *Server) handleApproveCLIAuthSession(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing session code")
return
}
// Resolve the authenticated user — try context first (set by middleware),
// then fall back to session cookie (since auth routes are exempt from middleware).
user := currentUser(r)
if user == nil {
user = s.validateSessionCookie(r)
}
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "You must be logged in to approve a CLI session")
return
}
// Verify the session exists and is pending
sess, err := s.store.GetCLIAuthSession(code)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check CLI auth session")
return
}
if sess == nil {
writeError(w, http.StatusNotFound, "not_found", "CLI auth session not found")
return
}
if sess.Status == "expired" {
writeError(w, http.StatusGone, "expired", "This CLI auth session has expired. Run 'pad auth login' again.")
return
}
if sess.Status == "approved" {
writeError(w, http.StatusConflict, "already_approved", "This CLI session has already been approved")
return
}
// Create a new session token for the CLI (long-lived, 30 days)
token, err := s.store.CreateSession(user.ID, "cli-browser-auth", clientIP(r), "", 30*24*time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
return
}
// Approve the CLI auth session with the new token
if err := s.store.ApproveCLIAuthSession(code, token, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to approve CLI auth session")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"approved": true,
"user": sessionUserPayload(user),
})
}
// cliAuthScheme picks the URL scheme for the CLI auth link.
//
// Precedence:
// 1. If the request hit this server over TLS (r.TLS != nil) → "https".
// 2. If a trusted proxy is configured AND the direct TCP peer is in one
// of the trusted CIDRs, honor X-Forwarded-Proto. Only the first comma-
// separated value is used and it must be "http" or "https".
// 3. Otherwise → "http". An untrusted client can still send X-Forwarded-
// Proto but we ignore it.
//
// This prevents an attacker from forging https://… in the terminal URL
// printed by `pad auth login` on a plain-HTTP self-host deployment.
func cliAuthScheme(r *http.Request, trustedCIDRs []*net.IPNet) string {
if r.TLS != nil {
return "https"
}
if len(trustedCIDRs) > 0 {
if peerIP := peerAddr(rawPeerAddr(r)); peerIP != nil && ipInCIDRs(peerIP, trustedCIDRs) {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
// Some proxies emit "https, http" when multiple hops are
// involved; take the first value and normalize.
first := strings.ToLower(strings.TrimSpace(strings.SplitN(proto, ",", 2)[0]))
if first == "https" || first == "http" {
return first
}
}
}
}
return "http"
}