Files
pad/internal/server/handlers_cli_auth.go
T
xarmian 393d986ea9 fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975) (#2252)
* fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975)

The CLI token minted by /auth/cli/sessions/{code}/approve was dated by the
approving web session's created_at, so approving from an 85-day-old session
left the token a 5-day window under the 90-day cap. It is now minted with no
issuedAt: created_at is the approval, 30-day sliding window, 90-day cap from
approval. No TTL lengths change.

The widening is accepted: a stolen near-expiry web session can now mint a CLI
token good for up to 90 days from approval. Step-up-on-approve is filed as its
own idea.

created_at doubled as the sign-in recency anchor for confirm-only account
delete (BUG-3336), so dating the CLI token from approval would have made it
read as a fresh sign-in. CLI-kind sessions can therefore no longer satisfy
confirm-only delete recency (403 reauth_required): approach (b), which keeps
the BUG-3336 property with no migration. Passwordless accounts confirm from
the web. The credential-change rotation keeps the replaced session's
created_at (pinned by a new test); its 7d-vs-30d TTL is BUG-3978, untouched.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(auth): only a browser session approves a CLI sign-in (TASK-3975 r2)

With the approval now minting the CLI token with its own lifetime, a
CLI-kind session that could approve could mint its own successor: a CLI
bearer creates a pending code with POST /auth/cli/sessions, approves it
with itself, and receives a new token with created_at = now and a fresh
90-day cap. Repeated before each cap, that chain keeps a CLI credential
(or a stolen one) alive forever. Before this PR the approval inherited
the approver's created_at, so the chain stayed bounded by the original
sign-in. Reproduced on the previous head: an 85-day-old CLI bearer got
200 and a session aged 0s.

The accepted widening is ONE hop from a web session; the chain never
was. /approve now refuses any approver that is not a web-kind session
with 403 session_required, before anything is minted. The web client
approves on its cookie and is the only caller. docs/api.md says so.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* test(auth): the rotation cap pin starts from a 7-day web session (TASK-3975)

#2254 (BUG-3978) makes a credential-change rotation keep the replaced
session's own sliding window. TestTASK3975_RotationKeepsParentCap
rotated the 24-hour session oauthOnlyCloud mints, so after that change
the 24-hour window bound before the 90-day cap it exists to pin, and it
failed on the rebased tree (expiry now+24h, want now+5d). The parent is
now a 7-day web session, so the cap five days away is what binds.
Test-only.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* docs(api): who may approve a CLI sign-in, by credential (TASK-3975 r4)

A CLI-kind bearer gets 403 session_required, a CLI-kind session sent as
a cookie is no credential (BUG-3350) and gets 401, and an API token gets
403. The sentence said any CLI-kind session got 403, and called the
web-kind check "browser" though a web session sent as a bearer passes.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 13:30:31 -04:00

309 lines
12 KiB
Go

package server
import (
"errors"
"fmt"
"github.com/PerpetualSoftware/pad/internal/store"
"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.
setup := false
if count, err := s.store.UserCount(); err == nil && count == 0 {
setup = true
}
// Who asked, for the approval page (TASK-2253). The User-Agent is the
// requester's own claim; the store caps it and the page labels it so.
sess, err := s.store.CreateCLIAuthSessionFrom(setup, store.CLIAuthRequester{
IP: clientIP(r),
UserAgent: requestUserAgent(r),
})
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
}
if sess.Status == "denied" {
// An error, not a 200 status (TASK-2253): a CLI from before Deny
// existed switches on status with no default and would keep polling
// a 200 "denied" for its full 20 minutes. A non-2xx counts as a poll
// error there, so it gives up after five in a row and prints this
// message. A current CLI recognises the code and stops at once.
writeError(w, http.StatusGone, "cli_auth_denied", "This sign-in was denied in the browser.")
return
}
response := map[string]interface{}{
"status": sess.Status,
}
if sess.Status == "pending" {
// Context for the person deciding (TASK-2253). Whoever holds the
// code is the requester (this is their own address) or the person
// they sent the link to, who needs it to decide.
response["created_at"] = sess.CreatedAt
if sess.RequesterIP != "" {
response["requester_ip"] = sess.RequesterIP
}
if sess.RequesterUserAgent != "" {
response["requester_user_agent"] = sess.RequesterUserAgent
}
}
// 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) {
// Approval mints a 30-day session, so it is an interactive-session
// action (BUG-3336, BUG-3349): a PAT that could approve would outlive
// its own revocation through the session it minted.
if isAPITokenAuth(r) {
writeError(w, http.StatusForbidden, "session_required",
"Approving a CLI sign-in requires an interactive session, not an API token")
return
}
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)
}
// A bot is no signed-in person (TASK-3392). This is the THIRD layer, kept
// as defence in depth: a bot never reaches here today, because (1)
// ValidateSession resolves a bot's session to nothing, so currentUser and
// validateSessionCookie return nil for it, and (2) the approver check
// below requires a resolved session belonging to this same user. Neither
// delete this as dead code nor rely on it alone; no test can isolate it.
if user.IsApp() {
user = nil
}
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
}
if sess.Status == "denied" {
writeError(w, http.StatusConflict, "cli_auth_denied", "This sign-in was denied. Run 'pad auth login' again.")
return
}
// Create a new session token for the CLI (long-lived, 30 days sliding,
// capped at 90 days from this approval). It is a new credential on another
// device, so it gets its own lifetime (TASK-3975): inheriting the approving
// web session's age would hand a CLI approved from an 85-day-old session
// only 5 days. It still must not read as a fresh sign-in (BUG-3336); the
// confirm-only account delete refuses CLI-kind sessions outright.
approver := s.requestSessionInfo(r)
if approver == nil || approver.User.ID != user.ID {
writeError(w, http.StatusUnauthorized, "unauthorized", "You must be logged in to approve a CLI session")
return
}
// Only a browser session approves (TASK-3975 r2). The token minted here
// starts its own 90-day cap, so a CLI token that could approve could mint
// its own successor, and a chain of them would never expire. The web
// client approves on its cookie; nothing else calls this.
if approver.Kind != store.SessionKindWeb {
writeError(w, http.StatusForbidden, "session_required",
"Approving a CLI sign-in requires a browser session")
return
}
// Fenced on the approver's credential epoch as this request was admitted
// (BUG-3382): a claim or reset since then makes the approval mint nothing.
token, err := s.store.CreateSessionFenced(user.ID, user.CredentialEpoch, "cli-browser-auth", clientIP(r), "", 30*24*time.Hour, time.Time{})
if errors.Is(err, store.ErrUserDisabled) {
// Disabled between this request's admission and the mint (BUG-3349).
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
return
}
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 {
// The session minted above reaches nobody now; end it rather than
// leave a live 30-day credential unreferenced.
_ = s.store.DeleteSession(token)
if errors.Is(err, store.ErrCLIAuthNotPending) {
// Denied (or approved elsewhere) between the read above and
// this write (TASK-2253).
writeError(w, http.StatusConflict, "cli_auth_denied", "This sign-in is no longer waiting for approval. Run 'pad auth login' again.")
return
}
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),
})
}
// handleDenyCLIAuthSession refuses a pending CLI auth session (TASK-2253).
// POST /api/v1/auth/cli/sessions/{code}/deny
//
// The code is the authority, not an account: it is already a bearer secret
// (whoever holds it can poll, and collects the token after an approval),
// and a deny harms only the requester, who holds the same code. So the
// handler checks no user. It is not CSRF-exempt: from a browser it is a
// cookie-carrying POST like approve, and the approval page only offers it
// to a signed-in visitor anyway. Denying twice answers 200; an approved or
// expired session answers with what it is instead.
func (s *Server) handleDenyCLIAuthSession(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing session code")
return
}
err := s.store.DenyCLIAuthSession(code)
if err == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"denied": true})
return
}
if !errors.Is(err, store.ErrCLIAuthNotPending) {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to deny CLI auth session")
return
}
sess, err := s.store.GetCLIAuthSession(code)
switch {
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check CLI auth session")
case sess == nil:
writeError(w, http.StatusNotFound, "not_found", "CLI auth session not found")
case sess.Status == "approved":
writeError(w, http.StatusConflict, "already_approved", "This CLI session has already been approved")
default:
writeError(w, http.StatusGone, "expired", "This CLI auth session has expired.")
}
}
// 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"
}