AttestoPhoenix.Store.Sweeper (AttestoPhoenix v3.0.0)

Copy Markdown View Source

Periodic housekeeping GenServer that deletes expired rows from the Ecto-backed authorization-code, refresh-token, device-code, CIBA-request, logout-session, DPoP-nonce, DPoP-replay, pushed-authorization-request, client-id-metadata-cache, and consent-grant tables.

Each of these tables carries an expires_at column whose semantics are fixed by the relevant RFC:

  • authorization codes - RFC 6749 §4.1.2 ("The authorization code MUST expire shortly after it is issued") and §10.5 (codes are short-lived, single-use).
  • refresh tokens - RFC 6749 §1.5 / §6 (refresh tokens MAY expire); the stored expiry bounds the credential's lifetime.
  • server-issued DPoP nonces - RFC 9449 §8 / §9 (the nonce the resource or authorization server requires the client to echo is time-bounded).
  • DPoP proof jti replay records - RFC 9449 §11.1 (a jti need only be remembered for the proof iat acceptance window; past that window the record is dead weight).
  • pushed authorization requests - RFC 9126 §2.2 (a request_uri reference is short-lived; past its expiry it can resolve nothing).
  • cached Client ID Metadata Documents - draft-ietf-oauth-client-id-metadata-document-01 §6 / RFC 9111 (a cached document is fresh only until its expires_at; past that it is re-fetched).
  • consent grants - RFC 6749 §4.1.1 / §4.1.2 (consent precedes a short-lived authorization code; a grant past its expires_at can authorize nothing, and consume/2 already rejects it on read).
  • back-channel-logout sessions - OpenID Connect Back-Channel Logout 1.0 (a recorded (session, RP) delivery row past its expires_at belongs to an abandoned session and is no longer a logout target).
  • CIBA authentication requests - OpenID Connect CIBA Core 1.0 §7.3 (an auth_req_id past its expires_at yields expired_token and can mint nothing; redeem/4 already re-checks expiry on read).

Correctness vs. housekeeping

Expiry-row deletion is not required for authorization correctness. Every store re-validates expires_at against the current time on read, so an expired row that has not yet been swept is never honored: an expired authorization code is rejected, an expired nonce is rejected, and an expired replay record no longer blocks a fresh jti. Those deletes only bound table growth by reclaiming rows that can no longer affect any decision. A consumed code's expired row can still carry the replay-revocation link for a live access token, so that row is retained until the linked token expires.

The process also irreversibly redacts refresh-successor ciphertext whose short retry deadline has passed, on the next scheduled sweep. When the Ecto refresh store uses a positive retry grace, this bounded credential cleanup requires the sweeper. The installer adds it to the host supervision tree automatically; manually wired applications MUST supervise it with a positive :sweep_interval_ms.

The remaining work is TTL housekeeping: it issues one delete per swept table using expires_at < $now. Authorization-code cleanup additionally keeps a row while its non-empty access-token link has a future access_token_expires_at, preserving replay revocation until that token dies.

Comparison boundary (fail-closed)

Row expiry uses a strict < comparison against a single DateTime captured once per sweep (DateTime.utc_now/0) and reused across every table, so a sweep applies one consistent boundary. A row whose expires_at equals "now" is retained. For an already-expired authorization-code row, however, a linked access token whose own expiry equals "now" is no longer live and does not delay cleanup. The sweeper widens no acceptance window.

Configuration

All policy is read from AttestoPhoenix.Config; nothing is hardcoded here.

  • :repo - the Ecto.Repo the deletes run against (required by AttestoPhoenix.Config).
  • :sweep_interval_ms - how often a sweep runs, in milliseconds. Manual supervision fails fast when this key is unset. The installer uses the explicit :if_configured mode so a rerun against an older or custom-store host leaves the child ignored when no interval was configured.
  • :schema_prefix - optional PostgreSQL schema applied to every delete so a host that installed the generated tables under a non-default schema sweeps the same tables it created.

The set of swept tables is fixed by the generated schema and is not host-configurable: every Ecto-backed store the library generates carries an expires_at column and is swept.

Summary

Functions

Starts the sweeper.

Runs a single sweep synchronously and returns the number of rows deleted per table. Test- and diagnostic-facing; the supervised process drives sweeps via the configured interval, not this call.

Functions

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the sweeper.

Requires a %AttestoPhoenix.Config{} under the :config key. The config's :sweep_interval_ms MUST be a positive integer; a missing or non-positive interval raises ArgumentError so a misconfigured host fails at boot instead of starting a process that never sweeps.

The installer passes :if_configured as true for upgrade compatibility. In that mode an absent interval returns :ignore, allowing an existing host that does not use the bundled Ecto stores to keep the sweeper disabled. An invalid non-nil interval still raises, and direct/manual supervision retains the fail-fast default.

sweep_now()

@spec sweep_now() :: %{optional(String.t()) => non_neg_integer()}

Runs a single sweep synchronously and returns the number of rows deleted per table. Test- and diagnostic-facing; the supervised process drives sweeps via the configured interval, not this call.

sweep_now(server)

@spec sweep_now(GenServer.server()) :: %{optional(String.t()) => non_neg_integer()}