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(); + } }