Attesto.DPoP (Attesto v1.12.2)

Copy Markdown View Source

RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP).

A DPoP proof is a JWS that a client signs with a key it holds and attaches to a token request (POST /token) or to every protected-resource request that uses a DPoP-bound access token. The proof carries:

  • a JOSE header with typ: "dpop+jwt", an asymmetric signature alg, and the client's public key in jwk;
  • a JOSE payload with htm (HTTP method), htu (HTTP target URI), iat (creation timestamp), jti (unique replay identifier), and - when presented alongside an access token - ath, the base64url-encoded SHA-256 hash of that access token.

The server validates the proof against the live request, computes the RFC 7638 SHA-256 thumbprint of the embedded JWK, and uses the thumbprint to bind issued/presented access tokens to the proof key via the access token's cnf.jkt claim (RFC 7800).

This module verifies a single DPoP proof and returns the thumbprint and replay identifier so the caller (the token endpoint or the authenticated-request handler) can:

  • compare jkt to the bound access token's cnf.jkt, and
  • persist jti in a replay cache.

It is framework-agnostic: no Plug, no database, no application config. It is a pure function of the proof JWT, the HTTP request context, and an optional access token. A resource server composes Attesto.Token.verify/3 with this module's verify_proof/2.

Accepted algorithms

Per RFC 9449 §4.2, DPoP proofs MUST be signed with an asymmetric algorithm. This verifier whitelists ES256, ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512, legacy EdDSA, and RFC 9864 Ed25519 / Ed448, intersected with the algorithms the configured JOSE backend reports as available. The explicit Edwards identifiers must match the embedded public JWK's curve, and RSA proof keys must have a modulus of at least 2048 bits. Symmetric algorithms (HS*) and the unsecured none algorithm are rejected; there is no caller-facing knob to relax this.

Replay protection

RFC 9449 §11.1 requires the resource server to reject a DPoP proof it has already seen. A captured-and-replayed proof is otherwise good for the entire iat acceptance window (default 60 seconds). This verifier enforces replay protection in two layers:

  1. The proof's jti is length-capped (see @max_jti_length) so an attacker cannot exhaust the cache by submitting proofs with megabyte-sized jti values.
  2. The proof's replay identity is recorded once, and rejected if already seen, for the acceptance window plus a one-second integer-boundary margin (max_age_seconds + future skew + 1 — see replay_ttl/1). The success map returns replay_key/replay_ttl so the caller can record it; deriving the TTL from the verifier's age policy keeps the cache from forgetting the identity while the proof is still acceptable. Attesto.DPoP.ReplayCache provides a default ETS-backed store.

Claim the jti only after the whole request has authenticated

A protected-resource pipeline MUST reject a replayed proof, but it MUST record the jti after the access token and its cnf.jkt binding verify - not during proof verification. Recording it earlier lets a captured-but-otherwise-valid proof (presented with no token, or a token that will fail binding) burn the jti first, so the legitimate request carrying that same proof is then rejected as a replay: a targeted denial of service. The token endpoint has the same obligation - record the identity only once the grant has validated.

So the correct shape is: call verify_proof/2 without :replay_check, verify the access token and compare its cnf.jkt to the returned jkt, and only then record the returned replay_key for replay_ttl seconds. Record replay_key, not the raw jti: it namespaces the identifier by the proof key (see the success map), so one key's proof cannot evict another's. Attesto.Plug.Authenticate does exactly this.

The :replay_check opt records the identity inline, during verification, before any of that. It exists for flows with no subsequent binding step and for test scaffolding; a protected-resource or token pipeline should prefer the defer-then-record shape above.

Summary

Functions

The list of JOSE alg values accepted on a DPoP proof's protected header, filtered by the configured JOSE backend's current capabilities.

The ath claim value defined by RFC 9449 §4.3: base64url(SHA-256(access_token)), unpadded.

RFC 7638 SHA-256 JWK thumbprint, base64url-encoded without padding. Accepts a %JOSE.JWK{} or a JWK as a plain map (e.g. the one in a DPoP proof's protected header).

Returns true iff the given access-token claims map advertises a DPoP binding via RFC 7800 cnf.jkt. Tolerates any verifier-accepted cnf.jkt value (non-empty string).

Verify a DPoP proof JWS per RFC 9449 against the given request context.

Types

nonce_check_fun()

@type nonce_check_fun() :: (String.t() | nil -> :ok | {:error, :use_dpop_nonce})

replay_check_fun()

@type replay_check_fun() :: (String.t(), pos_integer() -> :ok | {:error, :replay})

verified_proof()

@type verified_proof() :: %{
  ath: String.t() | nil,
  htm: String.t(),
  htu: String.t(),
  iat: non_neg_integer(),
  jkt: String.t(),
  jti: String.t(),
  replay_key: String.t(),
  replay_ttl: pos_integer()
}

verify_error()

@type verify_error() ::
  :invalid_proof
  | :invalid_signature
  | :invalid_typ
  | :invalid_alg
  | :unsupported_critical_header
  | :missing_jwk
  | :invalid_jwk
  | :invalid_htm
  | :invalid_htu
  | :missing_jti
  | :invalid_jti
  | :missing_ath
  | :invalid_ath
  | :missing_iat
  | :invalid_iat
  | :proof_expired
  | :replay
  | :use_dpop_nonce

verify_opts()

@type verify_opts() :: [
  http_method: String.t(),
  http_uri: String.t(),
  access_token: String.t() | nil,
  now: DateTime.t() | non_neg_integer(),
  max_age_seconds: pos_integer(),
  replay_check: replay_check_fun() | nil,
  nonce_check: nonce_check_fun() | nil
]

Functions

allowed_algs()

@spec allowed_algs() :: [String.t()]

The list of JOSE alg values accepted on a DPoP proof's protected header, filtered by the configured JOSE backend's current capabilities.

compute_ath(access_token)

@spec compute_ath(String.t()) :: String.t()

The ath claim value defined by RFC 9449 §4.3: base64url(SHA-256(access_token)), unpadded.

compute_jkt(jwk)

@spec compute_jkt(JOSE.JWK.t() | map()) :: String.t()

RFC 7638 SHA-256 JWK thumbprint, base64url-encoded without padding. Accepts a %JOSE.JWK{} or a JWK as a plain map (e.g. the one in a DPoP proof's protected header).

dpop_bound?(arg1)

@spec dpop_bound?(map()) :: boolean()

Returns true iff the given access-token claims map advertises a DPoP binding via RFC 7800 cnf.jkt. Tolerates any verifier-accepted cnf.jkt value (non-empty string).

verify_proof(proof, opts \\ [])

@spec verify_proof(String.t(), verify_opts()) ::
  {:ok, verified_proof()} | {:error, verify_error()}

Verify a DPoP proof JWS per RFC 9449 against the given request context.

Required opts

  • :http_method - the HTTP method of the request the proof was attached to ("POST", "GET", …). Compared case-sensitively to the proof's htm claim per RFC 9449 §4.3.
  • :http_uri - the HTTP target URI of the request, including scheme and host. Query and fragment components are stripped before comparison so a client that signed https://api.example/x and the server-observed https://api.example/x?cb=1 still match.

Optional opts

  • :access_token - the bearer/DPoP access token presented on the same request. If supplied, the proof MUST carry an ath claim whose value is base64url(SHA-256(access_token)) per RFC 9449 §4.3. If :access_token is omitted (e.g. the proof is attached to a token endpoint request, where no access token exists yet), the ath claim - if present - is returned but not constrained.
  • :now - DateTime or unix-seconds integer used as the clock reference. Defaults to DateTime.utc_now/0. Test-facing.
  • :max_age_seconds - how far in the past iat may be. Default 60. A constant 60-second window into the future is also accepted to tolerate modest client-side clock skew.
  • :replay_check - a two-arity function called with the proof's replay identity and the TTL (seconds) the store must remember it for, AFTER every other proof check has passed. Returns :ok if the identity has not been seen, or {:error, :replay} if it has. The identity is an OPAQUE string (the replay_key in the success map: a fixed-length digest namespacing the jti by the proof key - do NOT assume it is the raw jti or parse it). This records it inline, before the caller has verified the access token and its cnf.jkt binding - so a protected-resource or token pipeline should NOT use it; omit it, verify the token, then record the returned replay_key/replay_ttl (see the "Replay protection" section and Attesto.Plug.Authenticate). Use it only for a flow with no later binding step, or in test scaffolding.
  • :nonce_check - a one-arity function called with the proof's nonce claim (which may be nil). Returns :ok or {:error, :use_dpop_nonce} (RFC 9449 §8), the latter telling the caller to answer with a fresh DPoP-Nonce. Omitted, no nonce is required. See Attesto.DPoP.NonceStore.

Returns

  • {:ok, %{jkt: ..., jti: ..., ath: ..., htm: ..., htu: ..., iat: ...}} on success. jkt is the RFC 7638 SHA-256 thumbprint of the proof's embedded JWK; the caller compares it to the access token's cnf.jkt.
  • {:error, reason} otherwise. See the module typespecs for the full error set.

The success map carries jkt (the RFC 7638 thumbprint of the proof key), jti, iat, htm, htu, ath (or nil), and replay_ttl - the acceptance window this verification derived, for a caller that claims the jti itself rather than through :replay_check (as Attesto.Plug.Authenticate does, to avoid claiming for a request whose token has not yet verified). Keys may be added in a minor release, so match the ones you need rather than the whole map.