Attesto.AuthorizationCode (Attesto v0.5.0)

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

issue_attrs()

@type issue_attrs() :: %{
  :client_id => String.t(),
  :redirect_uri => String.t(),
  :code_challenge => String.t(),
  :subject => String.t(),
  optional(:scope) => [String.t()],
  optional(:code_challenge_method) => String.t(),
  optional(:dpop_jkt) => 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_claims

redeem_error()

@type redeem_error() ::
  :invalid_grant
  | :expired
  | :client_required
  | :client_mismatch
  | :redirect_uri_mismatch
  | :pkce_failed
  | :dpop_proof_required
  | :dpop_proof_unexpected
  | :dpop_binding_mismatch

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

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

  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.

The code is consumed (single use) before validation. Returns {:ok, %Attesto.AuthorizationCode.Grant{}} with the validated grant context, or {:error, reason}.