DpopVerifier
in package
DPoP proof verification — CONTRACT.md §21.7.2 (RFC 9449), contract 1.16.
The resource-server half of DPoP: given the DPoP header a caller presented,
decide whether it proves possession for this request and this access
token, and return the key thumbprint that
JwksVerifier::verifyTokenBinding() then matches against the token's
cnf.jkt.
Why this lives in the SDK
§21.7.2 is a ten-check list, and the contract is blunt about partial implementations: "Partial verification is worse than none, because it produces a guard that reports success." Nine of the ten look optional until someone builds an attack out of the one that was skipped, so they belong in one audited place rather than in every application that guards an endpoint.
The two most often missing, and what they cost:
typ— without pinning it todpop+jwt, any other JWT signed by the same key (an access token, an ID token) is replayable as a proof.ath— without it, a proof captured on one request can be re-aimed at a different token held by the same key.athbinds the proof to the token rather than merely to the key.
The algorithm comes from the key, never from the header
alg: none and RSA-public-key-as-HMAC-secret are the same bug wearing different
clothes: the token told the verifier how to check the token. This class derives
the expected algorithm from the embedded key's own kty/crv and hands that one
algorithm to the decoder, so an HMAC verifier is never a candidate.
firebase/php-jwt additionally refuses a header whose alg disagrees with the
key it was given, so a lying alg header is rejected here rather than ignored
— the same posture as the Rust SDK, and stricter than Python's and TypeScript's.
Either way the header never selects the algorithm.
PS256 needs phpseclib
firebase/php-jwt implements RSASSA-PSS through phpseclib/phpseclib, because
PHP's own openssl_verify() has no PSS padding. phpseclib is already in this
SDK's dependency graph, so all three permitted algorithms work out of the box; on
an installation where it is somehow absent, a PS256 proof fails closed (rejected)
rather than being waved through.
Table of Contents
Constants
- IAT_LEEWAY_SECONDS : mixed = 60
- §21.7.2 check 7 — the `iat` acceptance window, applied in **both** directions.
Methods
- accessTokenHash() : string
- Compute the `ath` claim value for an access token — RFC 9449 §4.2.
- canonicalHtu() : string
- Reduce a URI to its `htu` comparison form — §21.7.2 check 6.
- thumbprintS256() : string
- Compute the RFC 7638 SHA-256 thumbprint of a JWK — the `jkt`.
- verifyProof() : string
- Verify a DPoP proof against this request — all ten §21.7.2 checks.
Constants
IAT_LEEWAY_SECONDS
§21.7.2 check 7 — the `iat` acceptance window, applied in **both** directions.
public
mixed
IAT_LEEWAY_SECONDS
= 60
RFC 9449 recommends a small window without fixing a number; 60 seconds is the
contract's RECOMMENDED value. A named constant, because a bare 60 three call
frames deep is a number nobody ever revisits.
Methods
accessTokenHash()
Compute the `ath` claim value for an access token — RFC 9449 §4.2.
public
static accessTokenHash(string $accessToken) : string
base64url-unpadded SHA-256 over the token's bytes exactly as they travelled in
the Authorization header, not over anything decoded out of them.
Parameters
- $accessToken : string
-
The token as it arrived.
Return values
string —The 43-character base64url hash.
canonicalHtu()
Reduce a URI to its `htu` comparison form — §21.7.2 check 6.
public
static canonicalHtu(string $uri) : string
Query and fragment removed, and nothing else. No case folding, no default-port elision, no percent-decoding, no trailing-slash fixing: a normalising comparison is precisely where two unequal URIs become equal, and an attacker who finds such a pair can aim a proof at an endpoint it was never minted for.
Parameters
- $uri : string
-
The URI to reduce.
Return values
string —The same URI without its query string or fragment.
thumbprintS256()
Compute the RFC 7638 SHA-256 thumbprint of a JWK — the `jkt`.
public
static thumbprintS256(array<string, mixed> $jwk) : string
Only the members RFC 7638 names for the key type take part, serialised as
compact JSON with lexicographically ordered keys. Members outside that set
(kid, use, alg, x5c) are excluded by the spec, which is what makes the
thumbprint stable across two encodings of the same key.
Parameters
- $jwk : array<string, mixed>
-
The public key to fingerprint.
Tags
Return values
string —The 43-character base64url thumbprint.
verifyProof()
Verify a DPoP proof against this request — all ten §21.7.2 checks.
public
static verifyProof(string $proof, DpopRequest $request, JtiStore $store) : string
Returns the proof key's RFC 7638 thumbprint (jkt) on success. Feed it to
JwksVerifier::verifyTokenBinding() as the DPoP half of
PresentedProofs; returning it rather than true is deliberate, so the
value a guard passes onward could only have come from a proof that actually
verified.
Parameters
- $proof : string
-
The raw
DPoPheader value. - $request : DpopRequest
-
The method, URI and access token this proof must match.
- $store : JtiStore
-
The replay guard. Required — there is no default, because every default here is either a silent skip of replay protection or a per-process store masquerading as a global one.
Tags
Return values
string —The proof key's jkt.