Attesto.DPoP (Attesto v0.5.0)

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, and EdDSA. 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. If the caller supplies the :replay_check opt, the verifier invokes it with the proof's jti AND the TTL the cache must remember it for (the acceptance window: max_age_seconds + future skew), AFTER every other check has passed (so an attacker cannot fill the cache with proofs that would have failed anyway). Deriving the TTL from the verifier's age policy keeps the cache from forgetting a jti while the proof is still acceptable. The callback returns :ok or {:error, :replay}. Attesto.DPoP.ReplayCache provides a default ETS-backed implementation (check_and_record/2).

Protected-resource pipelines MUST pass :replay_check. Leaving it out is acceptable only in test scaffolding and at the token endpoint on first use of a proof (the endpoint records the jti itself).

Summary

Functions

The list of JOSE alg values accepted on a DPoP proof's protected header.

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

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.

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 5-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 jti and the TTL (seconds) the store must remember it for, AFTER every other check has passed. Returns :ok if the jti has not been seen, or {:error, :replay} if it has. Required by protected-resource pipelines; pass &Attesto.DPoP.ReplayCache.check_and_record/2. Omit only 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.