Attesto.Test.DPoP (Attesto v0.6.10)

Copy Markdown View Source

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

flaw()

@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 - omit ath even though an access token is presented.
  • :expired - backdate iat past the acceptance window.

Functions

generate_key(spec \\ {:ec, "P-256"})

@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}).

invalid_proof(jwk, flaw, htm, htu, opts \\ [])

@spec invalid_proof(JOSE.JWK.t(), flaw(), String.t(), String.t(), keyword()) ::
  String.t()

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

mint_access_token(config, principal, jwk, opts \\ [])

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.

proof(jwk, htm, htu, opts \\ [])

@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 carries ath (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 the nonce claim (RFC 9449 §8).
  • :now - DateTime or unix-seconds clock used for iat. Defaults to DateTime.utc_now/0.
  • :jti - override the random replay identifier (e.g. to drive a replay test that presents the same jti twice).