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, if any,
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/subjectfor that redemption, plus its optionalreplay_retain_untildeadline). The family isnilforfinalize/3no-refresh flows and is the exact issued family only forissue_refresh_and_finalize/6. 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).
Optional access-token replay containment
Integrations can extend reuse tracking to the access token minted from a code by capability-detecting these functions on the store module:
record_access_token_for_code(code_hash, jti, expires_at)links the minted token to the code until the token expires.revoke_access_token_for_code(code_hash)revokes the linked token and is idempotent for unknown or legacy codes.revoke_family_access_tokens(family_id)revokes tokens whose code was finalized with that refresh family inmark_consumed/2.access_token_revoked?(jti)reports whether an unexpired linked token has been revoked.
These remain capability-detected extension functions rather than behaviour
callbacks. That lets stores compiled against an earlier Attesto 2.x release
keep their existing @impl annotations without new compiler warnings while
allowing bundled and custom stores to adopt the extension independently.
Record shape
A stored record is a map with:
:code_hash- theAttesto.Secret.hash/1of the code (the key).:data- the canonical grant context the host round-trips. It contains all and only:client_id,:redirect_uri,:code_challenge,:subject,:scope,:resource,:dpop_jkt,:family_id, and:claims. Adapter metadata must not be added beside those keys; host-specific values belong inside the portable, string-keyed:claimsobject (valid UTF-8 without U+0000 and exact-range integers). The PKCE method is fixed toS256whenever a challenge is present and is therefore not a stored context key.: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. Conventionally
holds :family_id, :subject, and :replay_retain_until; the family is
nil for a no-refresh finalize/3 marker and non-nil only when the refresh
composition captured an actually issued family. :replay_retain_until is
the immutable refresh-family deadline as unix seconds. It is nil for a
family with no configured maximum lifetime, which tells persistent stores
to retain the marker indefinitely while that family may remain active.
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. Conventionally
holds :family_id, :subject, and :replay_retain_until; the family is
nil for a no-refresh finalize/3 marker and non-nil only when the refresh
composition captured an actually issued family. :replay_retain_until is
the immutable refresh-family deadline as unix seconds. It is nil for a
family with no configured maximum lifetime, which tells persistent stores
to retain the marker indefinitely while that family may remain active.
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 ordinary read-only inspection without burning the single-use code.
A store that does not implement it is detected via function_exported?/3
and the optional read is skipped.
@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
that redemption's :family_id, :subject, and optional
:replay_retain_until family deadline (or a nil family and deadline when
finalize/3 is used for a no-refresh flow). 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. A marker for a refresh family SHOULD persist through
:replay_retain_until; a nil deadline on a non-nil family means the family
has no fixed maximum lifetime and the marker must not be expired on a local
timer. 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, with a nil family for no-refresh finalization). 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.