Files
pad/internal/server/token_reaper.go
T
xarmian b510d67696 fix(oauth): sweep expired token, code and PKCE rows (BUG-3301) (#1671)
* fix(oauth): sweep expired token, code and PKCE rows (BUG-3301)

Nothing deleted OAuth rows by age. Each refresh leaves the chain's
previous access and refresh rows inactive and inserts a fresh pair,
measured at +1 access and +1 refresh row per refresh (48 rows a day for
one active client at the 1 h access lifetime), forever, on Pad Cloud and
on any https self-host that turns MCP on.

The token reaper now also runs SweepExpiredOAuthRows every hour. A row
goes once its requested_at (its own issuance, measured) is older than
its token's lifespan plus 24 h:
- refresh and authorization codes: the refresh lifespan. A replayed
  rotated token or a reused code keeps revoking the family for as long
  as the token could have been valid, because fosite checks "inactive"
  before "expired".
- access: the access lifespan. Nothing reads an expired access row.
- PKCE: the code lifespan. Only abandoned authorizations leave one.
The lifespans come from the running OAuth server (defaults when OAuth is
not built). No row a live chain or a concurrent exchange could use is
ever below a cutoff.

Deletes run 500 rows per statement with a 20 ms pause between batches.
Measured on SQLite with 50,000 rows and a concurrent token write every
5 ms: with no pause, one write waited 1.55 s; with the pause, the worst
write took 52 ms. The per-tick cap is 200 batches per table.

Connected Apps and the MCP resume count now drop a chain whose refresh
token has been expired for the grace period, so their "upper bound"
comments say how far. The deployment guide's "never swept" gap is gone.

Tests: store cutoffs, zero cutoffs, and batches with the cap, on SQLite
and Postgres. A server end-to-end test through /oauth/token: the live
chain survives and still refreshes; an aged-out chain leaves Connected
Apps and the resume count; a swept token is refused without revoking the
family; a retained rotated token still revokes it. Mutants: inverted age
comparison (store tests fail), refresh cutoff at now (e2e fails: the
live chain's refresh answers invalid_grant).

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

* fix(oauth): code retention adds the code lifespan; Capped means "may remain" (BUG-3301, codex r1)

- A code's requested_at is when it was authorized, but the first refresh
  token it yields starts at the exchange, up to a code lifespan later. The
  code cutoff now adds the code lifespan to the refresh lifespan, so reuse
  detection lasts as long as that refresh token could be valid. The
  comment no longer claims "the chain's lifetime": a chain renews forever.
- Capped says the cap was used, so rows MAY remain; the last full batch
  may have been the end.

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

* fix(oauth): a non-expiring lifespan skips its tables (BUG-3301, codex r2)

fosite reads a negative lifespan as "never expires"; the cutoff math
turned -1 into "now minus the grace", which would delete a live
non-expiring token after a day. A table whose retention depends on a
negative lifespan now gets a zero cutoff, which the store skips (codes
depend on both the code and refresh lifespans).

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 19:29:17 -04:00

113 lines
3.7 KiB
Go

package server
import (
"log/slog"
"sync"
"time"
)
// defaultTokenReaperInterval is how often the background reaper sweeps expired
// short-lived credentials. Hourly balances "bounded row growth" against
// "negligible overhead" — every table it touches is small and every DELETE is
// range-indexed on expires_at. PLAN-1933 DR-5 / TASK-1936.
const defaultTokenReaperInterval = 1 * time.Hour
// tokenReaperConfig captures the runtime knobs + lifecycle for the periodic
// token-reaper loop. Mirrors orphanGCConfig / opLogGCConfig (same lifecycle
// pattern, separate goroutine, separate stop channel). The CleanExpired*
// store methods existed but were never called before this — they leak rows
// without a caller (DR-5).
type tokenReaperConfig struct {
mu sync.Mutex
interval time.Duration
stop chan struct{}
running bool
}
// SetTokenReaperConfig overrides the default sweep interval (1h). Pass 0 to
// keep the package default. Must be called before StartTokenReaper; calling
// after the loop has started silently no-ops since the goroutine captured the
// value at start.
func (s *Server) SetTokenReaperConfig(interval time.Duration) {
s.tokenReaper.mu.Lock()
defer s.tokenReaper.mu.Unlock()
if interval > 0 {
s.tokenReaper.interval = interval
}
}
// StartTokenReaper kicks off the periodic sweep that deletes expired/used
// email-verification tokens, password-reset tokens, sessions, CLI-auth
// sessions, and expired OAuth token, code and PKCE rows (BUG-3301,
// oauth_sweep.go). Idempotent — calling twice silently no-ops.
//
// Started from the real server bootstrap path (cmd/pad/main.go), NOT from
// Server.New, so unit tests that construct a Server don't spawn a background
// goroutine unless they opt in (mirrors StartOrphanGC / StartOpLogGC). The
// loop is tracked by Server.bg so Stop() drains it before the process exits /
// the DB is closed (BUG-842 invariant).
func (s *Server) StartTokenReaper() {
s.tokenReaper.mu.Lock()
if s.tokenReaper.running {
s.tokenReaper.mu.Unlock()
return
}
if s.tokenReaper.interval == 0 {
s.tokenReaper.interval = defaultTokenReaperInterval
}
s.tokenReaper.stop = make(chan struct{})
s.tokenReaper.running = true
interval := s.tokenReaper.interval
stop := s.tokenReaper.stop
s.tokenReaper.mu.Unlock()
slog.Info("token reaper started", "interval", interval.String())
s.bg.Add(1)
go func() {
defer s.bg.Done()
defer s.recoverSweeper("token-reaper") // BUG-2071
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.runTokenReaperTick()
}
}
}()
}
// stopTokenReaper signals the loop to exit. Called from Server.Stop().
// Safe to call when the loop never started.
func (s *Server) stopTokenReaper() {
s.tokenReaper.mu.Lock()
defer s.tokenReaper.mu.Unlock()
if !s.tokenReaper.running {
return
}
close(s.tokenReaper.stop)
s.tokenReaper.running = false
}
// runTokenReaperTick runs one sweep. Each cleaner is independent — a failure
// in one is logged and does not skip the others. Quiet on success (operators
// don't want hourly no-op log lines).
func (s *Server) runTokenReaperTick() {
if err := s.store.CleanExpiredEmailVerifications(); err != nil {
slog.Warn("token reaper: clean expired email verifications failed", "error", err)
}
if err := s.store.CleanExpiredPasswordResets(); err != nil {
slog.Warn("token reaper: clean expired password resets failed", "error", err)
}
if err := s.store.CleanExpiredSessions(); err != nil {
slog.Warn("token reaper: clean expired sessions failed", "error", err)
}
if err := s.store.CleanExpiredCLIAuthSessions(); err != nil {
slog.Warn("token reaper: clean expired CLI auth sessions failed", "error", err)
}
s.sweepExpiredOAuthRows()
}