diff --git a/esignet-core/src/main/java/io/mosip/esignet/core/constants/Constants.java b/esignet-core/src/main/java/io/mosip/esignet/core/constants/Constants.java index cedd439dd..1dc5b9aab 100644 --- a/esignet-core/src/main/java/io/mosip/esignet/core/constants/Constants.java +++ b/esignet-core/src/main/java/io/mosip/esignet/core/constants/Constants.java @@ -61,7 +61,6 @@ public class Constants { public static final String DPOP_BOUND_ACCESS_TOKENS = "dpop_bound_access_tokens"; public static final String PAR_REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; - public static final String JTI_CACHE = "jti"; public static final String REQUIRE_PAR= "require_pushed_authorization_requests"; public static final String REQUIRE_PKCE= "require_pkce"; diff --git a/esignet-service/src/main/java/io/mosip/esignet/advice/DpopValidationFilter.java b/esignet-service/src/main/java/io/mosip/esignet/advice/DpopValidationFilter.java index 612ae50f1..2cceb86bf 100644 --- a/esignet-service/src/main/java/io/mosip/esignet/advice/DpopValidationFilter.java +++ b/esignet-service/src/main/java/io/mosip/esignet/advice/DpopValidationFilter.java @@ -315,7 +315,18 @@ private void replayCheck(JWTClaimsSet claims) { log.error("Missing jti claim"); throw new InvalidDpopHeaderException(); } - if (cacheUtilService.checkAndMarkJti(jti)) { + Date iatDate = claims.getIssueTime(); + Date expDate = claims.getExpirationTime(); + long iat = iatDate.toInstant().getEpochSecond(); + long acceptanceCloseSec = iat + maxDPOPIatAgeSeconds + maxClockSkewSeconds; + + if (expDate != null) { + long exp = expDate.toInstant().getEpochSecond(); + acceptanceCloseSec = Math.min(acceptanceCloseSec, exp + maxClockSkewSeconds); + } + long dpopJtiTtlSeconds = Math.max(1L, acceptanceCloseSec - Instant.now().getEpochSecond()); + + if (cacheUtilService.checkAndMarkJti(jti, dpopJtiTtlSeconds)) { log.error("Replay detected for jti: {}", jti); throw new InvalidDpopHeaderException(); } diff --git a/esignet-service/src/main/resources/application-default.properties b/esignet-service/src/main/resources/application-default.properties index c418f8dac..55e94e061 100644 --- a/esignet-service/src/main/resources/application-default.properties +++ b/esignet-service/src/main/resources/application-default.properties @@ -191,7 +191,7 @@ mosip.esignet.cache.security.algorithm-name=AES/ECB/PKCS5Padding mosip.esignet.cache.key.hash.algorithm=SHA3-256 mosip.esignet.cache.keyprefix=${mosip.esignet.namespace} -mosip.esignet.cache.names=clientdetails,preauth,authenticated,authcodegenerated,userinfo,linkcodegenerated,linked,linkedcode,linkedauth,consented,authtokens,bindingtransaction,apiratelimit,blocked,halted,nonce,par,jti,kbispec +mosip.esignet.cache.names=clientdetails,preauth,authenticated,authcodegenerated,userinfo,linkcodegenerated,linked,linkedcode,linkedauth,consented,authtokens,bindingtransaction,apiratelimit,blocked,halted,nonce,par,kbispec # 'simple' cache type is only applicable only for Non-Production setup spring.cache.type=redis @@ -220,7 +220,6 @@ mosip.esignet.cache.size={'clientdetails' : 200, \ 'halted' : 500,\ 'nonce' : 500, \ 'par' : 200, \ -'jti' : 200, \ 'kbispec': 1 } # Cache expire in seconds is applicable for both 'simple' and 'Redis' cache type @@ -242,7 +241,6 @@ mosip.esignet.cache.expire-in-seconds={'clientdetails' : 86400, \ 'halted' : ${mosip.esignet.signup.halt.expire-seconds}, \ 'nonce' : 86400, \ 'par' : ${mosip.esignet.par.expire-seconds},\ -'jti' : 86400 , \ 'kbispec': ${mosip.esignet.kbispec.ttl.seconds}} ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- @@ -458,6 +456,12 @@ mosip.esignet.client-assertion-jwt.leeway-seconds=15 mosip.esignet.client-assertion.unique.jti.required=true +# Hard ceiling for the JTI cache TTL — also the maximum permitted lifetime +mosip.esignet.client-assertion.jti.cache.max-ttl-seconds=3600 + +# Clock-skew buffer added on top of (exp − now) before the ceiling clamp. +mosip.esignet.client-assertion.jti.cache.skew-buffer-seconds=30 + # manage what fields to use to create public-key-hash for unique public key mosip.esignet.public-key-hash.fields={ 'RSA': { 'n' },\ \ 'EC': { 'x', 'y' } } diff --git a/esignet-service/src/test/java/io/mosip/esignet/advice/DpopValidationFilterTest.java b/esignet-service/src/test/java/io/mosip/esignet/advice/DpopValidationFilterTest.java index 77df52a37..443652f7f 100644 --- a/esignet-service/src/test/java/io/mosip/esignet/advice/DpopValidationFilterTest.java +++ b/esignet-service/src/test/java/io/mosip/esignet/advice/DpopValidationFilterTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -61,7 +62,7 @@ public void setup() throws Exception { accessToken = generateAccessTokenForUserinfo(true); - when(cacheUtilService.checkAndMarkJti(anyString())).thenReturn(false); + when(cacheUtilService.checkAndMarkJti(anyString(), anyLong())).thenReturn(false); ReflectionTestUtils.setField(filter, "discoveryMap", Map.ofEntries( Map.entry("dpop_signing_alg_values_supported", Arrays.asList("ES256", "RS256")), Map.entry("pushed_authorization_request_endpoint", "http://localhost/oauth/par"), @@ -148,7 +149,7 @@ public void testDpopHeader_replayDetection_thenFail() throws Exception { addAuthorizationHeader(request, accessToken); request.setMethod("GET"); - when(cacheUtilService.checkAndMarkJti(anyString())).thenReturn(true); // simulate replay + when(cacheUtilService.checkAndMarkJti(anyString(), anyLong())).thenReturn(true); // simulate replay filter.doFilterInternal(request, response, filterChain); @@ -402,4 +403,130 @@ public void testUserinfoPath_withMultipleAuthorizationHeaders_thenFail() throws assertTrue(wwwAuthenticate.contains("error=\"invalid_request\"")); } + /** + * Builds a DPoP proof JWT with a caller-controlled iat so we can drive the + * iat-anchored TTL formula in {@code replayCheck}. + */ + private String createDpopJwtWithCustomIat(String httpMethod, String htuClaim, Instant iat) throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(new JOSEObjectType("dpop+jwt")) + .jwk(ecJwk.toPublicJWK()) + .build(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .claim("htm", httpMethod) + .claim("htu", htuClaim) + .issueTime(Date.from(iat)) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new ECDSASigner(ecJwk.toECPrivateKey())); + return signedJWT.serialize(); + } + + /** + * Happy path — verifies that the TTL handed to the JTI cache equals + * {@code maxDPOPIatAgeSeconds + maxClockSkewSeconds} when iat = now and exp is absent. + * With the configured defaults (60 + 10), the expected TTL is ~70s. + */ + @Test + public void testDpopHeader_replayCheck_capturesIatAnchoredTtl() throws Exception { + String dpopJwt = createDpopJwtWithAllClaims("POST", "http://localhost/oauth/par", null, false); + + request.setRequestURI("/oauth/par"); + request.addHeader("DPoP", dpopJwt); + request.setMethod("POST"); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + when(cacheUtilService.checkAndMarkJti(anyString(), ttlCaptor.capture())).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + long ttl = ttlCaptor.getValue(); + // iat ≈ now, no exp ⇒ acceptanceCloseSec = now + 60 + 10 ⇒ TTL ≈ 70s (allow ±2s for scheduling jitter) + assertTrue(ttl >= 68 && ttl <= 70, + "Expected TTL ~70s (maxDPOPIatAgeSeconds + maxClockSkewSeconds), got " + ttl); + } + + /** + * Confirms TTL is anchored to {@code iat}, not to "now": + * a proof issued 30s ago must shrink the cache entry's lifetime by ~30s. + * Expected TTL = (iat - 30) + 60 + 10 - now ≈ 40s. + */ + @Test + public void testDpopHeader_replayCheck_olderIat_shortensTtl() throws Exception { + String dpopJwt = createDpopJwtWithCustomIat("POST", "http://localhost/oauth/par", + Instant.now().minusSeconds(30)); + + request.setRequestURI("/oauth/par"); + request.addHeader("DPoP", dpopJwt); + request.setMethod("POST"); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + when(cacheUtilService.checkAndMarkJti(anyString(), ttlCaptor.capture())).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + long ttl = ttlCaptor.getValue(); + // acceptanceCloseSec = (now-30) + 60 + 10 = now + 40 ⇒ TTL ≈ 40s + assertTrue(ttl >= 38 && ttl <= 40, + "Expected TTL ~40s for iat=now-30s, got " + ttl); + } + + /** + * Bound-A coverage — verifies that when the DPoP JWT declares a short {@code exp}, + * the TTL tightens to the exp-anchored bound instead of the iat-anchored server policy. + * With iat = now, exp = iat + 20s: acceptanceCloseSec = min(now+70, iat+20+10) = iat+30 + * ⇒ TTL ≈ 30s. + */ + @Test + public void testDpopHeader_replayCheck_shortExp_tightensTtl() throws Exception { + Instant iat = Instant.now(); + String dpopJwt = createDpopJwtWithIatAndExp("POST", "http://localhost/oauth/par", + iat, iat.plusSeconds(20)); + + request.setRequestURI("/oauth/par"); + request.addHeader("DPoP", dpopJwt); + request.setMethod("POST"); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + when(cacheUtilService.checkAndMarkJti(anyString(), ttlCaptor.capture())).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + long ttl = ttlCaptor.getValue(); + // acceptanceCloseSec = min(now+70, iat+20+10) = iat+30 ⇒ TTL ≈ 30s (allow ±2s for jitter) + assertTrue(ttl >= 28 && ttl <= 30, + "Expected TTL ~30s when exp binds, got " + ttl); + } + + /** + * Builds a DPoP proof JWT with caller-controlled iat AND exp claims so we can + * exercise the bound-A path in {@code replayCheck} (where JWT self-exp tightens + * the TTL below the iat-anchored server policy). + */ + private String createDpopJwtWithIatAndExp(String httpMethod, String htuClaim, + Instant iat, Instant exp) throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(new JOSEObjectType("dpop+jwt")) + .jwk(ecJwk.toPublicJWK()) + .build(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .claim("htm", httpMethod) + .claim("htu", htuClaim) + .issueTime(Date.from(iat)) + .expirationTime(Date.from(exp)) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new ECDSASigner(ecJwk.toECPrivateKey())); + return signedJWT.serialize(); + } + } diff --git a/esignet-service/src/test/resources/application-test.properties b/esignet-service/src/test/resources/application-test.properties index 0958d20d3..7f0fe1709 100644 --- a/esignet-service/src/test/resources/application-test.properties +++ b/esignet-service/src/test/resources/application-test.properties @@ -127,6 +127,9 @@ mosip.esignet.supported.client.assertion.types={'urn:ietf:params:oauth:client-as mosip.esignet.client-assertion.unique.jti.required=true +mosip.esignet.client-assertion.jti.cache.max-ttl-seconds=3600 +mosip.esignet.client-assertion.jti.cache.skew-buffer-seconds=30 + mosip.esignet.dpop.clock-skew=10 mosip.esignet.dpop.iat.max-age-seconds=60 mosip.esignet.dpop.header-filter.paths-to-validate={'${server.servlet.path}/oauth/par', \ @@ -151,15 +154,15 @@ mosip.esignet.cache.store.individual-id=true mosip.esignet.cache.security.secretkey.reference-id=TRANSACTION_CACHE mosip.esignet.cache.security.algorithm-name=AES/ECB/PKCS5Padding -mosip.esignet.cache.names=clientdetails,preauth,authenticated,authcodegenerated,userinfo,linkcodegenerated,linked,linkedcode,linkedauth,vcissuance,consented,halted,apiratelimit,blocked,nonce,par,jti +mosip.esignet.cache.names=clientdetails,preauth,authenticated,authcodegenerated,userinfo,linkcodegenerated,linked,linkedcode,linkedauth,vcissuance,consented,halted,apiratelimit,blocked,nonce,par spring.cache.type=simple mosip.esignet.cache.key.hash.algorithm=SHA3-256 mosip.esignet.cache.size={'clientdetails' : 200, 'preauth': 200, 'authenticated': 200, 'authcodegenerated': 200, 'userinfo': 200, \ - 'linkcodegenerated' : 500, 'linked': 200 , 'linkedcode': 200, 'linkedauth' : 200 , 'consented' :200, 'halted' :200, 'apiratelimit' : 500, 'blocked': 500, 'jti':200 } + 'linkcodegenerated' : 500, 'linked': 200 , 'linkedcode': 200, 'linkedauth' : 200 , 'consented' :200, 'halted' :200, 'apiratelimit' : 500, 'blocked': 500 } mosip.esignet.cache.expire-in-seconds={'clientdetails' : 86400, 'preauth': 180, 'authenticated': 120, 'authcodegenerated': 60, \ 'userinfo': ${mosip.esignet.access-token.expire.seconds}, 'linkcodegenerated' : ${mosip.esignet.link-code-expire-in-secs}, \ - 'linked': 60 , 'linkedcode': ${mosip.esignet.link-code-expire-in-secs}, 'linkedauth' : 60, 'consented': 120, 'halted': 120, 'apiratelimit' : 180, 'blocked': 300, 'jti': 86400 } + 'linked': 60 , 'linkedcode': ${mosip.esignet.link-code-expire-in-secs}, 'linkedauth' : 60, 'consented': 120, 'halted': 120, 'apiratelimit' : 180, 'blocked': 300 } ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- mosipbox.public.url=http://localhost:8088 diff --git a/oidc-service-impl/src/main/java/io/mosip/esignet/services/CacheUtilService.java b/oidc-service-impl/src/main/java/io/mosip/esignet/services/CacheUtilService.java index 1180a2f04..79a0cfbd2 100644 --- a/oidc-service-impl/src/main/java/io/mosip/esignet/services/CacheUtilService.java +++ b/oidc-service-impl/src/main/java/io/mosip/esignet/services/CacheUtilService.java @@ -5,14 +5,12 @@ */ package io.mosip.esignet.services; -import io.mosip.esignet.core.constants.ErrorConstants; import io.mosip.esignet.core.dto.OIDCTransaction; import io.mosip.esignet.core.dto.LinkTransactionMetadata; import io.mosip.esignet.core.dto.ApiRateLimit; import io.mosip.esignet.core.dto.PushedAuthorizationRequest; import io.mosip.esignet.core.exception.DuplicateLinkCodeException; import io.mosip.esignet.core.constants.Constants; -import io.mosip.esignet.core.exception.EsignetException; import io.mosip.esignet.core.util.IdentityProviderUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -23,8 +21,10 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.Objects; import static io.mosip.esignet.core.util.IdentityProviderUtil.ALGO_SHA3_256; @@ -34,9 +34,20 @@ @Service public class CacheUtilService { + @Value("${spring.cache.type}") + private String cacheType; + + @Value("${mosip.esignet.cache.keyprefix:esignet}") + private String cacheKeyPrefix; + + private static final String JTI_KEY_FORMAT = "%s:jti::%s"; + @Autowired CacheManager cacheManager; + @Autowired(required = false) + private StringRedisTemplate stringRedisTemplate; + @Cacheable(value = Constants.PRE_AUTH_SESSION_CACHE, key = "#transactionId") public OIDCTransaction setTransaction(String transactionId, OIDCTransaction oidcTransaction) { return oidcTransaction; @@ -155,18 +166,32 @@ public PushedAuthorizationRequest savePAR(String requestUri, PushedAuthorization } /** - * Check if JTI is used. - * Returns true if already used (replay detected), false otherwise. + * Check if JTI is used. Redis-only: simple cache type is dev-only and skips + * Returns true if already used (replay detected), false otherwise or in simple mode. */ - public boolean checkAndMarkJti(String jti) { - Cache jtiCache = cacheManager.getCache(Constants.JTI_CACHE); - if(Objects.isNull(jtiCache)) throw new EsignetException(ErrorConstants.UNKNOWN_ERROR); - if (jtiCache.get(jti) != null) { - log.error("Replay detected for jti: {}", jti); + public boolean checkAndMarkJti(String jti, long ttlSeconds) { + if (!"redis".equalsIgnoreCase(cacheType)) { + return false; + } + + if (ttlSeconds <= 0) { + log.error("Non-positive ttl for jti {} — treating as replay", jti); return true; } - jtiCache.put(jti, true); - return false; + + try { + String key = JTI_KEY_FORMAT.formatted(cacheKeyPrefix, jti); + Boolean inserted = stringRedisTemplate.opsForValue() + .setIfAbsent(key, "1", Duration.ofSeconds(ttlSeconds)); + if (Boolean.FALSE.equals(inserted)) { + log.error("Replay detected for jti: {}", jti); + return true; + } + return false; + } catch (Exception e) { + log.error("Redis JTI check failed, rejecting jti", e); + return true; // fail-safe posture + } } public void updateNonceInCachedTransaction(String cacheKey, String newNonce, Long newExpiryTime, String cacheName) { diff --git a/oidc-service-impl/src/main/java/io/mosip/esignet/services/TokenServiceImpl.java b/oidc-service-impl/src/main/java/io/mosip/esignet/services/TokenServiceImpl.java index 9f6762d74..d6afee688 100644 --- a/oidc-service-impl/src/main/java/io/mosip/esignet/services/TokenServiceImpl.java +++ b/oidc-service-impl/src/main/java/io/mosip/esignet/services/TokenServiceImpl.java @@ -85,6 +85,12 @@ public class TokenServiceImpl implements TokenService { @Value("${mosip.esignet.client-assertion-jwt.leeway-seconds:5}") private int maxClockSkew; + @Value("${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds:3600}") + private long maxJtiCacheTtlSeconds; + + @Value("${mosip.esignet.client-assertion.jti.cache.skew-buffer-seconds:30}") + private long jtiCacheSkewBufferSeconds; + @Value("${mosip.esignet.dpop.nonce.expire.seconds:15}") private long dpopNonceExpirySeconds; @@ -194,10 +200,8 @@ public void verifyClientAssertionToken(String clientId, String jwk, String clien List validAudience = getValidAudienceForClientAssertion(audience); NimbusJwtDecoder jwtDecoder = getNimbusJwtDecoderFromJwk(jwk, clientId, validAudience, maxClockSkew, alg); jwtDecoder.decode(clientAssertion); - String jti = signedJWT.getJWTClaimsSet().getJWTID(); - if (uniqueJtiRequired && (jti == null || cacheUtilService.checkAndMarkJti(jti))) { - log.error("invalid jti {}", jti); - throw new EsignetException(ErrorConstants.INVALID_CLIENT); + if (uniqueJtiRequired) { + enforceJtiReplayProtection(signedJWT.getJWTClaimsSet(), clientId); } } catch (EsignetException e) { throw e; @@ -207,6 +211,39 @@ public void verifyClientAssertionToken(String clientId, String jwk, String clien } } + private void enforceJtiReplayProtection(JWTClaimsSet claims, String clientId) { + String jti = claims.getJWTID(); + Date expDate = claims.getExpirationTime(); + Date iatDate = claims.getIssueTime(); + + if (jti == null) { + log.error("Missing jti in client assertion for clientId {}", clientId); + throw new EsignetException(ErrorConstants.INVALID_CLIENT); + } + + long now = Instant.now().getEpochSecond(); + long exp = expDate.toInstant().getEpochSecond(); + long iat = iatDate.toInstant().getEpochSecond(); + + // Guarantee cache always outlives validity → close the (MAX_CAP, exp) replay window. + long declaredLifetime = exp - iat; + if (declaredLifetime <= 0 || declaredLifetime > maxJtiCacheTtlSeconds) { + log.error("Client assertion lifetime {}s outside (0,{}] for clientId {}", + declaredLifetime, maxJtiCacheTtlSeconds, clientId); + throw new EsignetException(ErrorConstants.INVALID_CLIENT); + } + + long jtiTtlSeconds = Math.min( + (exp - now) + Math.max(jtiCacheSkewBufferSeconds,maxClockSkew), + maxJtiCacheTtlSeconds + ); + + if (cacheUtilService.checkAndMarkJti(jti, jtiTtlSeconds)) { + log.error("Replay detected for jti {} (clientId {})", jti, clientId); + throw new EsignetException(ErrorConstants.INVALID_CLIENT); + } + } + /** * Gets the valid audience list for client assertion verification. * If the server profile has 'client_auth_assertion_audience' configured for the 'strict_audience_check' feature, diff --git a/oidc-service-impl/src/test/java/io/mosip/esignet/services/TokenServiceTest.java b/oidc-service-impl/src/test/java/io/mosip/esignet/services/TokenServiceTest.java index ed0894e4d..a224a3062 100644 --- a/oidc-service-impl/src/test/java/io/mosip/esignet/services/TokenServiceTest.java +++ b/oidc-service-impl/src/test/java/io/mosip/esignet/services/TokenServiceTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -76,6 +77,9 @@ public void setup() { ReflectionTestUtils.setField(tokenService, "maxClockSkew", 5); ReflectionTestUtils.setField(tokenService, "discoveryMap", mockDiscoveryMap); ReflectionTestUtils.setField(tokenService, "uniqueJtiRequired", true); + ReflectionTestUtils.setField(tokenService, "maxJtiCacheTtlSeconds", 3600L); + ReflectionTestUtils.setField(tokenService, "jtiCacheSkewBufferSeconds", 30L); + ReflectionTestUtils.setField(tokenService, "serverProfile", serverProfile); } @@ -314,14 +318,14 @@ public void verifyClientAssertion_withDuplicateJTI_thenFail() throws Exception { .subject("client-id") .issuer("client-id") .audience("audience") - .issueTime(new Date(123000L)) + .issueTime(new Date(System.currentTimeMillis() - 1000)) .expirationTime(new Date(System.currentTimeMillis() + 60000)) .jwtID(IdentityProviderUtil.createTransactionId(null)) .build(); SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.PS256).keyID(rsaKey.getKeyID()).build(), claimsSet); signedJWT.sign(signer); - Mockito.when(cacheUtilService.checkAndMarkJti(Mockito.anyString())).thenReturn(true); + Mockito.when(cacheUtilService.checkAndMarkJti(Mockito.anyString(),Mockito.anyLong())).thenReturn(true); EsignetException ex = Assertions.assertThrows(EsignetException.class, () -> tokenService.verifyClientAssertionToken("client-id", rsaKey.toJSONString(), signedJWT.serialize(), List.of("audience"))); Assertions.assertEquals(ErrorConstants.INVALID_CLIENT, ex.getErrorCode()); } @@ -338,7 +342,7 @@ public void verifyClientAssertion_withRSAPSSKey_thenPass() throws Exception { .subject("client-id") .issuer("client-id") .audience("audience") - .issueTime(new Date(123000L)) + .issueTime(new Date(System.currentTimeMillis() - 1000)) .expirationTime(new Date(System.currentTimeMillis() + 60000)) .jwtID(IdentityProviderUtil.createTransactionId(null)) .build(); @@ -349,6 +353,97 @@ public void verifyClientAssertion_withRSAPSSKey_thenPass() throws Exception { tokenService.verifyClientAssertionToken("client-id", rsaKey.toJSONString(), signedJWT.serialize(), List.of("audience")); } + @Test + public void verifyClientAssertion_withLifetimeExceedingCap_thenFail() throws Exception { + // Declared lifetime = 4000 s, well above the 3600 s cap + long now = System.currentTimeMillis(); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject("client-id") + .audience("audience") + .issuer("client-id") + .issueTime(new Date(now)) + .expirationTime(new Date(now + 4_000_000L)) // +4000 seconds in ms + .jwtID(IdentityProviderUtil.createTransactionId(null)) + .build(); + + JWSSigner signer = new RSASSASigner(RSA_JWK.toRSAPrivateKey()); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); + jwt.sign(signer); + + EsignetException ex = Assertions.assertThrows(EsignetException.class, () -> + tokenService.verifyClientAssertionToken( + "client-id", + RSA_JWK.toPublicJWK().toJSONString(), + jwt.serialize(), + List.of("audience"))); + + Assertions.assertEquals(ErrorConstants.INVALID_CLIENT, ex.getErrorCode()); + + // Critical: cache must NOT have been called — we reject before reaching it + Mockito.verify(cacheUtilService, Mockito.never()) + .checkAndMarkJti(Mockito.anyString(), Mockito.anyLong()); + } + + @Test + public void verifyClientAssertion_validLifetime_passesCorrectTtl() throws Exception { + long now = System.currentTimeMillis(); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject("client-id") + .audience("audience") + .issuer("client-id") + .issueTime(new Date(now - 1000)) // 1s ago + .expirationTime(new Date(now + 60_000)) // valid for next 60s + .jwtID(IdentityProviderUtil.createTransactionId(null)) + .build(); + + JWSSigner signer = new RSASSASigner(RSA_JWK.toRSAPrivateKey()); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); + jwt.sign(signer); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + Mockito.when(cacheUtilService.checkAndMarkJti(Mockito.anyString(), ttlCaptor.capture())) + .thenReturn(false); + + tokenService.verifyClientAssertionToken( + "client-id", + RSA_JWK.toPublicJWK().toJSONString(), + jwt.serialize(), + List.of("audience")); + + // Expected: remaining (~60) + skewBuffer (30) = ~90s, well under the 3600s cap. + // Allow a small window for test scheduling jitter. + long ttl = ttlCaptor.getValue(); + Assertions.assertTrue(ttl >= 85 && ttl <= 95, + "Expected TTL ~90s, got " + ttl); + } + + @Test + public void verifyClientAssertion_expiredBeyondSkew_thenFail() throws Exception { + long now = System.currentTimeMillis(); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject("client-id") + .audience("audience") + .issuer("client-id") + .issueTime(new Date(now - 90_000)) + .expirationTime(new Date(now - 60_000)) + .jwtID(IdentityProviderUtil.createTransactionId(null)) + .build(); + + JWSSigner signer = new RSASSASigner(RSA_JWK.toRSAPrivateKey()); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); + jwt.sign(signer); + + EsignetException ex = Assertions.assertThrows(EsignetException.class, () -> + tokenService.verifyClientAssertionToken( + "client-id", + RSA_JWK.toPublicJWK().toJSONString(), + jwt.serialize(), + List.of("audience"))); + + Assertions.assertEquals(ErrorConstants.INVALID_CLIENT, ex.getErrorCode()); + Mockito.verify(cacheUtilService, Mockito.never()).checkAndMarkJti(Mockito.anyString(),Mockito.anyLong()); + } + @Test public void verifyClientAssertion_withECKey_thenPass() throws Exception { ECKey ecKey = new ECKeyGenerator(Curve.P_256) @@ -361,7 +456,7 @@ public void verifyClientAssertion_withECKey_thenPass() throws Exception { .subject("client-id") .issuer("client-id") .audience("audience") - .issueTime(new Date(123000L)) + .issueTime(new Date(System.currentTimeMillis())) .expirationTime(new Date(System.currentTimeMillis() + 60000)) .jwtID(IdentityProviderUtil.createTransactionId(null)) .build();