Attesto.AuthorizationCode (Attesto v2.0.1)

Copy Markdown View Source

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

issue_attrs()

@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()
}

issue_error()

@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

redeem_error()

@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()}

redeem_params()

@type redeem_params() :: %{
  :redirect_uri => String.t(),
  :code_verifier => String.t(),
  optional(:client_id) => String.t(),
  optional(:dpop_jkt) => String.t() | nil
}

Functions

dpop_bound?(store, code)

@spec dpop_bound?(module(), String.t()) :: boolean()

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.

finalize(store, code, grant)

@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.

issue(store, attrs, opts \\ [])

@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

  1. 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.

issue_refresh_and_finalize(code_store, code, grant, refresh_store, refresh_context, opts \\ [])

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.

redeem(store, code, params, opts \\ [])

@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}.