diff --git a/src/main/java/io/jsonwebtoken/JwtParser.java b/src/main/java/io/jsonwebtoken/JwtParser.java index e0373093..fd1f6c7e 100644 --- a/src/main/java/io/jsonwebtoken/JwtParser.java +++ b/src/main/java/io/jsonwebtoken/JwtParser.java @@ -131,11 +131,21 @@ public interface JwtParser { * The parser uses a {@link DefaultClock DefaultClock} instance by default. * * @param clock a {@code Clock} object to return the timestamp to use when validating the parsed JWT. - * @return the builder instance for method chaining. + * @return the parser for method chaining. * @since 0.7.0 */ JwtParser setClock(Clock clock); + /** + * Sets the amount of clock skew in seconds to tolerate when verifying the local time against the {@code exp} + * and {@code nbf} claims. + * + * @param seconds the number of seconds to tolerate for clock skew when verifying {@code exp} or {@code nbf} claims. + * @return the parser for method chaining. + * @since 0.7.0 + */ + JwtParser setAllowedClockSkewSeconds(long seconds); + /** * Sets the signing key used to verify any discovered JWS digital signature. If the specified JWT string is not * a JWS (no signature), this key is not used. diff --git a/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java b/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java index 90e9923a..4e4b9c79 100644 --- a/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java +++ b/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java @@ -57,6 +57,7 @@ public class DefaultJwtParser implements JwtParser { //don't need millis since JWT date fields are only second granularity: private static final String ISO_8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + private static final int MILLISECONDS_PER_SECOND = 1000; private ObjectMapper objectMapper = new ObjectMapper(); @@ -72,52 +73,47 @@ public class DefaultJwtParser implements JwtParser { private Clock clock = DefaultClock.INSTANCE; + private long allowedClockSkewMillis = 0; + @Override public JwtParser requireIssuedAt(Date issuedAt) { expectedClaims.setIssuedAt(issuedAt); - return this; } @Override public JwtParser requireIssuer(String issuer) { expectedClaims.setIssuer(issuer); - return this; } @Override public JwtParser requireAudience(String audience) { expectedClaims.setAudience(audience); - return this; } @Override public JwtParser requireSubject(String subject) { expectedClaims.setSubject(subject); - return this; } @Override public JwtParser requireId(String id) { expectedClaims.setId(id); - return this; } @Override public JwtParser requireExpiration(Date expiration) { expectedClaims.setExpiration(expiration); - return this; } @Override public JwtParser requireNotBefore(Date notBefore) { expectedClaims.setNotBefore(notBefore); - return this; } @@ -126,7 +122,6 @@ public class DefaultJwtParser implements JwtParser { Assert.hasText(claimName, "claim name cannot be null or empty."); Assert.notNull(value, "The value cannot be null for claim name: " + claimName); expectedClaims.put(claimName, value); - return this; } @@ -137,6 +132,12 @@ public class DefaultJwtParser implements JwtParser { return this; } + @Override + public JwtParser setAllowedClockSkewSeconds(long seconds) { + this.allowedClockSkewMillis = Math.max(0, seconds * MILLISECONDS_PER_SECOND); + return this; + } + @Override public JwtParser setSigningKey(byte[] key) { Assert.notEmpty(key, "signing key cannot be null or empty."); @@ -354,24 +355,33 @@ public class DefaultJwtParser implements JwtParser { } } + final boolean allowSkew = this.allowedClockSkewMillis > 0; + //since 0.3: if (claims != null) { SimpleDateFormat sdf; final Date now = this.clock.now(); + long nowTime = now.getTime(); //https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-30#section-4.1.4 //token MUST NOT be accepted on or after any specified exp time: Date exp = claims.getExpiration(); if (exp != null) { - if (now.equals(exp) || now.after(exp)) { + long maxTime = nowTime - this.allowedClockSkewMillis; + Date max = allowSkew ? new Date(maxTime) : now; + if (max.after(exp)) { sdf = new SimpleDateFormat(ISO_8601_FORMAT); String expVal = sdf.format(exp); String nowVal = sdf.format(now); - String msg = "JWT expired at " + expVal + ". Current time: " + nowVal; + long differenceMillis = maxTime - exp.getTime(); + + String msg = "JWT expired at " + expVal + ". Current time: " + nowVal + ", a difference of " + + differenceMillis + " milliseconds. Allowed clock skew: " + + this.allowedClockSkewMillis + " milliseconds."; throw new ExpiredJwtException(header, claims, msg); } } @@ -381,12 +391,19 @@ public class DefaultJwtParser implements JwtParser { Date nbf = claims.getNotBefore(); if (nbf != null) { - if (now.before(nbf)) { + long minTime = nowTime + this.allowedClockSkewMillis; + Date min = allowSkew ? new Date(minTime) : now; + if (min.before(nbf)) { sdf = new SimpleDateFormat(ISO_8601_FORMAT); String nbfVal = sdf.format(nbf); String nowVal = sdf.format(now); - String msg = "JWT must not be accepted before " + nbfVal + ". Current time: " + nowVal; + long differenceMillis = nbf.getTime() - minTime; + + String msg = "JWT must not be accepted before " + nbfVal + ". Current time: " + nowVal + + ", a difference of " + + differenceMillis + " milliseconds. Allowed clock skew: " + + this.allowedClockSkewMillis + " milliseconds."; throw new PrematureJwtException(header, claims, msg); } } diff --git a/src/test/groovy/io/jsonwebtoken/JwtParserTest.groovy b/src/test/groovy/io/jsonwebtoken/JwtParserTest.groovy index 5884e9a9..f3a0368d 100644 --- a/src/test/groovy/io/jsonwebtoken/JwtParserTest.groovy +++ b/src/test/groovy/io/jsonwebtoken/JwtParserTest.groovy @@ -176,8 +176,8 @@ class JwtParserTest { } catch (ExpiredJwtException e) { assertTrue e.getMessage().startsWith('JWT expired at ') - //https://github.com/jwtk/jjwt/issues/107 : - assertTrue e.getMessage().endsWith('Z') + //https://github.com/jwtk/jjwt/issues/107 (the Z designator at the end of the timestamp): + assertTrue e.getMessage().contains('Z, a difference of ') } } @@ -194,8 +194,60 @@ class JwtParserTest { } catch (PrematureJwtException e) { assertTrue e.getMessage().startsWith('JWT must not be accepted before ') - //https://github.com/jwtk/jjwt/issues/107 : - assertTrue e.getMessage().endsWith('Z') + //https://github.com/jwtk/jjwt/issues/107 (the Z designator at the end of the timestamp): + assertTrue e.getMessage().contains('Z, a difference of ') + } + } + + @Test + void testParseWithExpiredJwtWithinAllowedClockSkew() { + Date exp = new Date(System.currentTimeMillis() - 3000) + + String subject = 'Joe' + String compact = Jwts.builder().setSubject(subject).setExpiration(exp).compact() + + Jwt jwt = Jwts.parser().setAllowedClockSkewSeconds(10).parse(compact) + + assertEquals jwt.getBody().getSubject(), subject + } + + @Test + void testParseWithExpiredJwtNotWithinAllowedClockSkew() { + Date exp = new Date(System.currentTimeMillis() - 3000) + + String compact = Jwts.builder().setSubject('Joe').setExpiration(exp).compact() + + try { + Jwts.parser().setAllowedClockSkewSeconds(1).parse(compact) + fail() + } catch (ExpiredJwtException e) { + assertTrue e.getMessage().startsWith('JWT expired at ') + } + } + + @Test + void testParseWithPrematureJwtWithinAllowedClockSkew() { + Date exp = new Date(System.currentTimeMillis() + 3000) + + String subject = 'Joe' + String compact = Jwts.builder().setSubject(subject).setNotBefore(exp).compact() + + Jwt jwt = Jwts.parser().setAllowedClockSkewSeconds(10).parse(compact) + + assertEquals jwt.getBody().getSubject(), subject + } + + @Test + void testParseWithPrematureJwtNotWithinAllowedClockSkew() { + Date exp = new Date(System.currentTimeMillis() + 3000) + + String compact = Jwts.builder().setSubject('Joe').setNotBefore(exp).compact() + + try { + Jwts.parser().setAllowedClockSkewSeconds(1).parse(compact) + fail() + } catch (PrematureJwtException e) { + assertTrue e.getMessage().startsWith('JWT must not be accepted before ') } }