JwksVerifier
in package
Local EdDSA/Ed25519 JWKS verification (CONTRACT.md D-08).
Keys are sourced via OIDC discovery (GET /.well-known/openid-configuration ->
jwks_uri, falling back to {baseUrl}/oauth2/jwks if discovery is unavailable or
omits jwks_uri), TTL-cached, and refetched exactly once when an unknown kid is
encountered before failing closed.
self::verify() applies the COMPLETE CONTRACT.md §10.1 "minimum local-verification set", every rule of which fails closed:
- signature — Pitfall 5 / T-alg-confusion: the header
algis pinned toEdDSABEFORE any key lookup is attempted, soalg: noneand HS-family confusion are rejected without ever consulting a key. A token never gets to choose its own verification algorithm. exp— REQUIRED. A token with noexp, or anexpthat is not a JSON number, is rejected. An absentexpis a permanent credential, never "no expiry constraint" — treating it as the latter is theSEC-080defect.nbf— honoured when present; annbfin the future is rejected. An absentnbfis valid.tenant_id— REQUIRED and asserted. Pitfall 3 / T-cross-tenant:GET /oauth2/jwksis organization-wide, not tenant-scoped, so a validly-signed token for a different tenant under the same organization must still be rejected. The claim is checked AFTER signature verification succeeds; an absent claim, or an empty expected tenant, fails closed.iss— checked only when this verifier was constructed with an expected issuer. Unset by default.aud— checked only when this verifier was constructed with an expected audience. Unset by default; both RFC 7519 shapes (single string, array) honoured.- clock skew — self::CLOCK_SKEW_LEEWAY_SECONDS, a named 60-second constant applied to rules 2 and 3, deliberately not operator-configurable.
What firebase/php-jwt does versus what §10.1 requires: JWT::decode() validates
nbf/iat/exp and rejects a non-numeric exp — but ONLY when the claim is
present (isset($payload->exp) && ...). A token with no exp at all sails
straight through it, which is precisely the SEC-080 gap. Its is_numeric() test
also accepts a quoted "1700000000", which is a JSON string, not an RFC 7519
NumericDate. And its JWT::$leeway is a public mutable static that any code in the
process can set to an unbounded value, which §10.1 rule 7 forbids. This class
therefore enforces rules 2, 3 and 7 itself rather than inheriting the library's
behaviour, and pins JWT::$leeway to its own named constant for the duration of each
decode so a global override cannot widen the window.
Deliberately does NOT use firebase/php-jwt's CachedKeySet convenience class — it
requires a PSR-18 client + PSR-17 request factory + PSR-6 cache pool, a dependency
chain D-07 explicitly avoids. This hand-rolled TTL cache mirrors every sibling SDK's
own JWKS-cache shape (e.g. the Python SDK's _jwks.py).
verify() never throws on attacker-controlled token input — malformed/short/
non-3-part tokens, unknown algorithms, unknown kids, bad signatures, and every §10.1
claim-policy failure all return null (fail closed). The only thrown exception is
the ext-sodium-missing guard, which is an environment/deployment misconfiguration,
not attacker input.
Table of Contents
Constants
- CLOCK_SKEW_LEEWAY_SECONDS : mixed = 60
- The single, named, bounded clock-skew allowance applied to the `exp` and `nbf` checks (CONTRACT.md §10.1 rule 7 — RECOMMENDED 60 s).
Methods
- __construct() : mixed
- certificateThumbprintS256() : string
- Compute the RFC 8705 §3.1 `x5t#S256` thumbprint of a DER client certificate: base64url-encoded SHA-256, **without** padding.
- verify() : array<string, mixed>|null
- Verifies a token against the COMPLETE CONTRACT.md §10.1 minimum local-verification set — see the class docblock for the seven rules and for what `firebase/php-jwt` does versus what §10.1 requires.
- verifyCertificateBinding() : bool
- CONTRACT.md §10.1 **rule 9** — enforce a token's sender constraint against the certificate the caller presented on **this** connection (RFC 8705 §3 / RFC 7800, contract 1.15).
- verifyIdTokenSignature() : array<string, mixed>
- §12.4 rules 1–2 (CONTRACT.md, OIDC/SSO relying-party helpers) — algorithm and Ed25519 signature verification for an OIDC ID token, reusing this SAME verifier's key cache and single-refetch-on-unknown-`kid` behavior (§12 forbids forking the JWKS verifier the §10 middleware already uses).
- verifyTokenBinding() : bool
- CONTRACT.md §10.1 **rule 9** in full — enforce a token's sender constraint against **every** proof the caller presented (contract 1.16).
Constants
CLOCK_SKEW_LEEWAY_SECONDS
The single, named, bounded clock-skew allowance applied to the `exp` and `nbf` checks (CONTRACT.md §10.1 rule 7 — RECOMMENDED 60 s).
public
mixed
CLOCK_SKEW_LEEWAY_SECONDS
= 60
Deliberately a class constant and not a constructor parameter: §10.1 requires the
leeway be "a named constant, not an inline literal" and forbids it being
"operator-configurable to an unbounded value". Exposing it as a knob is the exact
failure mode the rule exists to prevent — and firebase/php-jwt's own public
mutable JWT::$leeway static is that knob, which is why this class pins it for
the duration of every decode instead of trusting whatever the process has set.
Methods
__construct()
public
__construct(ClientInterface $http, string $baseUrl[, int $cacheTtlSeconds = 300 ][, string $expectedIssuer = null ][, string|null $expectedAudience = null ]) : mixed
Parameters
- $http : ClientInterface
- $baseUrl : string
- $cacheTtlSeconds : int = 300
- $expectedIssuer : string = null
-
The
issvalue this verifier requires (CONTRACT.md §10.1 rule 5). CONDITIONAL:null(the default) means no issuer check is performed at all; once supplied, a token whoseissdiffers — or which carries noiss— is rejected. There is no default and no hardcoded AXIAM issuer. - $expectedAudience : string|null = null
-
The
audvalue this verifier requires (CONTRACT.md §10.1 rule 6). CONDITIONAL:null(the default) means no audience check at all; once supplied, a token whoseauddoes not contain it — including one with noaudat all — is rejected. A guard fronting a user-facing resource server should generally expectaxiam:user.
certificateThumbprintS256()
Compute the RFC 8705 §3.1 `x5t#S256` thumbprint of a DER client certificate: base64url-encoded SHA-256, **without** padding.
public
static certificateThumbprintS256(string $der) : string
Unpadded is not a style choice — RFC 7515 §2 defines base64url in JOSE as omitting
=, and a padded value will not compare equal to what AXIAM put in the token.
Parameters
- $der : string
-
Raw DER bytes of the peer's leaf certificate. To convert a PEM (what
SSL_CLIENT_CERTcarries), strip the armour and base64-decode the body.
Return values
stringverify()
Verifies a token against the COMPLETE CONTRACT.md §10.1 minimum local-verification set — see the class docblock for the seven rules and for what `firebase/php-jwt` does versus what §10.1 requires.
public
verify(string $jwt, string $expectedTenantId) : array<string, mixed>|null
Parameters
- $jwt : string
- $expectedTenantId : string
-
The configured tenant the token's
tenant_idmust equal (§10.1 rule 4). An empty string fails closed: with nothing to compare against, the check would be vacuous, and a vacuous tenant check is how a token from another tenant gets in.
Return values
array<string, mixed>|null —Verified claims, or null on any verification failure (never throws on attacker input).
verifyCertificateBinding()
CONTRACT.md §10.1 **rule 9** — enforce a token's sender constraint against the certificate the caller presented on **this** connection (RFC 8705 §3 / RFC 7800, contract 1.15).
public
static verifyCertificateBinding(array<string, mixed> $claims, string|null $presentedThumbprint) : bool
A token carrying cnf is not a bearer token. Accepting one without proving the
caller holds the named key converts it straight back into one, discarding the whole
protection the operator turned on — which is why this is a rule and not a
recommendation.
The four cases:
token's cnf |
$presentedThumbprint |
result |
|---|---|---|
| absent | anything | true |
x5t#S256 |
equal | true |
x5t#S256 |
different, or null |
false |
present, no x5t#S256 |
anything | false |
The first row is why adopting this rule breaks nothing: an unbound token is still accepted whether or not a certificate is present. Rule 9 constrains tokens that claim a constraint; it does not make certificates mandatory.
The last row is the one that is easy to get wrong. A cnf naming a confirmation
method this SDK cannot check — a DPoP jkt, say — is an unverifiable constraint,
never no constraint. Read the other way, a sender-constrained token silently
degrades to a bearer token the day a newer AXIAM issues a confirmation this SDK
predates.
The thumbprint must come from the transport. Under PHP-FPM behind an mTLS
terminator that is typically $_SERVER['SSL_CLIENT_CERT'] converted to DER and
fingerprinted with self::certificateThumbprintS256() — and only where that
variable is set by a proxy you control. Never from a caller-settable request
header: a forgeable input makes the whole mechanism decorative.
Returns bool rather than throwing, matching self::verify(): this class
never throws on attacker input.
Parameters
- $claims : array<string, mixed>
-
Verified claims from self::verify().
- $presentedThumbprint : string|null
-
RFC 8705 §3.1
x5t#S256of the peer certificate, or null if none.
Return values
boolverifyIdTokenSignature()
§12.4 rules 1–2 (CONTRACT.md, OIDC/SSO relying-party helpers) — algorithm and Ed25519 signature verification for an OIDC ID token, reusing this SAME verifier's key cache and single-refetch-on-unknown-`kid` behavior (§12 forbids forking the JWKS verifier the §10 middleware already uses).
public
verifyIdTokenSignature(string $jwt) : array<string, mixed>
Deliberately distinct from self::verify(): an ID token carries no
tenant_id claim to check (that check is specific to AXIAM's own access tokens),
and §12.4 requires a stable machine-readable failure reason rather than a bare
null, so this method THROWS AuthError (with invalid_alg, unknown_kid,
or invalid_signature in AuthError::getReason()) instead of returning one.
Issuer/audience/time/nonce (§12.4 rules 3–6) are the caller's job —
IdTokenValidator::checkClaims() — since they need
expectations (issuer, client_id, nonce) this verifier has no reason to know about.
Parameters
- $jwt : string
Return values
array<string, mixed> —Decoded claims — signature-verified, but NOT yet issuer/audience/time/nonce-checked.
verifyTokenBinding()
CONTRACT.md §10.1 **rule 9** in full — enforce a token's sender constraint against **every** proof the caller presented (contract 1.16).
public
static verifyTokenBinding(array<string, mixed> $claims, PresentedProofs $proofs) : bool
This is the complete rule, and the one to use unless your transport genuinely cannot produce a DPoP thumbprint.
The ten cases:
token's cnf certificate DPoP result
absent anything anything true
x5t#S256 equal ignored true
x5t#S256 different ignored false
x5t#S256 null ignored false
jkt ignored equal true
jkt ignored different false
jkt ignored null false
both equal equal true
both wrong/missing — false
present, names neither anything anything false
Two rows carry the weight. Both named is a conjunction: an operator who
turned on two constraints asked for two, and satisfying the more convenient one
is not compliance. Names neither is a refusal: a confirmation this SDK
cannot interpret is an unverifiable constraint, and reading it as
"unconstrained" is the exact downgrade rule 9 exists to prevent. That includes
an empty cnf, which is also how proto3 delivers an empty CnfClaim over
gRPC (§10.3 rule 3).
Parameters
- $claims : array<string, mixed>
-
Verified claims from self::verify().
- $proofs : PresentedProofs
-
What the caller proved on this connection and request.
Return values
bool —true when the token may be accepted, false on any rejecting row.