feat: harden ID token claim validation - #59
Merged
Conversation
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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four hardening changes to
validateIdToken(), all in the same seam.Changes
Require
expandiat. OIDC Core §2 makes both REQUIRED, andfirebase/php-jwtvalidatesexponly 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
issandnonceto 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\Errorthat does not implementOpenIdConnectExceptionInterface— the contract violationCLAUDE.mdrules out. Non-string audience entries are filtered for the same reason: interpolating them rendered anArray to string conversioninstead of naming the audiences.Two new private helpers carry the checks —
requireNumericClaim()andrequireStringClaim()— 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:exporiatBoth 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
issone 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()gainedexp/iat, which is what keeps the eleven pre-existingvalidateIdTokentests passing. One mutant needed a dedicated test:UnwrapArrayFilteron 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 normalizeall clean.