# `Attesto.DPoP`
[🔗](https://github.com/XukuLLC/attesto/blob/v2.11.0/lib/attesto/dpop.ex#L1)

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

Clients can build matching proofs with `Attesto.DPoP.Proof.build/4`.

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

# `nonce_check_fun`

```elixir
@type nonce_check_fun() :: (String.t() | nil -&gt; :ok | {:error, :use_dpop_nonce})
```

# `replay_check_fun`

```elixir
@type replay_check_fun() :: (String.t(), pos_integer() -&gt; :ok | {:error, :replay})
```

# `verified_proof`

```elixir
@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`

```elixir
@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`

```elixir
@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
]
```

# `allowed_algs`

```elixir
@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 capabilities. By default
these are discovered per call; `prime_allowed_algs/0` opts into a VM snapshot.

# `clear_allowed_algs_cache`

```elixir
@spec clear_allowed_algs_cache() :: :ok
```

Resume per-call JOSE capability discovery, including after backend changes.

# `compute_ath`

```elixir
@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`

```elixir
@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?`

```elixir
@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).

# `prime_allowed_algs`

```elixir
@spec prime_allowed_algs() :: :ok | {:error, :no_supported_algorithms}
```

Snapshot the current JOSE signing capabilities for this VM.

Opt in only when the application owns the node's crypto configuration and
keeps it immutable until restart. Call `clear_allowed_algs_cache/0` before
changing JOSE backends, then prime again after the change. Without priming,
`allowed_algs/0` continues to discover the current capabilities on every call.

This caches capability data only. Every proof still verifies its header,
public key, signature, claims, target, freshness and replay policy. The fixed
asymmetric algorithm whitelist is intersected with the snapshot on every call;
callers cannot supply or widen it.

# `verify_proof`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
