Storage seam for authorization codes.
Attesto.AuthorizationCode is pure: it generates a code, hashes it, and
validates a redemption, but it never decides where the code lives.
That is this behaviour. A host implements it over whatever store fits
(Postgres, Redis, ETS); Attesto.CodeStore.ETS is a ready single-node
implementation.
The single-use contract (load-bearing)
take/1 MUST be atomic: it returns the record for code_hash and
removes it in one indivisible step, so two concurrent redemptions of
the same code cannot both succeed. Authorization codes are single-use
(RFC 6749 §4.1.2); a store that let take/1 race would let a captured
code be replayed. A SQL implementation uses
DELETE ... WHERE code_hash = $1 RETURNING ...; an ETS implementation
uses :ets.take/2.
The code is consumed by take/1 even if Attesto.AuthorizationCode
then rejects the redemption (wrong redirect URI, failed PKCE): a code
that has been presented once is spent, which denies an attacker repeated
validation attempts against a stolen code.
Optional reuse tracking (additive, fail-safe)
The single-use contract above stops a code being redeemed twice, but on
its own it cannot tell a replay of an already-redeemed code apart from
a never-issued code: once take/1 has removed the row, a second
presentation looks identical to garbage. The OAuth 2.0 Security BCP
§4.13 (and RFC 6749 §4.1.2) say more: when a code is presented a second
time the AS SHOULD revoke the tokens already issued from the first
redemption, because a second presentation is an attack signal (the code
leaked). Acting on that signal requires remembering which family the
first redemption spawned.
A store MAY opt into this by implementing the OPTIONAL mark_consumed/2
callback and extending take/1 to return {:error, :consumed, meta}
for a code that was already redeemed. This is purely additive:
- A store that does NOT implement reuse tracking keeps
take/1's original{:ok, entry} | :errorcontract.Attesto.AuthorizationCodetreats a re-presented (now absent) code as:invalid_grant, exactly as before. Single use is unaffected. - A store that DOES implement reuse tracking calls
mark_consumed/2when a redemption succeeds, recording thecode_hashtogether withmeta(thefamily_id/subjectof that first redemption). A latertake/1of the same hash then returns{:error, :consumed, meta}, andAttesto.AuthorizationCode.redeem/4surfaces{:error, {:reuse, meta}}so the caller can revoke the family.
Fail-safe means: the absence of reuse tracking never makes the system
less safe than single use already guarantees; it only forgoes the
extra descendant-revocation signal. A store therefore implements the
callback only when it can persist the consumed marker durably enough to
be useful (a single-redemption window is already closed by take/1
whether or not the marker survives).
Record shape
A stored record is a map with:
:code_hash- theAttesto.Secret.hash/1of the code (the key).:data- the opaque grant context the host round-trips (client, redirect URI, scope, PKCE challenge, optional DPoP thumbprint, subject, and any host claims).:expires_at- absolute expiry, unix seconds. The store MAY evict expired records, butAttesto.AuthorizationCodere-checks expiry aftertake/1, so eviction timing is not security-critical.
Summary
Types
Reuse metadata recorded at the first redemption and replayed to a later
take/1 of the same code_hash. Opaque to Attesto.CodeStore; carried
through Attesto.AuthorizationCode.redeem/4 to the caller so it can
revoke the family the leaked code spawned. Conventionally holds the
:family_id and :subject of the first redemption.
Callbacks
OPTIONAL. Read the entry for code_hash WITHOUT consuming it (unlike
take/1). Returns {:ok, entry} when a code with that hash is stored, else
:error.
OPTIONAL. Record that code_hash was successfully redeemed and spawned
the family described by meta, so a later take/1 of the same hash can
report {:error, :consumed, meta}.
Persist a code record.
Atomically fetch and delete the record for code_hash.
Types
@type code_hash() :: String.t()
@type consumed_meta() :: map()
Reuse metadata recorded at the first redemption and replayed to a later
take/1 of the same code_hash. Opaque to Attesto.CodeStore; carried
through Attesto.AuthorizationCode.redeem/4 to the caller so it can
revoke the family the leaked code spawned. Conventionally holds the
:family_id and :subject of the first redemption.
Callbacks
OPTIONAL. Read the entry for code_hash WITHOUT consuming it (unlike
take/1). Returns {:ok, entry} when a code with that hash is stored, else
:error.
Used for read-only pre-checks at the token endpoint - e.g. surfacing a
holder-of-key (DPoP) requirement for a sender-constrained code (RFC 9449 §10 /
FAPI2) before the client-authentication failure, WITHOUT burning the
single-use code. A store that does not implement it is detected via
function_exported?/3 and the pre-check is skipped (single-use behaviour and
the normal error are unchanged).
@callback mark_consumed(code_hash(), consumed_meta()) :: :ok
OPTIONAL. Record that code_hash was successfully redeemed and spawned
the family described by meta, so a later take/1 of the same hash can
report {:error, :consumed, meta}.
Implemented only by stores that support code-reuse detection (OAuth 2.0
Security BCP §4.13 / RFC 6749 §4.1.2). Attesto.AuthorizationCode calls
it exactly once, after a redemption fully validates, with meta carrying
the first redemption's :family_id and :subject. A store that does not
implement this callback simply omits it from the behaviour; the redeemer
detects its absence (function_exported?/3) and skips the call, leaving
single-use behaviour unchanged.
Returns :ok. The marker SHOULD persist at least as long as the code's
original lifetime would have remained useful; a store MAY key it by
code_hash alongside the consumed-token bookkeeping it already keeps.
@callback put(entry()) :: :ok
Persist a code record.
@callback take(code_hash()) :: {:ok, entry()} | :error | {:error, :consumed, consumed_meta()}
Atomically fetch and delete the record for code_hash.
MUST be a single indivisible operation to preserve single use. Returns:
{:ok, record}- the code existed and was unredeemed; it is now removed. This is the primary path every store implements.:error- no such code (never issued, expired-and-evicted, or - for a store WITHOUT reuse tracking - already redeemed). Treated as:invalid_grantbyAttesto.AuthorizationCode.redeem/4.{:error, :consumed, meta}- OPTIONAL, only for a store that implementsmark_consumed/2: the code was already successfully redeemed once.metais the value passed tomark_consumed/2at that first redemption (carrying thefamily_id/subject). This is the code-reuse attack signal (OAuth 2.0 Security BCP §4.13); the redeemer surfaces it so the caller can revoke descendants.
A store that does not track reuse never returns the third form, so the
contract stays {:ok, entry} | :error for it and reuse tracking is
purely additive.