Ecto implementation of the Attesto.RefreshStore behaviour.
The protocol core (Attesto.RefreshToken) owns all rotation logic and reuse
detection; this module is purely the storage seam. It persists refresh-token
records over AttestoPhoenix.Schema.RefreshToken and provides the atomic
single-use claim on which reuse detection depends (RFC 6749 §10.4, OAuth 2.0
Security BCP §4.13).
Why the claim must be atomic
Rotation requires detecting when an already-rotated (consumed) token is
presented again: that is the captured-token signal, and the whole family
must then be revoked. Reliable detection needs a compare-and-set that, in
one indivisible step, checks the token is unconsumed and marks it consumed.
Here that is a single conditional UPDATE ... RETURNING:
UPDATE attesto_refresh_tokens
SET consumed = true, consumed_at = now()
WHERE token_hash = $1 AND consumed = false
RETURNING ...Zero rows updated with a row still present means the token was already
consumed: reuse. A non-atomic read-then-write would let two concurrent
rotations both observe "unconsumed" and both succeed, defeating detection.
This holds across all nodes sharing the database, which the single-node ETS
store cannot offer. consume/1 returns {:ok, entry} to the single winner,
{:reuse, entry} on a replay, and :error for an unknown token.
Sticky revocation
revoke_family/1 marks every row in the family revoked (it does not delete
them) so the revocation persists. A subsequent insert/1 checks the family
before writing and refuses with {:error, :family_revoked}, so a successor
whose claim won before the revocation landed cannot be added to a revoked
family. Revocation therefore rejects later inserts, not only the rows present
when it ran. The check and the insert run in one transaction so no concurrent
revocation can interleave between them.
The repo is resolved from the application environment (:repo under the
:attesto_phoenix app) so the host owns the connection; nothing here
hardcodes an OTP app's repo, and a missing repo fails closed.
Summary
Functions
Atomically marks the token consumed if it was not already.
Non-consuming read of the record for token_hash, or :error if absent or
family-revoked.
Persists a new (unconsumed) refresh-token record.
Records the successor minted by a consumed parent token.
Revokes a token family: marks every token in family_id revoked.
Functions
@spec consume( Attesto.RefreshStore.token_hash(), keyword() ) :: {:ok, Attesto.RefreshStore.entry()} | {:reuse, Attesto.RefreshStore.entry()} | :error
Atomically marks the token consumed if it was not already.
Returns {:ok, entry} to the single caller that wins the claim (the record
is reported as it stood, unconsumed, since the successor is minted from it),
{:reuse, entry} when the token was already consumed (the caller MUST then
revoke_family/1; the entry carries the :family_id), or :error for an
unknown token. The conditional UPDATE ... WHERE consumed = false RETURNING
is one indivisible statement, so concurrent rotations cannot both win.
@spec get(Attesto.RefreshStore.token_hash()) :: {:ok, Attesto.RefreshStore.entry()} | :error
Non-consuming read of the record for token_hash, or :error if absent or
family-revoked.
Returns the record in the Attesto.RefreshStore contract shape (opaque
:data context, :expires_at as absolute unix seconds). Used by
Attesto.RefreshToken to validate a rotation (expiry, client and DPoP
binding) and to detect an already-consumed replay before the atomic claim,
so a recoverable validation failure does not burn the token.
@spec insert(Attesto.RefreshStore.entry()) :: :ok | {:error, :family_revoked}
Persists a new (unconsumed) refresh-token record.
Returns {:error, :family_revoked} when the record's :family_id has
already been revoked, and the row is NOT written. The revocation check and
the insert run in one transaction so a concurrent revoke_family/1 cannot
slip between them and leave a live successor in a revoked family (sticky
revocation, RFC 6749 §10.4). The opaque store record is flattened onto the
schema columns by AttestoPhoenix.Schema.RefreshToken.from_store_record/2.
@spec remember_successor(Attesto.RefreshStore.token_hash(), map(), keyword()) :: :ok | :error
Records the successor minted by a consumed parent token.
The core uses this for refresh-rotation idempotency: an immediate retry of a
just-rotated token by the same client can receive the same successor rather
than revoking the family. Only consumed parents accept a successor marker.
The marker is encrypted before it is written to the database; if no
:refresh_successor_secret is configured, the store fails closed by returning
:error.
@spec revoke_family(Attesto.RefreshStore.family_id()) :: :ok
Revokes a token family: marks every token in family_id revoked.
The rows are kept (their :family_revoked flag is set) rather than deleted,
so the revocation is sticky: a successor insert/1 serialized after this call
is refused (see insert/1). Idempotent: re-revoking is a no-op re-set, and
revoking an unknown family updates nothing and returns :ok.