From 61405f1b5445afeeb91b47bd83feedd6202c5ce6 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 17:15:55 +0900 Subject: [PATCH] fix(identity): JWT parse() must validate expiry against the injected Clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- .../core/service/JjwtAuthTokenService.java | 6 +++ .../service/JjwtAuthTokenServiceTest.java | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java index 9c4d2e8..a10c338 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java @@ -75,6 +75,12 @@ public Optional parse(String token) { Claims claims = Jwts.parser() .verifyWith(signingKey) .requireIssuer(issuer) + // Validate expiration/not-before against the SAME injected clock + // that issue() uses. JJWT's parser otherwise defaults to the real + // system clock, which (a) makes the injected Clock untestable and + // (b) is asymmetric with issue(). io.jsonwebtoken.Clock is a + // {@code Date now()} functional interface, so adapt java.time.Clock. + .clock(() -> Date.from(Instant.now(clock))) .build() .parseSignedClaims(token) .getPayload(); diff --git a/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java index 9fdc3d1..2978383 100644 --- a/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java +++ b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java @@ -55,4 +55,41 @@ void roundTripsMustChangePasswordFalse() { assertThat(parsed.mustChangePassword()).isFalse(); } + + /** + * Regression lock for the parser-clock bug: {@code parse()} must validate + * expiry against the injected clock, not the real system clock. + * + *

The clock is fixed far in the past, so the token's 8h window closed + * years ago in wall-clock terms. If {@code parse()} (re)introduces the + * default system clock, it sees the token as long expired and returns + * empty — and this assertion fails. It passes only while the injected clock + * governs expiry. Deterministic regardless of when CI runs. + */ + @Test + void parseHonorsInjectedClock_acceptsTokenThatRealClockWouldReject() { + Clock past = Clock.fixed(Instant.parse("2020-01-01T00:00:00Z"), ZoneOffset.UTC); + JjwtAuthTokenService pastService = + new JjwtAuthTokenService(SECRET, Duration.ofHours(8), "devslab-kit-test", past); + + AuthToken token = pastService.issue(user(false)); + + assertThat(pastService.parse(token.value())).isPresent(); + } + + /** And the symmetric case: a token past its TTL per the injected clock is rejected. */ + @Test + void parseRejectsTokenExpiredPerInjectedClock() { + Clock t0 = Clock.fixed(Instant.parse("2026-05-31T00:00:00Z"), ZoneOffset.UTC); + JjwtAuthTokenService issuerService = + new JjwtAuthTokenService(SECRET, Duration.ofHours(1), "devslab-kit-test", t0); + AuthToken token = issuerService.issue(user(false)); + + // A reader whose clock sits 2h later — past the 1h TTL — must reject it. + Clock later = Clock.fixed(Instant.parse("2026-05-31T02:00:00Z"), ZoneOffset.UTC); + JjwtAuthTokenService readerService = + new JjwtAuthTokenService(SECRET, Duration.ofHours(1), "devslab-kit-test", later); + + assertThat(readerService.parse(token.value())).isEmpty(); + } }