mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
22d901c823
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
168 lines
5.2 KiB
Go
168 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
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"`
|
|
Status string `json:"status"` // "pending", "approved", "expired"
|
|
Token string `json:"token,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CreateCLIAuthSession generates a new pending CLI auth session with a
|
|
// 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 < ?
|
|
`), now())
|
|
|
|
// Generate a 16-byte random code (32 hex chars)
|
|
raw := make([]byte, 16)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return nil, fmt.Errorf("generate cli auth code: %w", err)
|
|
}
|
|
code := hex.EncodeToString(raw)
|
|
|
|
ts := now()
|
|
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)
|
|
VALUES (?, 'pending', ?, ?)
|
|
`), code, ts, expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert cli auth session: %w", err)
|
|
}
|
|
|
|
return &CLIAuthSession{
|
|
Code: code,
|
|
Status: "pending",
|
|
ExpiresAt: expiresAt,
|
|
CreatedAt: ts,
|
|
}, nil
|
|
}
|
|
|
|
// GetCLIAuthSession retrieves a CLI auth session by code. Returns nil if
|
|
// not found. Expired sessions are returned with status updated to "expired".
|
|
func (s *Store) GetCLIAuthSession(code string) (*CLIAuthSession, error) {
|
|
var sess CLIAuthSession
|
|
var token, userID sql.NullString
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT code, status, token, user_id, created_at, expires_at
|
|
FROM cli_auth_sessions WHERE code = ?
|
|
`), code).Scan(&sess.Code, &sess.Status, &token, &userID, &sess.CreatedAt, &sess.ExpiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get cli auth session: %w", err)
|
|
}
|
|
|
|
if token.Valid {
|
|
sess.Token = token.String
|
|
}
|
|
if userID.Valid {
|
|
sess.UserID = userID.String
|
|
}
|
|
|
|
// Check expiry
|
|
if sess.Status == "pending" && parseTime(sess.ExpiresAt).Before(time.Now().UTC()) {
|
|
sess.Status = "expired"
|
|
// Update in DB lazily
|
|
_, _ = s.db.Exec(s.q(`
|
|
UPDATE cli_auth_sessions SET status = 'expired' WHERE code = ?
|
|
`), code)
|
|
}
|
|
|
|
return &sess, nil
|
|
}
|
|
|
|
// ApproveCLIAuthSession marks a pending session as approved, storing the
|
|
// session token and user ID. Returns an error if the session doesn't exist,
|
|
// is expired, or was already approved.
|
|
func (s *Store) ApproveCLIAuthSession(code, sessionToken, userID string) error {
|
|
sess, err := s.GetCLIAuthSession(code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sess == nil {
|
|
return fmt.Errorf("cli auth session not found")
|
|
}
|
|
if sess.Status == "expired" {
|
|
return fmt.Errorf("cli auth session has expired")
|
|
}
|
|
if sess.Status == "approved" {
|
|
return fmt.Errorf("cli auth session already approved")
|
|
}
|
|
|
|
result, err := s.db.Exec(s.q(`
|
|
UPDATE cli_auth_sessions
|
|
SET status = 'approved', token = ?, user_id = ?
|
|
WHERE code = ? AND status = 'pending'
|
|
`), sessionToken, userID, code)
|
|
if err != nil {
|
|
return fmt.Errorf("approve cli auth session: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("cli auth session could not be approved (race condition or already consumed)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteCLIAuthSession removes a CLI auth session by code.
|
|
func (s *Store) DeleteCLIAuthSession(code string) error {
|
|
_, err := s.db.Exec(s.q(`DELETE FROM cli_auth_sessions WHERE code = ?`), code)
|
|
if err != nil {
|
|
return fmt.Errorf("delete cli auth session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanExpiredCLIAuthSessions removes all expired CLI auth sessions.
|
|
func (s *Store) CleanExpiredCLIAuthSessions() error {
|
|
_, err := s.db.Exec(s.q(`DELETE FROM cli_auth_sessions WHERE expires_at < ?`), now())
|
|
if err != nil {
|
|
return fmt.Errorf("clean expired cli auth sessions: %w", err)
|
|
}
|
|
return nil
|
|
}
|