Attesto.DPoP.ReplayCache (Attesto v0.5.0)

Copy Markdown View Source

In-memory, TTL-bounded cache of seen DPoP proof jti values.

RFC 9449 §11.1 requires the resource server to refuse a DPoP proof whose jti it has previously processed. A captured-and-replayed proof would otherwise be reusable for the full iat acceptance window (default 60 seconds).

This module is a ready-made implementation for the :replay_check option of Attesto.DPoP.verify_proof/2. It stores jti values in a public ETS table owned by a GenServer that sweeps expired entries on a fixed interval; lookups are O(1) and lock-free via :ets.insert_new/2.

Single-node deployment invariant (load-bearing)

This implementation is a per-node ETS singleton. RFC 9449 §11.1 replay rejection only holds across the 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. On a multi-node deployment you MUST swap the verifier's :replay_check callback for a shared-store implementation (e.g. a Postgres-backed cache using INSERT ... ON CONFLICT DO NOTHING for an atomic record-and-check, or Redis) and set :multi_node_acknowledged?: true to silence the boot-time guard. The verifier's :replay_check shape ((jti, ttl_seconds) -> :ok | {:error, :replay}) lets any such replacement plug in without changes to Attesto.DPoP. The verifier passes its own :max_age_seconds as ttl_seconds, so a shared store can size each jti's retention to the proof's freshness window.

The boot-time guard raises on startup if Node.list/0 is non-empty and :multi_node_acknowledged? is not set - a clustered BEAM with a node-local replay cache is a silently-broken security boundary (a captured proof becomes replayable once per node) that this guard refuses to enter. Failing the supervised start surfaces the misconfiguration loudly rather than emitting a log nobody reads.

Configuration (start options)

  • :ttl_seconds (default 60) - how long each jti is remembered. SHOULD match (or modestly exceed) the verifier's :max_age_seconds so a proof whose iat window has already closed is rejected by freshness OR by replay, never just by eviction race.
  • :sweep_interval_ms (default 30_000) - how often expired entries are deleted in bulk. The cache is correct without sweeping (lookups re-validate expiry); the sweeper just bounds table size.
  • :multi_node_acknowledged? (default false) - set to true after wiring a shared-store :replay_check so the boot-time guard does not fire on a clustered BEAM.

Wiring

children = [
  {Attesto.DPoP.ReplayCache, ttl_seconds: 60}
]

then, at the verifier:

Attesto.DPoP.verify_proof(proof,
  http_method: "GET",
  http_uri: uri,
  replay_check: &Attesto.DPoP.ReplayCache.check_and_record/2
)

Summary

Functions

Record jti and report whether it has already been seen within the TTL window.

Clear every entry from the cache. Test-facing.

Return the number of entries currently held. Test/diagnostic-facing.

Start the cache. Registered under __MODULE__.

Functions

check_and_record(jti, ttl_seconds \\ 60)

@spec check_and_record(String.t(), pos_integer()) :: :ok | {:error, :replay}

Record jti and report whether it has already been seen within the TTL window.

Returns :ok if the jti was not present (and has now been recorded), or {:error, :replay} if it was. The two-argument form (check_and_record/2) takes the jti and the TTL to remember it for, 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.

reset()

@spec reset() :: :ok

Clear every entry from the cache. Test-facing.

size()

@spec size() :: non_neg_integer()

Return the number of entries currently held. Test/diagnostic-facing.

start_link(opts \\ [])

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

Start the cache. Registered under __MODULE__.