From ff6e2dfbc629c3f808a799149bc2c5f8d3fdee35 Mon Sep 17 00:00:00 2001
From: pveeckhout <vaneeckhout.pieter@gmail.com>
Date: Mon, 25 Dec 2023 03:31:07 +0100
Subject: [PATCH 1/4] #60 Add expireAfter functionality to JWT Builder

The `expireAfter` method, accepting duration and timeUnit parameters, has been added to the JwtBuilder interface. This method calculates the JWT expiration date as either the issue time plus the duration or the system current time plus the duration if an issuedAt time hasn't been set. Additional tests for this feature have been included in `DefaultJwtParserTest.groovy`.
---
 .../main/java/io/jsonwebtoken/JwtBuilder.java | 21 +++++++++
 .../jsonwebtoken/impl/DefaultJwtBuilder.java  | 17 +++++++
 .../impl/DefaultJwtParserTest.groovy          | 45 +++++++++++++++++++
 3 files changed, 83 insertions(+)

diff --git a/api/src/main/java/io/jsonwebtoken/JwtBuilder.java b/api/src/main/java/io/jsonwebtoken/JwtBuilder.java
index 634800851..3250be893 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<JwtBuilder> {
     // for better/targeted JavaDoc
     JwtBuilder id(String jti);
 
+    /**
+     * Sets the JWT Claims <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4">
+     * <code>exp</code></a> (expiration) claim. It will set the expiration Date to the issuedAt time plus the duration
+     * specified it if has been set, otherwise it will use the current system time plus the duration specified
+     *
+     * <p>A JWT obtained after this timestamp should not be used.</p>
+     *
+     * <p>This is a convenience wrapper for:</p>
+     * <blockquote><pre>
+     * {@link #claims()}.{@link ClaimsMutator#expiration(Date) expiration(exp)}.{@link BuilderClaims#and() and()}</pre></blockquote>
+     *
+     * @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 <em>recommended signature algorithm</em>
      * 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 b6b178c46..a0a906faa 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,20 @@ public JwtBuilder id(String jti) {
         return claims().id(jti).and();
     }
 
+    @Override
+    public JwtBuilder expireAfter(long duration, TimeUnit timeUnit) {  // TODO: use java.time for version 1.0?
+        Assert.state(duration > 0, "duration must be a positive value.");
+        Assert.stateNotNull(timeUnit, "timeUnit is required.");
+
+        Date exp = Optional.ofNullable(this.claimsBuilder.get(DefaultClaims.ISSUED_AT))
+                .map(Date::getTime)
+                .map(time -> time + timeUnit.toMillis(duration))
+                .map(expMillis -> JwtDateConverter.INSTANCE.applyFrom(expMillis / 1000L))
+                .orElse(JwtDateConverter.INSTANCE.applyFrom((System.currentTimeMillis() + timeUnit.toMillis(duration)) / 1000L));
+
+        return claims().expiration(exp).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 8084b32f2..58dd9e48f 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,50 @@ class DefaultJwtParserTest {
         }
     }
 
+    @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() {
 

From e91b0425bfcf3604b8b3d25232a8c695b17f1613 Mon Sep 17 00:00:00 2001
From: pveeckhout <vaneeckhout.pieter@gmail.com>
Date: Mon, 25 Dec 2023 09:30:12 +0100
Subject: [PATCH 2/4] #60 Add validation tests for JWT expiration

Two new tests have been added to DefaultJwtParserTest to validate JWT expiration behavior. The tests ensure that for the 'expireAfter()' method, duration must be more than 0 and timeUnit cannot be null. The error messages for these validation checks have also been modified for clarity.
---
 .../jsonwebtoken/impl/DefaultJwtBuilder.java  |  4 +--
 .../impl/DefaultJwtParserTest.groovy          | 31 +++++++++++++++++--
 2 files changed, 30 insertions(+), 5 deletions(-)

diff --git a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
index a0a906faa..adacd87d0 100644
--- a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
+++ b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
@@ -482,8 +482,8 @@ public JwtBuilder id(String jti) {
 
     @Override
     public JwtBuilder expireAfter(long duration, TimeUnit timeUnit) {  // TODO: use java.time for version 1.0?
-        Assert.state(duration > 0, "duration must be a positive value.");
-        Assert.stateNotNull(timeUnit, "timeUnit is required.");
+        Assert.gt(duration, 0L, "duration must be > 0.");
+        Assert.notNull(timeUnit, "timeUnit cannot be null.");
 
         Date exp = Optional.ofNullable(this.claimsBuilder.get(DefaultClaims.ISSUED_AT))
                 .map(Date::getTime)
diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy
index 58dd9e48f..00e149ea0 100644
--- a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy
+++ b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJwtParserTest.groovy
@@ -278,6 +278,31 @@ 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
@@ -293,7 +318,7 @@ class DefaultJwtParserTest {
             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.";
+                    "Current time: ${later8601}. Allowed clock skew: 0 milliseconds."
             assertEquals msg, expected.message
         }
     }
@@ -317,7 +342,7 @@ class DefaultJwtParserTest {
             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.";
+                    "Current time: ${later8601}. Allowed clock skew: 0 milliseconds."
             assertEquals msg, expected.message
         }
     }
@@ -336,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
         }
     }

From de900cd1aba38fe630d87af3099f15478eef5538 Mon Sep 17 00:00:00 2001
From: pveeckhout <vaneeckhout.pieter@gmail.com>
Date: Wed, 10 Jan 2024 09:02:34 +0100
Subject: [PATCH 3/4] #60 Refactor token expiry calculation in
 DefaultJwtBuilder

The token expiry calculation logic has been cleaned up and optimized in DefaultJwtBuilder. A previously used optional stream has been replaced with a more straightforward if-else structure, leading to ease of code maintenance and improved readability.
---
 .../jsonwebtoken/impl/DefaultJwtBuilder.java  | 21 ++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
index adacd87d0..fb6505f55 100644
--- a/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
+++ b/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
@@ -481,17 +481,24 @@ public JwtBuilder id(String jti) {
     }
 
     @Override
-    public JwtBuilder expireAfter(long duration, TimeUnit timeUnit) {  // TODO: use java.time for version 1.0?
+    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 exp = Optional.ofNullable(this.claimsBuilder.get(DefaultClaims.ISSUED_AT))
-                .map(Date::getTime)
-                .map(time -> time + timeUnit.toMillis(duration))
-                .map(expMillis -> JwtDateConverter.INSTANCE.applyFrom(expMillis / 1000L))
-                .orElse(JwtDateConverter.INSTANCE.applyFrom((System.currentTimeMillis() + timeUnit.toMillis(duration)) / 1000L));
+        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);
 
-        return claims().expiration(exp).and();
+        /*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) {

From a6a79508b0fe6f606ce164d7740c18d916f383c1 Mon Sep 17 00:00:00 2001
From: pveeckhout <vaneeckhout.pieter@gmail.com>
Date: Wed, 10 Jan 2024 09:06:59 +0100
Subject: [PATCH 4/4] #60 Fix typo in JwtBuilder comments

A typographical error in the comments of the 'exp' function in JwtBuilder was corrected. The phrase "specified it if" was changed to "specified if it", making the comments clearer and easier to understand.
---
 api/src/main/java/io/jsonwebtoken/JwtBuilder.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/api/src/main/java/io/jsonwebtoken/JwtBuilder.java b/api/src/main/java/io/jsonwebtoken/JwtBuilder.java
index 3250be893..b3deef0ce 100644
--- a/api/src/main/java/io/jsonwebtoken/JwtBuilder.java
+++ b/api/src/main/java/io/jsonwebtoken/JwtBuilder.java
@@ -589,7 +589,7 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
     /**
      * Sets the JWT Claims <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4">
      * <code>exp</code></a> (expiration) claim. It will set the expiration Date to the issuedAt time plus the duration
-     * specified it if has been set, otherwise it will use the current system time plus the duration specified
+     * specified if it has been set, otherwise it will use the current system time plus the duration specified
      *
      * <p>A JWT obtained after this timestamp should not be used.</p>
      *