ReqDPoP

Hex.pm Hexdocs.pm License: MIT

ReqDPoP is a small Req plugin for OAuth 2.0 DPoP proof generation as defined by RFC 9449.

It is not an OAuth provider, token store, or full OAuth client. It does not fetch tokens for you. It only signs DPoP proofs for outgoing Req requests and, when configured, attaches a DPoP-bound access token.

Installation

def deps do
  [
    {:req_dpop, "~> 0.7"}
  ]
end

Resource Request

key = ReqDPoP.Key.generate(:es256)

client =
  Req.new(base_url: "https://api.example.com")
  |> ReqDPoP.attach(key: key, access_token: access_token)

Req.get!(client, url: "/resource")

The plugin adds:

htu and htm are derived from the final Req request URL and method after Req has applied base URL and path options. Per RFC 9449, htu excludes the request URL's query string and fragment.

Target URI override

When a request is sent to one address and its target URI is another, for example an internal address with the public authority in Host, the URL Req sends to is not the URI the server checks the proof against (RFC 9449 sections 4.2 and 4.3). :origin signs a different scheme, host and port and keeps the request path:

Req.new(base_url: "http://api.internal:4000", headers: [host: "api.example.com"])
|> ReqDPoP.attach(key: key, access_token: access_token, origin: "https://api.example.com")
|> Req.get!(url: "/resource")
# htu: https://api.example.com/resource

:htu takes a one-argument function of the request and returns the htu to sign, for a mapping :origin cannot express. A fixed string is rejected: it would be signed into every request the client makes.

Use an override only when the request's target URI differs from the URL it is sent to. A server that rejects a correct proof because it reconstructs its own URI wrongly has a configuration bug, and signing the URI it expects hides that bug. The override applies to the origin of the first hop, including nonce retries; a redirect hop to another origin signs its own URL.

Token Endpoint Proof

Use proof-only mode by omitting :access_token:

Req.new()
|> ReqDPoP.attach(key: key)
|> Req.post!(url: "https://auth.example.com/oauth/token", form: params)

Nonce Retry

By default, ReqDPoP retries once when a response carries a single DPoP-Nonce header with an RFC 9449 nonce value and is a nonce challenge in either form:

The retry proof includes the server nonce. Nonces and the retry budget are tracked per origin. Configure this behavior with :retry_on_nonce and :max_nonce_retries.

A compressed challenge body is decoded only when the request was made with Req's compressed: true, the same opt-in Req applies to every response body.

Redirects and Retries

Each hop of a request gets its own proof for the hop's method and URL with a fresh jti, including hops made by Req's retry and redirect steps.

On an untrusted cross-origin redirect, Req removes the Authorization header. ReqDPoP then removes the DPoP header as well, in proof-only mode too, so the redirect target receives neither the token nor a proof. Set Req's redirect_trusted: true to send both to the new origin.

Key Persistence

Generated keys are process-local values. Production clients should persist the DPoP key if they need stable sender binding across restarts:

key = ReqDPoP.Key.generate(:es256)
jwk = ReqDPoP.Key.export(key)
key = ReqDPoP.Key.load!(jwk)

Security Notes

Development

The test suite includes server-side interop checks against Attesto's DPoP verifier as a test-only dependency; Attesto is not required at runtime.

mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix test
mix docs
mix hex.build

License

MIT. See LICENSE.