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 defaults to the legacy identifier derived from the key 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).:alg- override the inferred identifier with any key-compatible algorithm accepted byAttesto.DPoP.allowed_algs/0, including RFC 9864Ed25519orEd448when the configured JOSE backend supports it.