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 is mandatory
issue/3 requires a valid S256 code_challenge; there is no
PKCE-less path. A redemption without a matching code_verifier fails.
This closes authorization-code interception for public clients and is
the modern default (OAuth 2.0 Security BCP). Only S256 is accepted
(see Attesto.PKCE).
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.
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 MUST be redeemed without one.
Summary
Functions
Mint a single-use authorization code and persist it via store.
Validate and consume a code at the token endpoint.
Types
@type issue_error() ::
:invalid_client_id
| :invalid_redirect_uri
| :invalid_code_challenge
| :unsupported_code_challenge_method
| :invalid_subject
| :invalid_scope
| :invalid_dpop_jkt
| :invalid_claims
@type redeem_error() ::
:invalid_grant
| :expired
| :client_required
| :client_mismatch
| :redirect_uri_mismatch
| :pkce_failed
| :dpop_proof_required
| :dpop_proof_unexpected
| :dpop_binding_mismatch
Functions
@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, a valid S256
:code_challenge, and :subject. Optional :scope (a list of
strings, default []), :code_challenge_method (must be "S256" if
given), :dpop_jkt (binds the code to a DPoP key), 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.
The code is consumed (single use) before validation. Returns
{:ok, %Attesto.AuthorizationCode.Grant{}} with the validated grant
context, or {:error, reason}.