mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
b510d67696
* 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
113 lines
3.7 KiB
Go
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()
|
|
}
|