Files
pad/internal/server/task3975_cli_token_lifetime_test.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

151 lines
5.9 KiB
Go

package server
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TASK-3975: a CLI token minted by approving from a web session gets its own
// lifetime (30-day slide, 90-day cap from the approval), not the approving
// session's remaining age.
func within(t *testing.T, what string, got, want time.Time, tol time.Duration) {
t.Helper()
if d := got.Sub(want); d > tol || d < -tol {
t.Errorf("%s = %v, want %v +/- %v", what, got, want, tol)
}
}
func sessionExpiry(t *testing.T, srv *Server, token string) time.Time {
t.Helper()
var s string
sum := sha256.Sum256([]byte(token))
if err := srv.store.DB().QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ?`, hex.EncodeToString(sum[:])).Scan(&s); err != nil {
t.Fatal(err)
}
exp, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatal(err)
}
return exp
}
func TestTASK3975_CLIApprovalFromAnEightyFiveDayWebSession_GetsFullWindow(t *testing.T) {
srv, u, token := oauthOnlyCloud(t)
ageAllSessions(t, srv, u.ID, 85*24*time.Hour)
pending, err := srv.store.CreateCLIAuthSession()
if err != nil {
t.Fatal(err)
}
if rr := doRequestWithCookieFrom(srv, "POST", "/api/v1/auth/cli/sessions/"+pending.Code+"/approve", nil, token, "198.51.100.7:5555"); rr.Code != http.StatusOK {
t.Fatalf("approve: %d %s", rr.Code, rr.Body.String())
}
approved, err := srv.store.GetCLIAuthSession(pending.Code)
if err != nil || approved == nil || approved.Token == "" {
t.Fatalf("approved CLI session: %+v %v", approved, err)
}
info, err := srv.store.ValidateSession(approved.Token)
if err != nil || info == nil {
t.Fatalf("validate: %v", err)
}
now := time.Now()
within(t, "created_at", info.CreatedAt, now, time.Minute)
within(t, "expires_at", sessionExpiry(t, srv, approved.Token), now.Add(30*24*time.Hour), time.Minute)
// Sliding renewal reaches the full 30 days, not the approver's remaining 5.
if _, err := srv.store.DB().Exec(`UPDATE sessions SET expires_at = ? WHERE user_id = ? AND kind = ?`,
now.Add(10*24*time.Hour).UTC().Format(time.RFC3339), u.ID, store.SessionKindCLI); err != nil {
t.Fatal(err)
}
newExp, renewed, err := srv.store.RenewSessionIfStale(approved.Token)
if err != nil || !renewed {
t.Fatalf("renew: renewed=%v err=%v", renewed, err)
}
within(t, "renewed expiry", newExp, now.Add(30*24*time.Hour), time.Minute)
}
// Dating the CLI token from the approval must not make it a fresh sign-in
// for the confirm-only delete recency check (BUG-3336).
func TestTASK3975_CLITokenFromOldSessionStillCannotConfirmDelete(t *testing.T) {
srv, u, token := oauthOnlyCloud(t)
ageAllSessions(t, srv, u.ID, 85*24*time.Hour)
pending, _ := srv.store.CreateCLIAuthSession()
if rr := doRequestWithCookieFrom(srv, "POST", "/api/v1/auth/cli/sessions/"+pending.Code+"/approve", nil, token, "198.51.100.7:5555"); rr.Code != http.StatusOK {
t.Fatalf("approve: %d %s", rr.Code, rr.Body.String())
}
approved, _ := srv.store.GetCLIAuthSession(pending.Code)
rr := doRequestWithBearer(srv, "POST", "/api/v1/auth/delete-account", approved.Token, map[string]any{"confirm": true})
if rr.Code != http.StatusForbidden || errorCode(t, rr) != "reauth_required" {
t.Fatalf("confirm-only with a fresh CLI token: %d %s, want 403 reauth_required", rr.Code, rr.Body.String())
}
stillThere(t, srv, u.ID)
}
// A credential-change rotation REPLACES the session, so it keeps the
// replaced session's cap (it must not extend a stolen session's life).
func TestTASK3975_RotationKeepsParentCap(t *testing.T) {
srv, u, _ := oauthOnlyCloud(t)
// A 7-day web session, so the 90-day cap (5 days away) binds rather than
// the session's own window, which a rotation keeps (BUG-3978).
token, err := srv.store.CreateSession(u.ID, "web", "198.51.100.7", "", webSessionTTL)
if err != nil {
t.Fatal(err)
}
ageAllSessions(t, srv, u.ID, 85*24*time.Hour)
req := httptest.NewRequest("POST", "/api/v1/auth/totp/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName(srv.secureCookies), Value: token})
rotated, ok := srv.rotateSessionsAfterCredentialChange(httptest.NewRecorder(), req, u)
if !ok {
t.Fatal("rotation failed")
}
info, err := srv.store.ValidateSession(rotated)
if err != nil || info == nil {
t.Fatalf("rotated session: %v", err)
}
within(t, "rotated created_at", info.CreatedAt, time.Now().Add(-85*24*time.Hour), time.Minute)
within(t, "rotated expiry", sessionExpiry(t, srv, rotated), time.Now().Add(5*24*time.Hour), time.Minute)
}
// TASK-3975 r2: only a browser session approves. A CLI token that could
// approve would mint its own successor with a fresh 90-day cap, and a chain
// of them would never expire. An 85-day-old CLI bearer is refused and mints
// nothing.
func TestTASK3975_CLIBearerCannotApprove(t *testing.T) {
t.Parallel()
srv, u, _ := oauthOnlyCloud(t)
cli, err := srv.store.CreateSession(u.ID, "cli-browser-auth", "198.51.100.7", "", 30*24*time.Hour)
if err != nil {
t.Fatal(err)
}
ageAllSessions(t, srv, u.ID, 85*24*time.Hour)
countSessions := func() int {
var n int
if err := srv.store.DB().QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, u.ID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
before := countSessions()
pending, err := srv.store.CreateCLIAuthSession()
if err != nil {
t.Fatal(err)
}
rr := doRequestWithBearer(srv, "POST", "/api/v1/auth/cli/sessions/"+pending.Code+"/approve", cli, nil)
if rr.Code != http.StatusForbidden || errorCode(t, rr) != "session_required" {
t.Fatalf("approve with a CLI bearer: %d %s, want 403 session_required", rr.Code, rr.Body.String())
}
if after := countSessions(); after != before {
t.Fatalf("a refused approval left %d sessions, want %d", after, before)
}
got, err := srv.store.GetCLIAuthSession(pending.Code)
if err != nil || got == nil || got.Status != "pending" || got.Token != "" {
t.Fatalf("the CLI auth session after a refused approval: %+v %v, want pending with no token", got, err)
}
}