Skip to content

fix(identity): JWT parse() must validate expiry against the injected Clock#37

Merged
jlc488 merged 1 commit into
mainfrom
fix/jwt-parser-clock
May 31, 2026
Merged

fix(identity): JWT parse() must validate expiry against the injected Clock#37
jlc488 merged 1 commit into
mainfrom
fix/jwt-parser-clock

Conversation

@jlc488

@jlc488 jlc488 commented May 31, 2026

Copy link
Copy Markdown
Contributor

The bug

JjwtAuthTokenService.issue() stamps iat/exp from the injected Clock, but parse() built the JJWT parser without .clock(), so expiration was validated against the real system clock. The injected Clock — the entire reason it's a constructor argument — was ignored on the read path, and issue()/parse() could disagree on "now".

How it surfaced

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's build cache serving identity-core:test UP-TO-DATE. This is a latent time-bomb on main, 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:

.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 (testable) path is corrected. Only one Jwts.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-daemon green (all 17 test tasks executed fresh). Re-verified from a throwaway git worktree checkout of the committed SHA: JjwtAuthTokenServiceTest 4/0/0.

…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().
@jlc488
jlc488 merged commit 70cb04d into main May 31, 2026
1 check passed
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