Storage seam for refresh tokens, with the atomic primitive that makes reuse detection possible.
Attesto.RefreshToken is pure rotation logic; this behaviour is where
refresh tokens live and how they are consumed. Attesto.RefreshStore.ETS
is a ready single-node implementation; a production host implements it
over its database.
The consume/1 contract (load-bearing)
Refresh-token rotation (RFC 6749 §10.4, OAuth 2.0 Security BCP) requires
detecting when an already-rotated (consumed) token is presented again:
that means the token was captured, and the whole token family must be
revoked. Detecting it reliably needs an atomic compare-and-set:
consume/1 MUST, in one indivisible step, check whether the token is
unconsumed and, if so, mark it consumed.
It returns:
{:ok, record}- the token existed and was unconsumed; it is now consumed. The caller issues the next token in the family.{:reuse, record}- the token existed but was already consumed. The caller MUSTrevoke_family/1. Therecordcarries thefamily_idto revoke.:error- no such token.
A SQL implementation is UPDATE refresh_tokens SET consumed = true WHERE token_hash = $1 AND consumed = false RETURNING ...: zero rows updated
with a row present means reuse. A non-atomic get-then-update would let
two concurrent rotations both see "unconsumed" and both succeed,
defeating detection.
Record shape
:token_hash-Attesto.Secret.hash/1of the token (the key).:family_id- groups all tokens descended from one authorization; revoked together on reuse.:generation- 0 for the first token in a family, incremented each rotation. Diagnostic.:data- the opaque context the host round-trips (subject, scope, client, optional DPoP thumbprint, host claims).:expires_at- absolute expiry, unix seconds.:consumed- whether the token has been rotated already.
Summary
Callbacks
Atomically mark the token consumed if it was not already. See the
moduledoc for the required semantics and the three return values. This
is the claim step, run only once a rotation has otherwise validated; it
also closes the read-then-claim race (a concurrent rotation that claimed
the token first surfaces here as {:reuse, record}).
Non-consuming read of the record for token_hash, or :error if
absent. Used to validate a rotation (expiry, DPoP binding) and to detect
a replayed already-consumed token BEFORE the atomic consume/1 claims
it, so a recoverable validation failure does not burn the token.
Persist a new (unconsumed) refresh-token record.
Revoke a token family: remove every token in family_id AND mark the
family revoked so a subsequent insert/1 for it is refused (sticky
revocation; see insert/1). Idempotent - revoking an already-revoked or
unknown family is a no-op :ok.
Types
@type entry() :: %{ token_hash: token_hash(), family_id: family_id(), generation: non_neg_integer(), data: map(), expires_at: integer(), consumed: boolean() }
@type family_id() :: String.t()
@type token_hash() :: String.t()
Callbacks
@callback consume(token_hash()) :: {:ok, entry()} | {:reuse, entry()} | :error
Atomically mark the token consumed if it was not already. See the
moduledoc for the required semantics and the three return values. This
is the claim step, run only once a rotation has otherwise validated; it
also closes the read-then-claim race (a concurrent rotation that claimed
the token first surfaces here as {:reuse, record}).
@callback get(token_hash()) :: {:ok, entry()} | :error
Non-consuming read of the record for token_hash, or :error if
absent. Used to validate a rotation (expiry, DPoP binding) and to detect
a replayed already-consumed token BEFORE the atomic consume/1 claims
it, so a recoverable validation failure does not burn the token.
@callback insert(entry()) :: :ok | {:error, :family_revoked}
Persist a new (unconsumed) refresh-token record.
Returns {:error, :family_revoked} if the record's family_id has been
revoked (see revoke_family/1); the row MUST NOT be stored in that
case. This closes a concurrency race: a rotation that wins the atomic
consume/1 but whose successor insert/1 lands after a concurrent
reuse revoked the family would otherwise leave a live successor in a
revoked family. Revocation is therefore sticky - it rejects later
inserts, not just the rows present at revoke time.
@callback revoke_family(family_id()) :: :ok
Revoke a token family: remove every token in family_id AND mark the
family revoked so a subsequent insert/1 for it is refused (sticky
revocation; see insert/1). Idempotent - revoking an already-revoked or
unknown family is a no-op :ok.