diff --git a/api/src/main/java/io/jsonwebtoken/JwtBuilder.java b/api/src/main/java/io/jsonwebtoken/JwtBuilder.java index 634800851..b3deef0ce 100644 --- a/api/src/main/java/io/jsonwebtoken/JwtBuilder.java +++ b/api/src/main/java/io/jsonwebtoken/JwtBuilder.java @@ -42,6 +42,7 @@ import java.security.interfaces.RSAKey; import java.util.Date; import java.util.Map; +import java.util.concurrent.TimeUnit; /** * A builder for constructing Unprotected JWTs, Signed JWTs (aka 'JWS's) and Encrypted JWTs (aka 'JWE's). @@ -585,6 +586,26 @@ public interface JwtBuilder extends ClaimsMutator { // for better/targeted JavaDoc JwtBuilder id(String jti); + /** + * Sets the JWT Claims + * exp (expiration) claim. It will set the expiration Date to the issuedAt time plus the duration + * specified if it has been set, otherwise it will use the current system time plus the duration specified + * + *

A JWT obtained after this timestamp should not be used.

+ * + *

This is a convenience wrapper for:

+ *
+     * {@link #claims()}.{@link ClaimsMutator#expiration(Date) expiration(exp)}.{@link BuilderClaims#and() and()}
+ * + * @param duration The duration after the issue time that the JWT should expire. It is added to the issue time to + * calculate the expiration time. + * @param timeUnit The time unit of the duration parameter. This specifies the unit of measurement for the + * duration (e.g., seconds, minutes, hours, etc.), determining how the duration value should + * be interpreted when calculating the expiration time. + * @return the builder instance for method chaining. + */ + JwtBuilder expireAfter(long duration, TimeUnit timeUnit); + /** * Signs the constructed JWT with the specified key using the key's recommended signature algorithm * as defined below, producing a JWS. If the recommended signature algorithm isn't sufficient for your needs, diff --git a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java index ef41c7aec..75a59ea10 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java @@ -31,6 +31,7 @@ import io.jsonwebtoken.impl.lang.Bytes; import io.jsonwebtoken.impl.lang.Function; import io.jsonwebtoken.impl.lang.Functions; +import io.jsonwebtoken.impl.lang.JwtDateConverter; import io.jsonwebtoken.impl.lang.Parameter; import io.jsonwebtoken.impl.lang.Services; import io.jsonwebtoken.impl.security.DefaultAeadRequest; @@ -76,7 +77,9 @@ import java.util.Date; import java.util.LinkedHashSet; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.TimeUnit; public class DefaultJwtBuilder implements JwtBuilder { @@ -477,6 +480,27 @@ public JwtBuilder id(String jti) { return claims().id(jti).and(); } + @Override + public JwtBuilder expireAfter(final long duration, final TimeUnit timeUnit) { // TODO: use java.time and optionals from jdk 8 for version 1.0 + Assert.gt(duration, 0L, "duration must be > 0."); + Assert.notNull(timeUnit, "timeUnit cannot be null."); + + Date issuedAtDate = this.claimsBuilder.get(DefaultClaims.ISSUED_AT); + long expiryEpochMillis; + if (null != issuedAtDate) { + expiryEpochMillis = issuedAtDate.getTime() + timeUnit.toMillis(duration); + } else { + expiryEpochMillis = (System.currentTimeMillis() + timeUnit.toMillis(duration)); + } + Date expiryDate = JwtDateConverter.INSTANCE.applyFrom(expiryEpochMillis / 1000L); + + /*Instant expiryInstant = Optional.ofNullable(this.claimsBuilder.get(DefaultClaims.ISSUED_AT)) // this should return an instant I guess + .orElseGet(() -> Instant.now()) + .plus(duration, timeUnit);*/ + + return claims().expiration(expiryDate).and(); + } + private void assertPayloadEncoding(String type) { if (!this.encodePayload) { String msg = "Payload encoding may not be disabled for " + type + "s, only JWSs."; diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy index 4c41142c2..87423c0c4 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy @@ -32,6 +32,7 @@ import org.junit.Test import javax.crypto.Mac import javax.crypto.SecretKey +import java.util.concurrent.TimeUnit import static org.junit.Assert.* @@ -277,6 +278,75 @@ class DefaultJwtParserTest { } } + @Test + void testExpiredAfterDurationValidationMessage() { + def duration = -1L + def timeUnit = TimeUnit.MINUTES + try { + Jwts.builder().expireAfter(duration, timeUnit).compact() + } catch (IllegalArgumentException expected) { + String msg = "duration must be > 0." + assertEquals msg, expected.message + } + } + + @Test + void testExpiredAfterTimeUnitValidationMessage() { + def duration = 15L + def timeUnit = null + try { + Jwts.builder().expireAfter(duration, timeUnit).compact() + } catch (IllegalArgumentException expected) { + String msg = "timeUnit cannot be null." + assertEquals msg, expected.message + } + } + + + @Test + void testExpiredAfterExceptionMessage() { + long differenceMillis = 781 // arbitrary, anything > 0 is fine + def duration = 15L + def timeUnit = TimeUnit.MINUTES + def expectedExpiry = JwtDateConverter.INSTANCE.applyFrom((System.currentTimeMillis() + timeUnit.toMillis(duration)) / 1000L) + def later = new Date(expectedExpiry.getTime() + differenceMillis) + def s = Jwts.builder().expireAfter(duration, timeUnit).compact() + + try { + Jwts.parser().unsecured().clock(new FixedClock(later)).build().parse(s) + } catch (ExpiredJwtException expected) { + def exp8601 = DateFormats.formatIso8601(expectedExpiry, true) + def later8601 = DateFormats.formatIso8601(later, true) + String msg = "JWT expired ${differenceMillis} milliseconds ago at ${exp8601}. " + + "Current time: ${later8601}. Allowed clock skew: 0 milliseconds." + assertEquals msg, expected.message + } + } + + @Test + void testExpiredAfterWithIssuedAtExceptionMessage() { + long differenceMillis = 781 // arbitrary, anything > 0 is fine + def duration = 15L + def timeUnit = TimeUnit.MINUTES + def issuedAt = JwtDateConverter.INSTANCE.applyFrom((System.currentTimeMillis() + timeUnit.toMillis(-1L)) / 1000L) //set it to one minute earlier + def expectedExpiry = JwtDateConverter.INSTANCE.applyFrom((System.currentTimeMillis() + timeUnit.toMillis(duration - 1L)) / 1000L) // we expect it to expire a minute earlier + def later = new Date(expectedExpiry.getTime() + differenceMillis) + def s = Jwts.builder() + .issuedAt(issuedAt) + .expireAfter(duration, timeUnit) + .compact() + + try { + Jwts.parser().unsecured().clock(new FixedClock(later)).build().parse(s) + } catch (ExpiredJwtException expected) { + def exp8601 = DateFormats.formatIso8601(expectedExpiry, true) + def later8601 = DateFormats.formatIso8601(later, true) + String msg = "JWT expired ${differenceMillis} milliseconds ago at ${exp8601}. " + + "Current time: ${later8601}. Allowed clock skew: 0 milliseconds." + assertEquals msg, expected.message + } + } + @Test void testNotBeforeExceptionMessage() { @@ -291,7 +361,7 @@ class DefaultJwtParserTest { def nbf8601 = DateFormats.formatIso8601(nbf, true) def earlier8601 = DateFormats.formatIso8601(earlier, true) String msg = "JWT early by ${differenceMillis} milliseconds before ${nbf8601}. " + - "Current time: ${earlier8601}. Allowed clock skew: 0 milliseconds."; + "Current time: ${earlier8601}. Allowed clock skew: 0 milliseconds." assertEquals msg, expected.message } }