Attesto.AuthorizationCode (Attesto v0.7.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 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

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

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(: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_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

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.

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

  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.

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