package oauth import ( "context" "fmt" "time" ) // Cleanup of spent and expired OAuth rows. // // WHAT IS DELETED, AND WHAT IS DELIBERATELY NOT // // Only rows that can no longer authenticate anything. Every predicate below // requires the row to be past its expiry — not merely consumed, not merely // revoked — because those two states are evidence, and evidence is worth // keeping until it stops being relevant. // // A consumed refresh token in particular must outlive its usefulness: it is // what REUSE DETECTION matches against. Delete it the moment it is spent and a // stolen token replayed a minute later looks like an unknown token rather than // a theft, and the family is never revoked. So a consumed refresh token is kept // until its original expiry, by which point replaying it proves nothing anyway. // // A revoked token is kept for the same reason plus one more: "this token was // revoked at 14:02 for refresh_token_reuse" is an answer to a question someone // will eventually ask. // // GRACE. Everything is deleted a grace period AFTER expiry rather than at it, // so a clock skewed between two instances cannot delete a row another instance // still considers live. // // SAFE TO RUN TWICE, AND SAFE TO RUN CONCURRENTLY. Every statement is a bounded // DELETE with a predicate that no longer matches once the row is gone. Two // workers running at once delete disjoint sets and neither errors. // CleanupGrace is how long a dead row is kept past its expiry. // // An hour is far beyond any plausible clock skew between instances and short // enough that the tables do not accumulate. It also means a support question // asked within the hour can still see the row. const CleanupGrace = time.Hour // CleanupBatch bounds one pass. // // Bounded because an unbounded DELETE holds locks for as long as it runs, and // on a table that every MCP request reads that is a latency spike nobody can // explain afterwards. Five thousand rows is milliseconds; if there is more, the // next pass takes it. const CleanupBatch = 5000 // CleanupResult reports what one pass removed. type CleanupResult struct { Grants int64 AccessTokens int64 RefreshTokens int64 CompletedInOne bool // false when a batch filled, meaning more remains } // Cleanup removes expired authorization codes and tokens. // // Returns counts rather than logging them, so the caller decides the level and // this function stays usable from a test. func (s *Store) Cleanup(ctx context.Context) (CleanupResult, error) { cutoff := s.now().Add(-CleanupGrace) var out CleanupResult // Authorization codes. Sixty-second TTL, so almost every row here is // already dead; this is the highest-volume and cheapest of the three. // // ctid rather than id in the subquery because it is the physical row // address — the planner can go straight to it without a second index // lookup, which is what keeps a bounded delete genuinely cheap. tag, err := s.db.Exec(ctx, `DELETE FROM oauth_grants WHERE ctid IN ( SELECT ctid FROM oauth_grants WHERE expires_at < $1 LIMIT $2 )`, cutoff, CleanupBatch) if err != nil { return out, fmt.Errorf("oauth: cleanup grants: %w", err) } out.Grants = tag.RowsAffected() // Access tokens. Fifteen-minute TTL. An expired one cannot authenticate — // FindAccessToken's predicate already excludes it — so deleting it removes // no capability. tag, err = s.db.Exec(ctx, `DELETE FROM oauth_tokens WHERE ctid IN ( SELECT ctid FROM oauth_tokens WHERE token_type = 'access' AND expires_at < $1 LIMIT $2 )`, cutoff, CleanupBatch) if err != nil { return out, fmt.Errorf("oauth: cleanup access tokens: %w", err) } out.AccessTokens = tag.RowsAffected() // Refresh tokens, and this is the one with a real constraint on it. // // EXPIRY ONLY — not `consumed_at IS NOT NULL`, and not `revoked_at IS NOT // NULL`. A consumed refresh token is what RedeemRefreshToken matches to // detect reuse; deleting it early turns a detectable theft into an // unremarkable "unknown token" and the family is never revoked. Thirty-day // TTL means these are the longest-lived rows in the schema, which is the // price of that detection and is worth paying. tag, err = s.db.Exec(ctx, `DELETE FROM oauth_tokens WHERE ctid IN ( SELECT ctid FROM oauth_tokens WHERE token_type = 'refresh' AND expires_at < $1 LIMIT $2 )`, cutoff, CleanupBatch) if err != nil { return out, fmt.Errorf("oauth: cleanup refresh tokens: %w", err) } out.RefreshTokens = tag.RowsAffected() out.CompletedInOne = out.Grants < CleanupBatch && out.AccessTokens < CleanupBatch && out.RefreshTokens < CleanupBatch return out, nil } // RevokeExpiredFamilies is deliberately absent. // // It looks like it belongs here — "tidy up families whose tokens have all // lapsed" — and it would do nothing. Revocation is a state on a row, and a row // that has been deleted has no state to set. A family whose every token has // expired and been swept simply ceases to exist, which is the correct outcome // and requires no work.