From d1ba2e97a03290f2a0b49bfc7e274b88069c0993 Mon Sep 17 00:00:00 2001 From: NopeCharon <115794254+NopeCharon@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:42:04 +0530 Subject: [PATCH] fix(jwtsvid): return null from getIssuedAt when the iat claim is absent The 'iat' claim is optional in a JWT-SVID, and JwtSvid.parseInsecure and JwtSvid.parseAndValidate accept tokens without it. In that case the issuedAt field is null, and JwtSvid.getIssuedAt() threw a NullPointerException while making its defensive copy. Any caller that read the issued-at time of such a token crashed. getIssuedAt() now returns null when the token has no 'iat' claim, and otherwise still returns a defensive copy, matching getExpiry(). The method gains Javadoc describing the null return. CachedJwtSource.isTokenPastHalfLifetime previously avoided the NPE by reading the raw "iat" entry from the claims map. It now uses getIssuedAt() directly. Its behavior is unchanged: SVIDs without 'iat' are still treated as needing a refresh. Tests cover a token without 'iat' (getIssuedAt returns null) and a token with 'iat' (the value is returned and the internal state cannot be modified through the returned Date). Signed-off-by: NopeCharon <115794254+NopeCharon@users.noreply.github.com> --- .../java/io/spiffe/svid/jwtsvid/JwtSvid.java | 11 ++++++ .../spiffe/workloadapi/CachedJwtSource.java | 6 ++-- .../jwtsvid/JwtSvidParseInsecureTest.java | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/java-spiffe-core/src/main/java/io/spiffe/svid/jwtsvid/JwtSvid.java b/java-spiffe-core/src/main/java/io/spiffe/svid/jwtsvid/JwtSvid.java index 46b19b52..210363a1 100644 --- a/java-spiffe-core/src/main/java/io/spiffe/svid/jwtsvid/JwtSvid.java +++ b/java-spiffe-core/src/main/java/io/spiffe/svid/jwtsvid/JwtSvid.java @@ -276,7 +276,18 @@ public Date getExpiry() { return new Date(expiry.getTime()); } + /** + * Returns a copy of the issued at date time of the JWT SVID. + * + * @return a copy of the issued at date time of the JWT SVID, or {@code null} if the token + * does not have an 'iat' claim + */ public Date getIssuedAt() { + // the 'iat' claim is optional in a JWT-SVID + if (issuedAt == null) { + return null; + } + // defensive copy to prevent exposing a mutable object return new Date(issuedAt.getTime()); } diff --git a/java-spiffe-core/src/main/java/io/spiffe/workloadapi/CachedJwtSource.java b/java-spiffe-core/src/main/java/io/spiffe/workloadapi/CachedJwtSource.java index bd39915a..e5ddbd3c 100644 --- a/java-spiffe-core/src/main/java/io/spiffe/workloadapi/CachedJwtSource.java +++ b/java-spiffe-core/src/main/java/io/spiffe/workloadapi/CachedJwtSource.java @@ -270,13 +270,13 @@ private static Set getAudienceSet(String audience, String[] extraAudienc } private boolean isTokenPastHalfLifetime(JwtSvid jwtSvid) { - Object issuedAtClaim = jwtSvid.getClaims().get("iat"); - if (!(issuedAtClaim instanceof Date)) { + Date issuedAt = jwtSvid.getIssuedAt(); + if (issuedAt == null) { return true; } long expiryTime = jwtSvid.getExpiry().getTime(); - long issuedAtTime = ((Date) issuedAtClaim).getTime(); + long issuedAtTime = issuedAt.getTime(); long halfLifeTime = expiryTime - (expiryTime - issuedAtTime) / 2; return clock.instant().toEpochMilli() > halfLifeTime; diff --git a/java-spiffe-core/src/test/java/io/spiffe/svid/jwtsvid/JwtSvidParseInsecureTest.java b/java-spiffe-core/src/test/java/io/spiffe/svid/jwtsvid/JwtSvidParseInsecureTest.java index 25b72ea7..a23c9edc 100644 --- a/java-spiffe-core/src/test/java/io/spiffe/svid/jwtsvid/JwtSvidParseInsecureTest.java +++ b/java-spiffe-core/src/test/java/io/spiffe/svid/jwtsvid/JwtSvidParseInsecureTest.java @@ -15,6 +15,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.security.KeyPair; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.Map; @@ -23,6 +24,7 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; class JwtSvidParseInsecureTest { @@ -96,6 +98,40 @@ void testParseInsecure_nullAudience_throwsNullPointerException() throws JwtSvidE } } + @Test + void testParseInsecure_missingIssuedAt_getIssuedAtReturnsNull() throws JwtSvidException { + KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521); + SpiffeId spiffeId = TrustDomain.parse("test.domain").newSpiffeId("host"); + Set audience = Collections.singleton("audience"); + Date expiration = new Date(System.currentTimeMillis() + 3600000); + JWTClaimsSet claims = TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), expiration); + + JwtSvid jwtSvid = JwtSvid.parseInsecure(TestUtils.generateToken(claims, key1, "authority1"), audience); + + assertNull(jwtSvid.getIssuedAt()); + } + + @Test + void testParseInsecure_withIssuedAt_getIssuedAtReturnsCopy() throws JwtSvidException { + KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521); + SpiffeId spiffeId = TrustDomain.parse("test.domain").newSpiffeId("host"); + Set audience = Collections.singleton("audience"); + Date issuedAt = new Date((System.currentTimeMillis() / 1000) * 1000); + Date expiration = new Date(issuedAt.getTime() + 3600000); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .subject(spiffeId.toString()) + .audience(new ArrayList<>(audience)) + .issueTime(issuedAt) + .expirationTime(expiration) + .build(); + + JwtSvid jwtSvid = JwtSvid.parseInsecure(TestUtils.generateToken(claims, key1, "authority1"), audience); + + assertEquals(issuedAt, jwtSvid.getIssuedAt()); + jwtSvid.getIssuedAt().setTime(0); + assertEquals(issuedAt, jwtSvid.getIssuedAt()); + } + static Stream provideSuccessScenarios() { KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521); KeyPair key2 = TestUtils.generateECKeyPair(Curve.P_521);