AttestoPhoenix.Store.EctoNonceStore (AttestoPhoenix v0.6.1)

Copy Markdown View Source

Postgres-backed Attesto.DPoP.NonceStore for clustered deployments (RFC 9449 §8).

A server may require a server-issued nonce in every DPoP proof, binding each proof to a short-lived, server-chosen value. This defeats proof pre-generation and narrows the replay window beyond what the jti cache (RFC 9449 §11.1) alone provides. An in-memory store cannot share a nonce across nodes: a nonce issued on one node would be unknown to another. This implementation persists each nonce so any node honours a nonce issued by any other.

Behaviour callbacks

  • issue/1 mints, persists, and returns an opaque nonce for the DPoP-Nonce response header (RFC 9449 §8.1). The TTL is recorded on the row as a concrete expires_at so a later check needs no TTL argument.
  • valid?/1 reports whether a nonce was issued by this store, has not expired, and has not yet been consumed. It is read-only, so it is the shape Attesto.DPoP.verify_proof/2 expects for :nonce_check when the caller does not need single-use consumption.

Single-use consume

A read-only check cannot make a nonce single-use under concurrency: two requests could both observe the same live nonce. accept/2 is the atomic consume primitive that delivers the single-use guarantee (RFC 9449 §8). It marks the nonce used and returns :ok to exactly one caller, or {:error, :used | :expired | :unknown} to every other, via one conditional statement:

UPDATE dpop_nonces
   SET used_at = $now
 WHERE nonce = $nonce AND used_at IS NULL AND issued_at >= $cutoff

Postgres serialises concurrent updates to the same row, so exactly one caller observes an affected-row count of 1 (the winner) and the rest observe 0. No read-modify-write race exists.

TTL

issue/1 records issuance and the derived expiry; accept/2 also takes the TTL so the caller's policy, not this store, fixes the freshness window. A nonce whose issued_at is older than now - ttl is rejected with {:error, :expired} (RFC 9449 §8).

The repository is read from the supplied AttestoPhoenix.Config.

Summary

Functions

Atomically consumes nonce under a freshness window of ttl seconds.

Mints and persists a fresh nonce valid for ttl_seconds, returning the opaque value to put in a DPoP-Nonce header. Behaviour entrypoint; resolves the repo from the application-wide configured AttestoPhoenix.Config.

Returns true iff nonce was issued by this store, has not expired, and has not been consumed. Behaviour entrypoint; resolves the repo from the application-wide configured AttestoPhoenix.Config.

Functions

accept(nonce, ttl)

@spec accept(String.t(), pos_integer()) :: :ok | {:error, :used | :expired | :unknown}

Atomically consumes nonce under a freshness window of ttl seconds.

Returns :ok to the single caller that wins the consume, and a precise reason to every other so the server can answer with the correct DPoP error (RFC 9449 §8) rather than silently rejecting (fail-closed):

  • {:error, :used} - the nonce was already consumed.
  • {:error, :expired} - the nonce is past the freshness window.
  • {:error, :unknown} - the nonce was never issued by this store.

Behaviour-style entrypoint; resolves the repo from the application-wide configured AttestoPhoenix.Config.

accept(config, nonce, ttl)

@spec accept(AttestoPhoenix.Config.t(), String.t(), pos_integer()) ::
  :ok | {:error, :used | :expired | :unknown}

Like accept/2, using an explicit AttestoPhoenix.Config.

issue(ttl_seconds)

@spec issue(pos_integer()) :: String.t()

Mints and persists a fresh nonce valid for ttl_seconds, returning the opaque value to put in a DPoP-Nonce header. Behaviour entrypoint; resolves the repo from the application-wide configured AttestoPhoenix.Config.

issue(config, ttl_seconds)

Like issue/1, using an explicit AttestoPhoenix.Config.

valid?(nonce)

@spec valid?(String.t()) :: boolean()

Returns true iff nonce was issued by this store, has not expired, and has not been consumed. Behaviour entrypoint; resolves the repo from the application-wide configured AttestoPhoenix.Config.

valid?(config, nonce)

@spec valid?(AttestoPhoenix.Config.t(), String.t()) :: boolean()

Like valid?/1, using an explicit AttestoPhoenix.Config.