Files
pad/docs
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
..