# `Attesto.DPoP.ReplayCache`
[🔗](https://github.com/XukuLLC/attesto/blob/v0.13.0/lib/attesto/dpop/replay_cache.ex#L1)

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
    )

# `check_and_record`

```elixir
@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`

```elixir
@spec reset() :: :ok
```

Clear every entry from the cache. Test-facing.

# `size`

```elixir
@spec size() :: non_neg_integer()
```

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

# `start_link`

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

Start the cache. Registered under `__MODULE__`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
