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
This commit is contained in:
xarmian
2026-10-20 13:30:31 -04:00
committed by GitHub
parent 27bc097f5a
commit 393d986ea9
4 changed files with 172 additions and 9 deletions
+2 -2
View File
@@ -121,10 +121,10 @@ One browser tab's content writes to an item are ordered by the server, also **pe
- `POST /api/v1/auth/logout` — destroy session - `POST /api/v1/auth/logout` — destroy session
- `GET/PATCH /api/v1/auth/me` — current user profile (GET) and update name/password (PATCH) - `GET/PATCH /api/v1/auth/me` — current user profile (GET) and update name/password (PATCH)
- `GET /api/v1/auth/export` — the caller's account export (GDPR portability), streamed as `pad-account-export.json` (not `pad-export.json`, which is the workspace bundle's file): `{format: "pad-account-export", version: 1, user, workspaces: [...], complete: true}`, where owned workspaces carry their collections and items. `complete: true` is written last and only after every workspace (BUG-3626). The status line goes out before the body, so a timeout (60s) or a store error mid-export cannot become an error status. The server cuts the connection instead, and the body never reaches the trailer. A reader must refuse a file without `complete: true`; the web client does. 403 `forbidden` when the caller is collection-restricted in any owned workspace (BUG-1945) - `GET /api/v1/auth/export` — the caller's account export (GDPR portability), streamed as `pad-account-export.json` (not `pad-export.json`, which is the workspace bundle's file): `{format: "pad-account-export", version: 1, user, workspaces: [...], complete: true}`, where owned workspaces carry their collections and items. `complete: true` is written last and only after every workspace (BUG-3626). The status line goes out before the body, so a timeout (60s) or a store error mid-export cannot become an error status. The server cuts the connection instead, and the body never reaches the trailer. A reader must refuse a file without `complete: true`; the web client does. 403 `forbidden` when the caller is collection-restricted in any owned workspace (BUG-1945)
- `POST /api/v1/auth/delete-account` — delete the caller's account: `{password}`, or `{confirm: true}` for a cloud account with no password, plus `totp_code` when 2FA is on. The instance's last ACTIVE human admin is refused `409 last_active_admin` (BUG-3811), by a check that runs before any Stripe cancel; the deletion re-checks under its lock, and an admin set that changed in between refuses it there: `409 last_active_admin` again, or `500 partial_delete` when a Stripe cancel already ran. A disabled admin is not the admin who remains - `POST /api/v1/auth/delete-account` — delete the caller's account: `{password}`, or `{confirm: true}` for a cloud account with no password (it needs a web session signed in within the last 10 minutes, else `403 reauth_required`; a CLI-kind session, however it was minted, never satisfies it, TASK-3975, so a passwordless account confirms from the web, not the CLI), plus `totp_code` when 2FA is on. The instance's last ACTIVE human admin is refused `409 last_active_admin` (BUG-3811), by a check that runs before any Stripe cancel; the deletion re-checks under its lock, and an admin set that changed in between refuses it there: `409 last_active_admin` again, or `500 partial_delete` when a Stripe cancel already ran. A disabled admin is not the admin who remains
- `POST /api/v1/auth/forgot-password` — request password reset email - `POST /api/v1/auth/forgot-password` — request password reset email
- `POST /api/v1/auth/reset-password` — reset password with token - `POST /api/v1/auth/reset-password` — reset password with token
- `POST /api/v1/auth/cli/sessions`, `GET /api/v1/auth/cli/sessions/{code}`, `POST /api/v1/auth/cli/sessions/{code}/approve`, `POST /api/v1/auth/cli/sessions/{code}/deny` — browser sign-in for `pad auth login`: the CLI creates a session and polls it, and a signed-in user approves it at `/auth/cli/{code}`, which mints a 30-day session the poll hands to the CLI. Since TASK-2253 the session records `requester_ip` (the server's view of the creating client) and `requester_user_agent` (what the creator sent, capped at 200 characters, so requester-controlled text), and a PENDING poll returns them with `created_at` (additive, omitempty) for the approval page, which renders the agent as text labelled "reported by the requester". `deny` checks no account, because the code is the authority (it is already a bearer secret: whoever holds it can poll and collect an approval's token); it moves pending → denied, answers 200 again on a denied session, and 409 `already_approved` / 410 `expired` otherwise. A denied session POLLS as **410 `cli_auth_denied`**, not as a 200 status: a CLI from before Deny has no case for an unknown status and would poll it for 20 minutes, whereas a non-2xx counts as a poll error there and it stops after five in a row, printing the message; a current CLI stops on the first. Approving a denied session answers 409 `cli_auth_denied`, and an approval that loses to a concurrent deny ends the session it minted - `POST /api/v1/auth/cli/sessions`, `GET /api/v1/auth/cli/sessions/{code}`, `POST /api/v1/auth/cli/sessions/{code}/approve`, `POST /api/v1/auth/cli/sessions/{code}/deny` — browser sign-in for `pad auth login`: the CLI creates a session and polls it, and a signed-in user approves it at `/auth/cli/{code}`, which mints a 30-day sliding session the poll hands to the CLI, with its own lifetime: it is dated from the approval (hard cap 90 days from then), not from the approving web session's sign-in, so approving from an old web session does not shorten it (TASK-3975). Only a web-kind session approves (the browser's cookie session, or a web session sent as a bearer). A CLI-kind bearer gets `403 session_required`, because a CLI token that could approve could mint its own successor and chain past the cap (TASK-3975 r2); a CLI-kind session sent as a cookie is not a credential at all (BUG-3350) and gets `401 unauthorized`; an API token gets `403 session_required`. Since TASK-2253 the session records `requester_ip` (the server's view of the creating client) and `requester_user_agent` (what the creator sent, capped at 200 characters, so requester-controlled text), and a PENDING poll returns them with `created_at` (additive, omitempty) for the approval page, which renders the agent as text labelled "reported by the requester". `deny` checks no account, because the code is the authority (it is already a bearer secret: whoever holds it can poll and collect an approval's token); it moves pending → denied, answers 200 again on a denied session, and 409 `already_approved` / 410 `expired` otherwise. A denied session POLLS as **410 `cli_auth_denied`**, not as a 200 status: a CLI from before Deny has no case for an unknown status and would poll it for 20 minutes, whereas a non-2xx counts as a poll error there and it stops after five in a row, printing the message; a current CLI stops on the first. Approving a denied session answers 409 `cli_auth_denied`, and an approval that loses to a concurrent deny ends the session it minted
- `POST /api/v1/auth/local-reset` — localhost-only account recovery (self-host, non-cloud). Loopback-gated, no auth — the bootstrap trust model. Returns a single-use reset link, or a temp password with `{"temp_password": true}`. Backs `pad auth reset-password`. - `POST /api/v1/auth/local-reset` — localhost-only account recovery (self-host, non-cloud). Loopback-gated, no auth — the bootstrap trust model. Returns a single-use reset link, or a temp password with `{"temp_password": true}`. Backs `pad auth reset-password`.
- `GET/POST/DELETE /api/v1/auth/tokens` — user-scoped API tokens. **Minting and rotating require an INTERACTIVE SESSION** (BUG-2890): a call authenticated by a PAT is refused `403 session_required` on `POST /auth/tokens`, `POST /auth/tokens/{id}/rotate` and `POST /workspaces/{ws}/tokens`, because a token that can mint tokens outlives its own revocation. A session cookie and a `padsess_` CLI bearer both count as interactive; LIST and REVOKE stay PAT-reachable, deliberately — revocation is the compromised-credential response - `GET/POST/DELETE /api/v1/auth/tokens` — user-scoped API tokens. **Minting and rotating require an INTERACTIVE SESSION** (BUG-2890): a call authenticated by a PAT is refused `403 session_required` on `POST /auth/tokens`, `POST /auth/tokens/{id}/rotate` and `POST /workspaces/{ws}/tokens`, because a token that can mint tokens outlives its own revocation. A session cookie and a `padsess_` CLI bearer both count as interactive; LIST and REVOKE stay PAT-reachable, deliberately — revocation is the compromised-credential response
- **`scopes` on create** (TASK-3866), on both `POST /auth/tokens` and `POST /workspaces/{ws}/tokens`: a STRING holding a JSON array of `*`, `read`, `write`, `pad:read`, `pad:write`, `pad:admin` (exactly what the request-time check `tokenScopeAllows` recognizes). It is stored compact (`[ "read" ]` → `["read"]`). Only an omitted field keeps the default `["*"]`. Refused `400 validation_error`, with the message naming the bad value: a `scopes` field that is JSON `null` or not a string (an explicit null used to read as omitted and mint a full-access key), the empty string and the empty array (`""` and `[]` are the legacy unrestricted forms, and no new key is minted in them; omit `scopes` instead), a string that is not a JSON array of strings (including `"null"`), and any value outside the vocabulary. These used to be stored verbatim, minting a key that every request then refused. There are two levels: `read`/`pad:read` allow GET, HEAD and OPTIONS (plus the playbook `run` POST), and every other recognized value allows everything the user's workspace role allows. `pad:admin` grants nothing beyond `write` for an API key. The web settings form offers "Read and write" (`["*"]`) and "Read only" (`["read"]`), and the token list labels each key from `web/src/lib/settings/tokenScopes.ts`, which is held to the server by `internal/server/testdata/token_scope_levels.json`. - **`scopes` on create** (TASK-3866), on both `POST /auth/tokens` and `POST /workspaces/{ws}/tokens`: a STRING holding a JSON array of `*`, `read`, `write`, `pad:read`, `pad:write`, `pad:admin` (exactly what the request-time check `tokenScopeAllows` recognizes). It is stored compact (`[ "read" ]` → `["read"]`). Only an omitted field keeps the default `["*"]`. Refused `400 validation_error`, with the message naming the bad value: a `scopes` field that is JSON `null` or not a string (an explicit null used to read as omitted and mint a full-access key), the empty string and the empty array (`""` and `[]` are the legacy unrestricted forms, and no new key is minted in them; omit `scopes` instead), a string that is not a JSON array of strings (including `"null"`), and any value outside the vocabulary. These used to be stored verbatim, minting a key that every request then refused. There are two levels: `read`/`pad:read` allow GET, HEAD and OPTIONS (plus the playbook `run` POST), and every other recognized value allows everything the user's workspace role allows. `pad:admin` grants nothing beyond `write` for an API key. The web settings form offers "Read and write" (`["*"]`) and "Read only" (`["read"]`), and the token list labels each key from `web/src/lib/settings/tokenScopes.ts`, which is held to the server by `internal/server/testdata/token_scope_levels.json`.
+4 -2
View File
@@ -36,7 +36,9 @@ func writeAccountDisabledIf(w http.ResponseWriter, err error) bool {
// Without a password to check, a recent sign-in is the proof of identity: a // Without a password to check, a recent sign-in is the proof of identity: a
// stolen or long-lived session is not enough, and the real user signs in // stolen or long-lived session is not enough, and the real user signs in
// again. A credential-change rotation does not restart the clock // again. A credential-change rotation does not restart the clock
// (CreateSessionIssuedAt). // (CreateSessionIssuedAt). A CLI-kind session never satisfies it (TASK-3975):
// a CLI token approved from a web session has its own created_at, so its age
// says nothing about when anyone signed in.
const accountDeleteReauthWindow = 10 * time.Minute const accountDeleteReauthWindow = 10 * time.Minute
// cookieKindAccepted reports whether a session may be used as a browser // cookieKindAccepted reports whether a session may be used as a browser
@@ -141,7 +143,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
// linked OAuth, reads false too. So confirm-only also needs a recent // linked OAuth, reads false too. So confirm-only also needs a recent
// sign-in (codex review). // sign-in (codex review).
info := s.requestSessionInfo(r) info := s.requestSessionInfo(r)
if info == nil || info.User.ID != fullUser.ID || time.Since(info.CreatedAt) > accountDeleteReauthWindow { if info == nil || info.User.ID != fullUser.ID || info.Kind == store.SessionKindCLI || time.Since(info.CreatedAt) > accountDeleteReauthWindow {
writeError(w, http.StatusForbidden, "reauth_required", writeError(w, http.StatusForbidden, "reauth_required",
"For your security, sign in again to delete your account") "For your security, sign in again to delete your account")
return return
+16 -5
View File
@@ -184,18 +184,29 @@ func (s *Server) handleApproveCLIAuthSession(w http.ResponseWriter, r *http.Requ
return return
} }
// Create a new session token for the CLI (long-lived, 30 days). It is // Create a new session token for the CLI (long-lived, 30 days sliding,
// dated by the approving session's sign-in (BUG-3336): approving from an // capped at 90 days from this approval). It is a new credential on another
// existing session is not a fresh sign-in, so it must not hand out a // device, so it gets its own lifetime (TASK-3975): inheriting the approving
// session that reads as one. // 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) approver := s.requestSessionInfo(r)
if approver == nil || approver.User.ID != user.ID { if approver == nil || approver.User.ID != user.ID {
writeError(w, http.StatusUnauthorized, "unauthorized", "You must be logged in to approve a CLI session") writeError(w, http.StatusUnauthorized, "unauthorized", "You must be logged in to approve a CLI session")
return 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 // 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. // (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, approver.CreatedAt) 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) { if errors.Is(err, store.ErrUserDisabled) {
// Disabled between this request's admission and the mint (BUG-3349). // 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.") writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
@@ -0,0 +1,150 @@
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)
}
}