fix(identity): JWT parse() must validate expiry against the injected Clock#37
Merged
Conversation
…Clock
JjwtAuthTokenService.issue() stamped iat/exp from the injected Clock, but
parse() built the JJWT parser without .clock(), so expiration was validated
against the real system clock. Two consequences:
1. Asymmetric time source — issue() and parse() could disagree on "now".
2. The injected Clock (the whole reason it's a constructor arg) was ignored on
the read path, making token validation untestable with a fixed clock.
This surfaced as a wall-clock-dependent CI failure: JjwtAuthTokenServiceTest
fixes the clock at 2026-05-31T00:00:00Z with an 8h TTL (exp 08:00:00Z). Any CI
run after 08:00 UTC saw the token as already expired -> parse() returned empty
-> orElseThrow() threw NoSuchElementException. The suite passed only when it
happened to run before 08:00 UTC (and locally was masked by Gradle build-cache
serving identity-core:test UP-TO-DATE). It is a latent time-bomb on main, not
specific to any feature branch.
Fix: pass the injected clock to the parser —
.clock(() -> Date.from(Instant.now(clock)))
io.jsonwebtoken.Clock is a `Date now()` functional interface, so java.time.Clock
adapts with a lambda. Production behaviour is unchanged (the runtime injects
Clock.systemUTC(), identical on both paths); only the injected-clock path is
corrected.
Regression locks added to JjwtAuthTokenServiceTest (now 4 tests, deterministic
regardless of when CI runs):
- parseHonorsInjectedClock_acceptsTokenThatRealClockWouldReject: clock fixed in
2020; a token whose 8h window closed years ago in wall-clock terms must still
parse, proving the injected clock governs expiry (fails if .clock() is removed).
- parseRejectsTokenExpiredPerInjectedClock: a reader clock past the TTL rejects
the token.
Verified: ./gradlew build --no-daemon green, all 17 test tasks executed fresh;
JjwtAuthTokenServiceTest 4/0/0. Only one Jwts.parser() site exists in main
source — no other parser is missing .clock().
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.
The bug
JjwtAuthTokenService.issue()stampsiat/expfrom the injectedClock, butparse()built the JJWT parser without.clock(), so expiration was validated against the real system clock. The injectedClock— the entire reason it's a constructor argument — was ignored on the read path, andissue()/parse()could disagree on "now".How it surfaced
A wall-clock-dependent CI failure.
JjwtAuthTokenServiceTestfixes the clock at2026-05-31T00:00:00Zwith an 8h TTL (exp08:00:00Z). Any CI run after 08:00 UTC saw the token as already expired →parse()returned empty →orElseThrow()threwNoSuchElementException. The suite passed only when it happened to run before 08:00 UTC, and locally was masked by Gradle's build cache servingidentity-core:testUP-TO-DATE. This is a latent time-bomb onmain, not specific to any feature branch — it just happened to be exposed by a build that ran past 08:00 UTC.The fix
Pass the injected clock to the parser:
io.jsonwebtoken.Clockis aDate now()functional interface, sojava.time.Clockadapts with a lambda. Production behaviour is unchanged — the runtime injectsClock.systemUTC(), identical on both paths; only the injected-clock (testable) path is corrected. Only oneJwts.parser()site exists in main source, so no other parser needs the same fix.Regression locks (now 4 tests, deterministic regardless of when CI runs)
parseHonorsInjectedClock_acceptsTokenThatRealClockWouldReject— clock fixed in 2020; a token whose 8h window closed years ago in wall-clock terms must still parse, proving the injected clock governs expiry. Fails if.clock()is ever removed.parseRejectsTokenExpiredPerInjectedClock— a reader clock past the TTL rejects the token.Verification
./gradlew build --no-daemongreen (all 17 test tasks executed fresh). Re-verified from a throwawaygit worktreecheckout of the committed SHA:JjwtAuthTokenServiceTest4/0/0.