Skip to content

feat: harden ID token claim validation - #59

Merged
turegjorup merged 1 commit into
developfrom
feature/id-token-claim-hardening
Aug 26, 2026
Merged

feat: harden ID token claim validation#59
turegjorup merged 1 commit into
developfrom
feature/id-token-claim-hardening

Conversation

@turegjorup

@turegjorup turegjorup commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Four hardening changes to validateIdToken(), all in the same seam.

Changes

Require exp and iat. OIDC Core §2 makes both REQUIRED, and firebase/php-jwt validates exp only when it is present — so a token omitting it never expired. Forged tokens still die at the signature check, so exploiting this needs a misbehaving IdP, but the deadline should not be optional.

Compare the nonce with hash_equals(). It is the only claim checked against a value the caller holds, so a timing signal there leaks that secret rather than a public identifier.

Compare the audience strictly. PHP's loose comparison treats numeric strings as equal by value, so an IdP announcing aud: "1e2" previously satisfied a client id of "100". Numeric client ids are real, so this is worth closing.

Require iss and nonce to be non-empty strings. Both were interpolated into exception messages unchecked, so a signed token carrying an array in either claim turned a claims mismatch into a bare \Error that does not implement OpenIdConnectExceptionInterface — the contract violation CLAUDE.md rules out. Non-string audience entries are filtered for the same reason: interpolating them rendered an Array to string conversion instead of naming the audiences.

Two new private helpers carry the checks — requireNumericClaim() and requireStringClaim() — the latter returning the narrowed value so callers cannot re-read the untyped property.

Behaviour changes

Nothing here changes behaviour for a spec-compliant IdP, but two cases that previously passed now raise ClaimsException:

  • an ID token without exp or iat
  • an audience that matched only under loose comparison

Both are called out in the changelog, since the first is the kind of tightening a deployment could feel on upgrade.

Reviewer note

The last two changes are separable from the first two — they fix a different symptom (unchecked claim types reaching string interpolation) of the same underlying habit. They are here because they sit inside the function being changed and fixing the nonce while leaving iss one line above it would have been odd. Say the word if you would rather they went in their own PR.

Verification

131 tests, all green. Coverage 100% (29/29 methods, 179/179 lines). Mutation: 229 generated, 225 killed, 4 ignored, zero escaped, MSI 100%.

getMockClaims() gained exp/iat, which is what keeps the eleven pre-existing validateIdToken tests passing. One mutant needed a dedicated test: UnwrapArrayFilter on the audience filter survives unless a case reaches the exception message with every entry non-string, so there is a test asserting that message stays printable.

PHPStan max clean at the ceiling and at the dependency floor, php-cs-fixer, markdownlint, prettier and composer normalize all clean.

Four changes to validateIdToken(), all in the same seam.

Require "exp" and "iat" (OIDC Core §2 makes both REQUIRED). firebase/php-jwt
validates "exp" only when it is present, so a token omitting it never
expired. Forged tokens still die at the signature check, so exploiting this
needs a misbehaving IdP — but the deadline should not be optional.

Compare the nonce with hash_equals(). It is the only claim checked against a
value the caller holds, so a timing signal there leaks that secret rather
than a public identifier.

Compare the audience strictly. PHP's loose comparison treats numeric strings
as equal by value, so an audience of "1e2" satisfied a client id of "100".
Non-string audience entries are filtered out: they cannot match a string
client id, and interpolating them into the exception message rendered an
"Array to string conversion" instead of naming the audiences.

Require "iss" and "nonce" to be non-empty strings. Both were interpolated
into exception messages unchecked, so a signed token carrying an array in
either claim turned a claims mismatch into a bare \Error that does not
implement OpenIdConnectExceptionInterface. Two new private helpers,
requireNumericClaim() and requireStringClaim(), hold the checks; the latter
returns the narrowed value so callers cannot re-read the untyped property.

The last two go beyond the planned exp/iat and hash_equals work, but they
are the same defect class in the same function: a claim whose type was
assumed rather than checked.
@turegjorup turegjorup self-assigned this Aug 26, 2026
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (41f3e05) to head (72ca421).

Additional details and impacted files
@@             Coverage Diff             @@
##             develop       #59   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity        77        83    +6     
===========================================
  Files              1         1           
  Lines            196       208   +12     
===========================================
+ Hits             196       208   +12     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@turegjorup
turegjorup merged commit aa772be into develop Aug 26, 2026
18 checks passed
@turegjorup
turegjorup deleted the feature/id-token-claim-hardening branch August 26, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant