DPoP test fixtures for host application suites.
A host that protects routes with Attesto's DPoP verification
(Attesto.DPoP.verify_proof/2 composed with Attesto.Token.verify/3)
needs, in its own tests, the client half of the RFC 9449 exchange: a
DPoP-sender-constrained access token, the matching proof JWT, and the
deliberately-broken proofs that must be rejected. Hand-rolling those in
every consumer re-implements JWS signing and the cnf.jkt / ath
derivations the library already owns, and drifts from the verifier the
moment a rule changes.
This module ships under lib/ (like AttestoMCP.Test.DPoPReplay) so a
consumer can call it from its test/ tree without depending on
Attesto's own test support. It builds everything through the same
primitives the production code uses - Attesto.Token.mint/3,
Attesto.DPoP.compute_jkt/1, Attesto.DPoP.compute_ath/1, and
JOSE.JWS - so a fixture is correct by construction against the
verifier and stays in step with it.
Proof key
Every function takes the client's DPoP key as a %JOSE.JWK{} (generate
one with generate_key/1, or supply your own EC/RSA/OKP key). The proof
embeds only the key's public half in its protected header, as
RFC 9449 §4.2 requires; Attesto.DPoP.verify_proof/2 rejects any header
carrying private-key material.
Example
jwk = Attesto.Test.DPoP.generate_key()
{token, _resp} =
Attesto.Test.DPoP.mint_access_token(config, %{
kind: "client",
sub: "oc_acme",
scopes: ["read"],
claims: %{"client_id" => "acme"}
}, jwk)
proof =
Attesto.Test.DPoP.proof(jwk, "GET", "https://api.example/thing",
access_token: token
)
{:ok, %{jkt: jkt}} =
Attesto.DPoP.verify_proof(proof,
http_method: "GET",
http_uri: "https://api.example/thing",
access_token: token
)
{:ok, _claims} = Attesto.Token.verify(config, token, dpop_jkt: jkt)
Summary
Types
A deliberate defect to bake into a proof so a negative test can assert the verifier rejects it
Functions
Generate a fresh DPoP proof key.
Build a DPoP proof carrying a single deliberate defect, for negative
tests that assert Attesto.DPoP.verify_proof/2 rejects it.
Mint a DPoP-sender-constrained access token bound to jwk.
Build a valid DPoP proof JWT signed with jwk for (htm, htu).
Types
@type flaw() :: :wrong_htm | :wrong_htu | :missing_ath | :expired
A deliberate defect to bake into a proof so a negative test can assert the verifier rejects it:
:wrong_htm- sign a method the request will not carry.:wrong_htu- sign a target URI the request will not carry.:missing_ath- omitatheven though an access token is presented.:expired- backdateiatpast the acceptance window.
Functions
@spec generate_key(term()) :: JOSE.JWK.t()
Generate a fresh DPoP proof key.
Defaults to an EC P-256 key (ES256), the smallest of the algorithms
Attesto.DPoP accepts. Pass a JOSE.JWK.generate_key/1 spec to choose
another, e.g. generate_key({:rsa, 2048}).
Build a DPoP proof carrying a single deliberate defect, for negative
tests that assert Attesto.DPoP.verify_proof/2 rejects it.
flaw is one of the flaw/0 values. htm/htu are the values the
request will actually carry; the defect is applied relative to them
(e.g. :wrong_htu signs a different URI than htu). opts is the same
as proof/4; for :missing_ath, pass :access_token (the proof omits
ath despite the token being presented, which the verifier rejects with
:missing_ath).
@spec mint_access_token( Attesto.Config.t(), Attesto.Token.principal(), JOSE.JWK.t(), Attesto.Token.mint_opts() ) :: {String.t(), Attesto.Token.token_response()}
Mint a DPoP-sender-constrained access token bound to jwk.
Computes the RFC 7638 thumbprint of jwk's public half and mints a
token through Attesto.Token.mint/3 with that thumbprint as the
cnf.jkt binding (RFC 9449 §6 / RFC 7800). principal and opts are
passed through to mint/3 unchanged (except :dpop_jkt, which this
function supplies), so the caller controls subject, scope, audience,
lifetime, and clock exactly as with a direct mint.
Returns {access_token, token_response} where token_response is the
full Attesto.Token.mint/3 map (token_type is "DPoP"). Raises if
mint/3 returns an error, since a fixture that fails to mint is a test
bug, not a condition under test.
@spec proof(JOSE.JWK.t(), String.t(), String.t(), keyword()) :: String.t()
Build a valid DPoP proof JWT signed with jwk for (htm, htu).
The proof carries the protected header %{"typ" => "dpop+jwt", "alg" => ..., "jwk" => <public jwk>} and the payload %{"htm" => htm, "htu" => htu, "iat" => now, "jti" => <random>} (RFC 9449 §4.2). The signing
alg is derived from the key shape via Attesto.SigningAlg, and only
the key's public half is embedded, so the result verifies under
Attesto.DPoP.verify_proof/2.
Options:
:access_token- when given, the proof carriesath(base64url(SHA-256(access_token)), RFC 9449 §4.3) so it verifies against the bound token on a protected-resource request. Omit it for a token-endpoint proof, where no access token exists yet.:nonce- a server-issued DPoP nonce to carry in thenonceclaim (RFC 9449 §8).:now-DateTimeor unix-seconds clock used foriat. Defaults toDateTime.utc_now/0.:jti- override the random replay identifier (e.g. to drive a replay test that presents the samejtitwice).