AXIAM PHP SDK

JwksVerifier
in package

FinalYes

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:

  1. signature — Pitfall 5 / T-alg-confusion: the header alg is pinned to EdDSA BEFORE any key lookup is attempted, so alg: none and HS-family confusion are rejected without ever consulting a key. A token never gets to choose its own verification algorithm.
  2. exp — REQUIRED. A token with no exp, or an exp that is not a JSON number, is rejected. An absent exp is a permanent credential, never "no expiry constraint" — treating it as the latter is the SEC-080 defect.
  3. nbf — honoured when present; an nbf in the future is rejected. An absent nbf is valid.
  4. tenant_id — REQUIRED and asserted. Pitfall 3 / T-cross-tenant: GET /oauth2/jwks is 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.
  5. iss — checked only when this verifier was constructed with an expected issuer. Unset by default.
  6. aud — checked only when this verifier was constructed with an expected audience. Unset by default; both RFC 7519 shapes (single string, array) honoured.
  7. clock skewself::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 iss value 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 whose iss differs — or which carries no iss — is rejected. There is no default and no hardcoded AXIAM issuer.

$expectedAudience : string|null = null

The aud value this verifier requires (CONTRACT.md §10.1 rule 6). CONDITIONAL: null (the default) means no audience check at all; once supplied, a token whose aud does not contain it — including one with no aud at all — is rejected. A guard fronting a user-facing resource server should generally expect axiam: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_CERT carries), strip the armour and base64-decode the body.

Return values
string

verify()

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_id must 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#S256 of the peer certificate, or null if none.

Return values
bool

verifyIdTokenSignature()

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

On this page

Search results