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
117 lines
5.0 KiB
Go
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)
|
|
}
|