131 lines
5.0 KiB
Go
131 lines
5.0 KiB
Go
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.
|