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

117 lines
5.0 KiB
Go

package server
import (
"log/slog"
"time"
"github.com/PerpetualSoftware/pad/internal/oauth"
"github.com/PerpetualSoftware/pad/internal/store"
)
// The OAuth expiry sweep's retention (BUG-3301). A row is deleted only when
// its requested_at is older than its token's lifespan plus
// oauthSweepGrace. A row's requested_at is its own issuance, and fosite
// sets its expiry to that instant plus the lifespan (rounded to the
// second), so the cutoff is always at least a day past the moment the
// token stopped being usable. No live chain, and no /oauth/token
// exchange racing the sweep, can need a row the sweep deletes: an
// exchange that could still succeed presents a token younger than its
// lifespan.
//
// Per table:
//
// - refresh: the refresh lifespan (30 d by default). Rotation leaves each
// old refresh row inactive, and fosite checks "inactive" BEFORE
// "expired" (flow_refresh.go), so a replayed rotated token revokes the
// whole family. Keeping the row for as long as its token could have
// been valid keeps that detection for every token that could still
// matter. Past it, a replay answers invalid_grant without revoking;
// the token was expired anyway, so the attacker gains nothing.
// - access: the access lifespan (1 h). An inactive and a missing access
// row both reject, and fosite never revokes a family on access-token
// reuse, so nothing reads one past its expiry.
// - authorization codes: the code lifespan plus the refresh lifespan, not
// the code's 15 min alone. A reused code revokes the chain it produced
// (flow_authorize_code_token.go). The code's requested_at is when it
// was authorized, and the first refresh token it yields starts its
// lifespan at the exchange, up to a code lifespan later. So this keeps
// reuse detection for as long as that first refresh token could have
// been valid, at one row per grant.
// - PKCE: the code lifespan. A successful exchange deletes the row; what
// is left belongs to abandoned authorizations, useless once the code
// has expired.
//
// Audit does not need these rows: mcp_audit_log records the chain's
// request_id, with no foreign key to a token row.
//
// The grace absorbs fosite's rounding and any clock skew, with margin; a
// day is far more than either needs, and costs one extra day of rows.
const oauthSweepGrace = 24 * time.Hour
// Batching (BUG-3301), measured on SQLite with 50,000 aged refresh rows
// while a second goroutine wrote an access-token row every 5 ms (the write
// /oauth/token does):
//
// - One 500-row DELETE: p50 10.4 ms, p95 17.8 ms, max 19.0 ms.
// - Back to back with no pause, the writer lost the lock to the next batch
// again and again while SQLite's busy handler backed off: one write
// waited 1.55 s (the whole sweep), and in a second run p99 was 753 ms.
// - With oauthSweepPause between batches: write max 52 ms and 44 ms over
// two runs (p99 40 ms, p50 about 7 ms), and the sweep took 5-6 s.
//
// The cap bounds a tick at 200 x 500 = 100,000 rows per table, about 6 s
// of wall time, far past the measured growth of 48 rows a day per active
// client; a backlog (an install that ran unswept for months) drains over a
// few hourly ticks.
const (
oauthSweepBatchSize = 500
oauthSweepMaxBatches = 200
oauthSweepPause = 20 * time.Millisecond
)
// oauthSweepCutoffs derives the per-table cutoffs from the running OAuth
// server's lifespans, or the package defaults when OAuth is not
// constructed (an install that served OAuth before and no longer does
// still has rows to sweep).
func (s *Server) oauthSweepCutoffs(now time.Time) store.OAuthSweepCutoffs {
l := oauth.DefaultLifespans()
if s.oauthServer != nil {
l = s.oauthServer.Lifespans()
}
// fosite reads a negative lifespan as "never expires", so a table whose
// retention depends on one gets a zero cutoff, which the store skips:
// no row of a token that cannot expire is ever old enough to delete.
before := func(lifespans ...time.Duration) time.Time {
var total time.Duration
for _, d := range lifespans {
if d < 0 {
return time.Time{}
}
total += d
}
return now.Add(-(total + oauthSweepGrace))
}
return store.OAuthSweepCutoffs{
AccessTokens: before(l.AccessToken),
RefreshTokens: before(l.RefreshToken),
AuthorizationCodes: before(l.AuthorizeCode, l.RefreshToken),
PKCERequests: before(l.AuthorizeCode),
}
}
// sweepExpiredOAuthRows is the token reaper's OAuth step.
func (s *Server) sweepExpiredOAuthRows() {
res, err := s.store.SweepExpiredOAuthRows(s.oauthSweepCutoffs(time.Now()), oauthSweepBatchSize, oauthSweepMaxBatches, oauthSweepPause)
if err != nil {
slog.Warn("token reaper: sweep expired OAuth rows failed", "error", err, "deleted", res.Total())
return
}
if res.Total() == 0 {
return
}
slog.Info("token reaper: swept expired OAuth rows",
"access_tokens", res.AccessTokens, "refresh_tokens", res.RefreshTokens,
"authorization_codes", res.AuthorizationCodes, "pkce_requests", res.PKCERequests,
"capped", res.Capped)
}