Files
krow_backend/go-api/internal/oauth/cleanup.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

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.