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 all access-token, ID-token, and
other response fields have 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). The no-refresh
finalize/3 path records a nil family ID; only
issue_refresh_and_finalize/6 records the exact family returned by refresh
issuance. 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).
:family_id on an authorization request is provenance metadata carried by
the returned Grant; public Attesto.RefreshToken.issue/3 deliberately
rejects that value and always creates a fresh family. A host that issues a
refresh token from a redeemed code should use
issue_refresh_and_finalize/6, which owns issuance, captures the returned
family ID, and records it only after issuance succeeds. This keeps the
code-reuse marker useful without reopening caller control over
refresh-family generation. That helper also requires the refresh context's
subject and client to match the redeemed grant and permits only
scope/resource narrowing.
RFC 9449 §5 requires a public client's refresh token to be DPoP-bound and
prohibits binding a confidential client's refresh token. The host knows the
client classification and must choose the refresh context's :dpop_jkt
accordingly; core cannot infer it. For a DPoP-bound grant,
issue_refresh_and_finalize/6 therefore permits either nil (confidential
refresh) or the grant's exact JKT (public refresh), and rejects any other
JKT. For an unbound grant, the host may still select a token-endpoint DPoP
binding as described below.
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 while a
token-request DPoP proof is present - this module does not reject that
(unlike Attesto.RefreshToken, which is stricter). But it does NOT act on
that proof: the returned grant's dpop_jkt stays nil, and binding the
new access token to the token-request proof is the token endpoint's job
(via Attesto.Token's :dpop_jkt mint opt), not this module's. A host
that decides the new token's binding from grant.dpop_jkt alone would
therefore miss a token-endpoint proof; read the presented proof directly.
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.
Issue an initial refresh token for a redeemed code and finalize its reuse marker with the family ID returned by the refresh-token issuer.
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(:resource) => [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_resource
| :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. This form is only for flows that issue
no refresh token and always records a nil family_id in the marker. The
Grant's family_id is authorization provenance, not a refresh-family
identifier. For a refresh grant, use issue_refresh_and_finalize/6 so the
marker carries the family ID returned by RefreshToken.issue/3 without
accepting a caller-supplied ID.
@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 provenance string round-tripped to the redeemed
Grant; use issue_refresh_and_finalize/6 to bind the actually issued
refresh family), and
:claims (a lossless, string-keyed I-JSON object of host context;
persisted numbers are exact-range integers, not floats) 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 issue_refresh_and_finalize( module(), String.t(), Attesto.AuthorizationCode.Grant.t(), module(), Attesto.RefreshToken.context(), keyword() ) :: {:ok, Attesto.RefreshToken.issued()} | {:error, Attesto.RefreshToken.issue_error()}
Issue an initial refresh token for a redeemed code and finalize its reuse marker with the family ID returned by the refresh-token issuer.
This composition API is the safe bridge for authorization-code flows that
issue refresh tokens. First finish every access-token, ID-token, and other
response operation that can fail; then call this function, and add the
returned plaintext refresh token to the response only after it returns
{:ok, issued}. It calls Attesto.RefreshToken.issue/3 itself, so callers
never provide a family ID or generation. Only a validated success result can
reach mark_consumed/2, and the exact family ID captured from that result is
written to the code-reuse marker. A refresh issuance error is returned
unchanged and leaves the code spent-but-unfinalized. A finalization exception
or contract violation is propagated and never becomes an :ok result.
refresh_context must carry the redeemed grant's :subject and
:client_id, and its :scope and :resource lists must be subsets of the
grant's authorization. If the redeemed grant is DPoP-bound, the context's
:dpop_jkt may be nil for a confidential-client refresh or must exactly
match that binding for a public-client refresh; a different key is rejected.
The host is responsible for that public/confidential classification and for
choosing the context binding required by RFC 9449 §5. A token-endpoint DPoP
binding may differ from the authorization-request binding only when the
redeemed grant itself is unbound, which permits token-endpoint DPoP
initiation without weakening a holder-of-key grant. The context must not
contain top-level :family_id or :generation continuation fields. Those
are internal rotation state, not caller input; put authorization provenance
inside :claims when needed.
@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. For a redemption that issued a refresh token,
:family_id identifies the descendant family to revoke (OAuth 2.0 Security
BCP §4.13); for a no-refresh redemption it is nil. Codes the store has
never seen remain {:error, :invalid_grant}.