Attesto.Test.DPoP (Attesto v1.4.1)

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 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 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).
  • :alg - override the inferred identifier with any key-compatible algorithm accepted by Attesto.DPoP.allowed_algs/0, including RFC 9864 Ed25519 or Ed448 when the configured JOSE backend supports it.