RFC 6749 §4.1 authorization-code grant, with mandatory PKCE (RFC 7636, S256) and optional DPoP binding of the code (RFC 9449 §10).
This module is pure logic over a Attesto.CodeStore: issue/3 mints a
single-use code at the authorization endpoint, redeem/4 validates and
consumes it at the token endpoint and returns the grant context the host
uses to mint an access token. The store decides where codes live and
guarantees single use; everything validated here (expiry, exact
redirect-URI match, the PKCE transform, the DPoP key binding) is
protocol.
PKCE (S256), required by default
issue/3 accepts a valid S256 code_challenge and redeem/4 checks the
matching code_verifier; only S256 is accepted (see Attesto.PKCE). This
closes authorization-code interception and is the modern default (OAuth 2.0
Security BCP / RFC 9700). PKCE enforcement at the authorization endpoint is
governed by Attesto.AuthorizationRequest's :require_pkce option (default
true); a host MAY relax it for a confidential client (public clients MUST
use PKCE, RFC 9700 §2.1.1), in which case a code is issued with no challenge
and redeemed with no verifier. A code_challenge that is present is always
fully enforced. issue/3 therefore treats :code_challenge as optional: when
given it must be a valid S256 challenge, when absent the code is unbound and a
later redemption MUST present no code_verifier.
Single use even on failure
redeem/4 consumes the code via Attesto.CodeStore.take/1 before
validating it, so a presented code is spent whether or not the
redemption succeeds. An attacker who captures a code cannot make
repeated validation attempts against it.
Code-reuse detection (when the store supports it)
Single use alone cannot distinguish a replay of an already-redeemed
code from a never-issued code: once take/1 removes the row, both
look absent. OAuth 2.0 Security BCP §4.13 (and RFC 6749 §4.1.2) say the
AS SHOULD, on a second presentation of a code, revoke the tokens already
issued from its first redemption, because a re-presented code is an
attack signal.
redeem/4 enables that when - and only when - the Attesto.CodeStore
implements the optional reuse-tracking pair (Attesto.CodeStore.take/1
returning {:error, :consumed, meta} plus
Attesto.CodeStore.mark_consumed/2). The reuse marker is recorded by
finalize/3, which the caller invokes AFTER the full token response has been
successfully built - NOT by redeem/4 itself. So a code whose redemption
validated but whose downstream issuance then failed (a mint or refresh-token
fault, a host callback returning a bad principal) is left single-use-spent
but NOT reuse-flagged: a replay is {:error, :invalid_grant}, and a
legitimate retry of a transient failure is never mistaken for a reuse attack
(which would wrongly revoke the family). Once finalize/3 has run, a later
redemption of the same code yields {:error, {:reuse, meta}}, where meta
carries that first redemption's context so the caller can revoke the
descendant family (e.g. via Attesto.Revocation). A store that does not
implement the pair behaves exactly as before: a re-presented code is
{:error, :invalid_grant}.
This is additive and fail-safe (see Attesto.CodeStore).
Pass a :family_id to issue/3 to link the code to the refresh-token
family it will spawn; it rides onto the returned Grant so the host
mints the family under that id, and it is what reuse detection replays.
DPoP-bound codes
If issue/3 is given a :dpop_jkt, the code is bound to that DPoP key
(RFC 9449 §10): redemption MUST present the same :dpop_jkt (the
thumbprint of the key in the token-request's DPoP proof) or it is
rejected. A code minted without a binding MAY still be redeemed with a
token-request DPoP proof; in that case this module treats the proof as a
token-endpoint sender constraint for the access token the host is about to
mint, not as a pre-existing authorization-code binding.
Summary
Functions
Returns true iff a stored code for code is bound to a DPoP key (RFC 9449
§10) - i.e. its redemption requires a matching DPoP proof (holder-of-key).
Finalize a fully completed redemption: record the reuse marker
(consumed_success) for code's grant.
Mint a single-use authorization code and persist it via store.
Validate and consume a code at the token endpoint.
Types
@type issue_attrs() :: %{ :client_id => String.t(), :redirect_uri => String.t(), optional(:code_challenge) => String.t() | nil, :subject => String.t(), optional(:scope) => [String.t()], optional(:code_challenge_method) => String.t(), optional(:dpop_jkt) => String.t() | nil, optional(:family_id) => String.t() | nil, optional(:claims) => map() }
@type issue_error() ::
:invalid_client_id
| :invalid_redirect_uri
| :invalid_code_challenge
| :unsupported_code_challenge_method
| :invalid_subject
| :invalid_scope
| :invalid_dpop_jkt
| :invalid_family_id
| :invalid_claims
@type redeem_error() :: :invalid_grant | :expired | :client_required | :client_mismatch | :redirect_uri_mismatch | :pkce_failed | :dpop_proof_required | :dpop_binding_mismatch | {:reuse, Attesto.CodeStore.consumed_meta()}
Functions
Returns true iff a stored code for code is bound to a DPoP key (RFC 9449
§10) - i.e. its redemption requires a matching DPoP proof (holder-of-key).
Reads the code via the store's OPTIONAL Attesto.CodeStore.get/1 WITHOUT
consuming it, so a legitimate redemption is unaffected. Returns false when
the store has no get/1, the code is unknown, or it carries no :dpop_jkt.
This lets the token endpoint surface a holder-of-key (invalid_dpop_proof)
rejection ahead of the client-authentication error (FAPI2) without burning the
single-use code.
@spec finalize(module(), String.t(), Attesto.AuthorizationCode.Grant.t()) :: :ok
Finalize a fully completed redemption: record the reuse marker
(consumed_success) for code's grant.
Call this only AFTER the full token response has been successfully built. It
is split from redeem/4 so redemption is atomic - redeem/4 claims the code
(single use, via take/1) and validates it, but defers this marker so a
failure in the caller's downstream issuance (mint, refresh-token persistence,
a host callback fault) does NOT leave a spent-but-tokenless code recorded as a
completed redemption (which would make a legitimate retry look like a reuse
attack and revoke the family). A no-op for stores that do not implement
Attesto.CodeStore.mark_consumed/2.
@spec issue(module(), issue_attrs(), keyword()) :: {:ok, String.t()} | {:error, issue_error()}
Mint a single-use authorization code and persist it via store.
attrs MUST carry :client_id, :redirect_uri, and :subject.
Optional :code_challenge binds the code to PKCE; when present,
:code_challenge_method must be "S256" if given. Optional :scope (a
list of strings, default []), :dpop_jkt (binds the code to a DPoP key),
:family_id (a
non-empty string linking this code to the refresh-token family it will
spawn; rides onto the redeemed Grant and is what code-reuse detection
replays - see the moduledoc), and :claims (an opaque host context map
round-tripped to redeem/4).
Options: :ttl (seconds the code is valid, default
- and
:now(clock override).
Returns {:ok, code} with the plaintext code to hand the client. Only
the code's hash is stored. Returns {:error, reason} on malformed
attrs.
@spec redeem(module(), String.t(), redeem_params(), keyword()) :: {:ok, Attesto.AuthorizationCode.Grant.t()} | {:error, redeem_error()}
Validate and consume a code at the token endpoint.
params MUST carry the :redirect_uri (matched exactly against the one
in the authorization request), the :code_verifier (checked against the
stored PKCE challenge), and the :client_id of the redeeming client. By
default client binding is fail-closed: since every stored code carries a
client_id, redemption MUST present one (:client_required if absent,
:client_mismatch if wrong) - this stops a code issued to one client
being redeemed by another (RFC 6749 §4.1.3). A caller that cannot
authenticate the client and relies on PKCE alone passes
allow_missing_client_id?: true in opts. :dpop_jkt is required iff
the code was DPoP-bound at issue/3; if the code was not bound, a presented
:dpop_jkt is allowed and can be used by the caller to mint a DPoP-bound
access token.
The code is consumed (single use) before validation. Returns
{:ok, %Attesto.AuthorizationCode.Grant{}} with the validated grant
context, or {:error, reason}.
When the store implements optional reuse tracking (see
Attesto.CodeStore), a second redemption of a code that was already
successfully redeemed returns {:error, {:reuse, meta}} rather than
{:error, :invalid_grant}. meta carries the first redemption's
:family_id and :subject so the caller can revoke the descendant
family (OAuth 2.0 Security BCP §4.13). Codes the store has never seen
remain {:error, :invalid_grant}.