# `Attesto.DPoP`
[🔗](https://github.com/XukuLLC/attesto/blob/v2.0.1/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`, 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 current capabilities.

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