Ecto-backed, shared-store jti replay check for DPoP proofs
(RFC 9449 §11.1).
RFC 9449 §11.1 requires the resource server to refuse a DPoP proof whose
jti it has already processed. A captured-and-replayed proof would
otherwise be reusable for the full iat acceptance window (typically 60
seconds).
check_and_record/2 implements the (jti, ttl_seconds) -> :ok | {:error, :replay} callback shape that Attesto.DPoP.verify_proof/2
invokes via its :replay_check option. Records live in one relational
table (AttestoPhoenix.Schema.DPoPReplay), so the check is correct
across every node of a multi-node deployment: a jti recorded on any
node is rejected on every other. The verifier passes its own
:max_age_seconds as ttl_seconds, so each record's retention is sized
to the proof's freshness window.
Why a shared store
Attesto.DPoP.ReplayCache is a per-node ETS singleton. RFC 9449 §11.1
replay rejection only holds across a deployment if every request for a
given access token reaches the same node - otherwise a captured proof is
replayable once per node behind a load balancer. A multi-node deployment
MUST therefore use a shared store such as this one. A single-node host
may instead wire Attesto.DPoP.ReplayCache directly and skip the
database round-trip.
Atomic record-and-check
check_and_record/2 inserts the jti with its expires_at:
:ok- the row was inserted; thisjtihad not been seen.{:error, :replay}- the insert hit the unique constraint onjti(the table's primary key); thisjtiwas already recorded and the proof is a replay.
The decision is made by the database's unique constraint, not by a
read-then-write in the application, so two concurrent requests carrying
the same jti (on one node or several) cannot both observe :ok:
exactly one insert wins and every other observes the conflict. This is
the relational equivalent of INSERT ... ON CONFLICT DO NOTHING and is
the property that makes the check safe across nodes.
An already-expires_at-elapsed row for the same jti is treated as a
collision and rejected rather than overwritten. That is not a
correctness gap: a proof whose iat window has closed is rejected by
DPoP freshness before replay is even consulted, so the only effect is to
fail closed on an unreachable corner. The periodic sweep reclaims
expired rows so the table does not grow without bound.
Periodic sweep
Rows whose expires_at is in the past are no longer security-relevant
(a repeat of the same jti is rejected by DPoP freshness before replay
is consulted), so they are deleted in bulk by
AttestoPhoenix.Store.Sweeper, the package's GenServer sweeper, on a
fixed interval. The check is correct without the sweeper running; the
sweeper only reclaims space.
Configuration
All policy is read from configuration; nothing is hardcoded. The backing
Ecto.Repo is the one configured for the library
(config :attesto_phoenix, repo: MyApp.Repo), the same value
AttestoPhoenix.Config carries under :repo. It is read at call time
and a missing repo fails closed: a replay check with no backing store
could make no decision safely.
Wiring
Use it as the verifier's :replay_check:
Attesto.DPoP.verify_proof(proof,
http_method: "GET",
http_uri: uri,
replay_check: &AttestoPhoenix.Store.EctoReplayCheck.check_and_record/2
)
Summary
Functions
Record jti and report whether it has already been seen within its TTL
window.
Delete every recorded jti whose expires_at has elapsed and return the
count.
Functions
@spec check_and_record(String.t(), pos_integer()) :: :ok | {:error, :replay}
Record jti and report whether it has already been seen within its TTL
window.
Returns :ok when the jti was not present and has now been recorded,
or {:error, :replay} when an entry already exists. The two-argument
form takes the jti and the number of seconds to retain it, which is
the shape Attesto.DPoP.verify_proof/2 passes its :replay_check
callback (the verifier derives the TTL from its own acceptance window).
Pass &check_and_record/2 directly. The TTL argument defaults to
60 seconds when called as check_and_record/1.
The Ecto.Repo is read from configuration; replay policy is never
hardcoded here.
@spec sweep() :: non_neg_integer()
Delete every recorded jti whose expires_at has elapsed and return the
count.
AttestoPhoenix.Store.Sweeper drives the periodic sweep across all
Ecto-backed tables; this function exposes the replay-table sweep on its
own for hosts that prefer to drive it from their own scheduler. Deletion
uses a strict < comparison against a single captured "now", so a row
whose expires_at equals "now" is retained, never deleted: the sweep
widens no acceptance window.