# `Attesto.DPoP`
[🔗](https://github.com/XukuLLC/attesto/blob/v0.13.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`.

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

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

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

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

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

---

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