From fad8813b0f5ce9e91677703748a8b784427c7061 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Fri, 19 Jun 2026 14:16:46 +0530 Subject: [PATCH 01/11] added JWT expire time as TTL Signed-off-by: Harsh Kashiwal --- .../esignet/advice/DpopValidationFilter.java | 4 +- .../resources/application-default.properties | 8 +- .../advice/DpopValidationFilterTest.java | 4 +- .../resources/application-test.properties | 5 +- .../esignet/services/CacheUtilService.java | 39 ++++++- .../esignet/services/TokenServiceImpl.java | 54 ++++++++- .../esignet/services/TokenServiceTest.java | 104 +++++++++++++++++- 7 files changed, 203 insertions(+), 15 deletions(-) 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..79e432a53 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,9 @@ private void replayCheck(JWTClaimsSet claims) { log.error("Missing jti claim"); throw new InvalidDpopHeaderException(); } - if (cacheUtilService.checkAndMarkJti(jti)) { + + long dpopJtiTtlSeconds = (long) maxDPOPIatAgeSeconds + maxClockSkewSeconds; + 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..1434ba1c4 100644 --- a/esignet-service/src/main/resources/application-default.properties +++ b/esignet-service/src/main/resources/application-default.properties @@ -242,7 +242,7 @@ 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 , \ +'jti' : 3600 , \ 'kbispec': ${mosip.esignet.kbispec.ttl.seconds}} ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- @@ -458,6 +458,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..d8cf41184 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 @@ -61,7 +61,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 +148,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); diff --git a/esignet-service/src/test/resources/application-test.properties b/esignet-service/src/test/resources/application-test.properties index 0958d20d3..023aa017b 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', \ @@ -159,7 +162,7 @@ mosip.esignet.cache.size={'clientdetails' : 200, 'preauth': 200, 'authenticated' 'linkcodegenerated' : 500, 'linked': 200 , 'linkedcode': 200, 'linkedauth' : 200 , 'consented' :200, 'halted' :200, 'apiratelimit' : 500, 'blocked': 500, 'jti':200 } 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, 'jti': 3600 } ## ------------------------------------------ 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 10c47c7d5..20c31a24d 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 @@ -25,7 +25,9 @@ import org.springframework.cache.annotation.Caching; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; @@ -55,6 +57,11 @@ 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; @@ -205,9 +212,37 @@ public PushedAuthorizationRequest savePAR(String requestUri, PushedAuthorization * Check if JTI is used. * Returns true if already used (replay detected), false otherwise. */ - public boolean checkAndMarkJti(String jti) { + public boolean checkAndMarkJti(String jti, long ttlSeconds) { + if (ttlSeconds <= 0) { + log.error("Non-positive ttl for jti {} — treating as replay", jti); + return true; + } + + if ("redis".equalsIgnoreCase(cacheType)) { + try (RedisConnection connection = redisConnectionFactory.getConnection()) { + byte[] key = JTI_KEY_FORMAT.formatted(cacheKeyPrefix, jti) + .getBytes(StandardCharsets.UTF_8); + Boolean inserted = connection.stringCommands().set( + key, + new byte[]{1}, + Expiration.seconds(ttlSeconds), + RedisStringCommands.SetOption.SET_IF_ABSENT); + 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, mirrors checkNonce posture + } + } + + // Non-prod path (spring.cache.type=simple) Cache jtiCache = cacheManager.getCache(Constants.JTI_CACHE); - if(Objects.isNull(jtiCache)) throw new EsignetException(ErrorConstants.UNKNOWN_ERROR); + if (Objects.isNull(jtiCache)) { + throw new EsignetException(ErrorConstants.UNKNOWN_ERROR); + } if (jtiCache.get(jti) != null) { log.error("Replay detected for jti: {}", jti); return true; 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 2ddf51df7..1b1b8f89e 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 @@ -84,6 +84,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; @@ -190,10 +196,8 @@ public void verifyClientAssertionToken(String clientId, String jwk, String clien NimbusJwtDecoder jwtDecoder = getNimbusJwtDecoderFromJwk(jwk, clientId, audience, 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; @@ -203,6 +207,48 @@ 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); + } + if (expDate == null || iatDate == null) { + log.error("Client assertion missing exp/iat 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) + jtiCacheSkewBufferSeconds, + maxJtiCacheTtlSeconds + ); + + if (jtiTtlSeconds <= 0) { + log.error("Client assertion already expired for clientId {}", clientId); + throw new EsignetException(ErrorConstants.INVALID_CLIENT); + } + + if (cacheUtilService.checkAndMarkJti(jti, jtiTtlSeconds)) { + log.error("Replay detected for jti {} (clientId {})", jti, clientId); + throw new EsignetException(ErrorConstants.INVALID_CLIENT); + } + } + private NimbusJwtDecoder getNimbusJwtDecoderFromJwk(String jwkJson, String clientId, List audience, int maxClockSkew, String alg) throws Exception { JWK parsedJwk = JWK.parse(jwkJson); 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 8b438a4fd..e3dbe72a5 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 @@ -26,6 +26,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; @@ -72,6 +73,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); + } @Test @@ -309,14 +313,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()); } @@ -333,7 +337,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(); @@ -344,6 +348,98 @@ 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(); + // exp = now - 60s ⇒ remainingValidity = -60 + // jtiTtl = min(-60 + 30, 3600) = -30 → rejected + 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()); + } + @Test public void verifyClientAssertion_withECKey_thenPass() throws Exception { ECKey ecKey = new ECKeyGenerator(Curve.P_256) @@ -356,7 +452,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() - 1000)) .expirationTime(new Date(System.currentTimeMillis() + 60000)) .jwtID(IdentityProviderUtil.createTransactionId(null)) .build(); From 332ff33e0b3a740e482fa869001a12c82439c954 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Tue, 23 Jun 2026 12:54:40 +0530 Subject: [PATCH 02/11] added property as placeholder Signed-off-by: Harsh Kashiwal --- .../src/main/resources/application-default.properties | 2 +- esignet-service/src/test/resources/application-test.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esignet-service/src/main/resources/application-default.properties b/esignet-service/src/main/resources/application-default.properties index 1434ba1c4..f5a80b992 100644 --- a/esignet-service/src/main/resources/application-default.properties +++ b/esignet-service/src/main/resources/application-default.properties @@ -242,7 +242,7 @@ mosip.esignet.cache.expire-in-seconds={'clientdetails' : 86400, \ 'halted' : ${mosip.esignet.signup.halt.expire-seconds}, \ 'nonce' : 86400, \ 'par' : ${mosip.esignet.par.expire-seconds},\ -'jti' : 3600 , \ +'jti' : ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} , \ 'kbispec': ${mosip.esignet.kbispec.ttl.seconds}} ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- diff --git a/esignet-service/src/test/resources/application-test.properties b/esignet-service/src/test/resources/application-test.properties index 023aa017b..ccee12dd7 100644 --- a/esignet-service/src/test/resources/application-test.properties +++ b/esignet-service/src/test/resources/application-test.properties @@ -162,7 +162,7 @@ mosip.esignet.cache.size={'clientdetails' : 200, 'preauth': 200, 'authenticated' 'linkcodegenerated' : 500, 'linked': 200 , 'linkedcode': 200, 'linkedauth' : 200 , 'consented' :200, 'halted' :200, 'apiratelimit' : 500, 'blocked': 500, 'jti':200 } 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': 3600 } + 'linked': 60 , 'linkedcode': ${mosip.esignet.link-code-expire-in-secs}, 'linkedauth' : 60, 'consented': 120, 'halted': 120, 'apiratelimit' : 180, 'blocked': 300, 'jti': ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} } ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- mosipbox.public.url=http://localhost:8088 From 802243c3c1a0a998e184c62b9187796f00869a2b Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Tue, 23 Jun 2026 17:51:43 +0530 Subject: [PATCH 03/11] resolved coderabbit review comments Signed-off-by: Harsh Kashiwal --- .../main/java/io/mosip/esignet/services/TokenServiceImpl.java | 2 +- .../test/java/io/mosip/esignet/services/TokenServiceTest.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 4213158ea..5171abce8 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 @@ -238,7 +238,7 @@ private void enforceJtiReplayProtection(JWTClaimsSet claims, String clientId) { } long jtiTtlSeconds = Math.min( - (exp - now) + jtiCacheSkewBufferSeconds, + (exp - now) + Math.max(jtiCacheSkewBufferSeconds,maxClockSkew), maxJtiCacheTtlSeconds ); 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 fc802cea9..8485895fc 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 @@ -443,6 +443,7 @@ public void verifyClientAssertion_expiredBeyondSkew_thenFail() throws Exception List.of("audience"))); Assertions.assertEquals(ErrorConstants.INVALID_CLIENT, ex.getErrorCode()); + Mockito.verify(cacheUtilService, Mockito.never()).checkAndMarkJti(Mockito.anyString(),Mockito.anyLong()); } @Test From 446b98f95880e428526f60d848a9bac98f74ab3a Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Tue, 23 Jun 2026 18:21:27 +0530 Subject: [PATCH 04/11] resolved another coderabbit review comment Signed-off-by: Harsh Kashiwal --- .../test/java/io/mosip/esignet/services/TokenServiceTest.java | 2 -- 1 file changed, 2 deletions(-) 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 8485895fc..d03be4862 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 @@ -420,8 +420,6 @@ public void verifyClientAssertion_validLifetime_passesCorrectTtl() throws Except @Test public void verifyClientAssertion_expiredBeyondSkew_thenFail() throws Exception { long now = System.currentTimeMillis(); - // exp = now - 60s ⇒ remainingValidity = -60 - // jtiTtl = min(-60 + 30, 3600) = -30 → rejected JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() .subject("client-id") .audience("audience") From 79b7ee383e8725e461d3480f6e205e6094297826 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Mon, 29 Jun 2026 18:22:34 +0530 Subject: [PATCH 05/11] updated jti store logic for dpop jti and added testcases Signed-off-by: Harsh Kashiwal --- .../esignet/advice/DpopValidationFilter.java | 6 +- .../advice/DpopValidationFilterTest.java | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) 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 79e432a53..fc717db20 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,8 +315,10 @@ private void replayCheck(JWTClaimsSet claims) { log.error("Missing jti claim"); throw new InvalidDpopHeaderException(); } - - long dpopJtiTtlSeconds = (long) maxDPOPIatAgeSeconds + maxClockSkewSeconds; + Date iatDate = claims.getIssueTime(); + long iat = iatDate.toInstant().getEpochSecond(); + long expirySec = iat + maxDPOPIatAgeSeconds + 2L * maxClockSkewSeconds; + long dpopJtiTtlSeconds = Math.max(1L, expirySec - Instant.now().getEpochSecond()); if (cacheUtilService.checkAndMarkJti(jti, dpopJtiTtlSeconds)) { log.error("Replay detected for jti: {}", jti); throw new InvalidDpopHeaderException(); 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 d8cf41184..a534e12c2 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; @@ -402,4 +403,77 @@ 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 + 2 * maxClockSkewSeconds} when iat = now. + * With the configured defaults (60 + 2*10), the expected TTL is ~80s. + */ + @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 ⇒ expirySec = now + 60 + 20 ⇒ TTL ≈ 80s (allow ±2s for scheduling jitter) + assertTrue(ttl >= 78 && ttl <= 80, + "Expected TTL ~80s (maxDPOPIatAgeSeconds + 2*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 + 20 - now ≈ 50s. + */ + @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(); + // expirySec = (now-30) + 60 + 20 = now + 50 ⇒ TTL ≈ 50s + assertTrue(ttl >= 48 && ttl <= 50, + "Expected TTL ~50s for iat=now-30s, got " + ttl); + } + } From c06269da4e3892cb4141c88ecb00a67b31dfe812 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Mon, 29 Jun 2026 18:56:55 +0530 Subject: [PATCH 06/11] added jti caching login for dpop and testcases Signed-off-by: Harsh Kashiwal --- .../esignet/advice/DpopValidationFilter.java | 6 +- .../advice/DpopValidationFilterTest.java | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) 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 79e432a53..fc717db20 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,8 +315,10 @@ private void replayCheck(JWTClaimsSet claims) { log.error("Missing jti claim"); throw new InvalidDpopHeaderException(); } - - long dpopJtiTtlSeconds = (long) maxDPOPIatAgeSeconds + maxClockSkewSeconds; + Date iatDate = claims.getIssueTime(); + long iat = iatDate.toInstant().getEpochSecond(); + long expirySec = iat + maxDPOPIatAgeSeconds + 2L * maxClockSkewSeconds; + long dpopJtiTtlSeconds = Math.max(1L, expirySec - Instant.now().getEpochSecond()); if (cacheUtilService.checkAndMarkJti(jti, dpopJtiTtlSeconds)) { log.error("Replay detected for jti: {}", jti); throw new InvalidDpopHeaderException(); 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 d8cf41184..a534e12c2 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; @@ -402,4 +403,77 @@ 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 + 2 * maxClockSkewSeconds} when iat = now. + * With the configured defaults (60 + 2*10), the expected TTL is ~80s. + */ + @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 ⇒ expirySec = now + 60 + 20 ⇒ TTL ≈ 80s (allow ±2s for scheduling jitter) + assertTrue(ttl >= 78 && ttl <= 80, + "Expected TTL ~80s (maxDPOPIatAgeSeconds + 2*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 + 20 - now ≈ 50s. + */ + @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(); + // expirySec = (now-30) + 60 + 20 = now + 50 ⇒ TTL ≈ 50s + assertTrue(ttl >= 48 && ttl <= 50, + "Expected TTL ~50s for iat=now-30s, got " + ttl); + } + } From f725cfc93063c25a27906604bf226d8dd77d3a28 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Wed, 1 Jul 2026 13:01:06 +0530 Subject: [PATCH 07/11] removed dead validation Signed-off-by: Harsh Kashiwal --- .../main/java/io/mosip/esignet/services/TokenServiceImpl.java | 4 ---- 1 file changed, 4 deletions(-) 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 5171abce8..3cd3a2388 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 @@ -220,10 +220,6 @@ private void enforceJtiReplayProtection(JWTClaimsSet claims, String clientId) { log.error("Missing jti in client assertion for clientId {}", clientId); throw new EsignetException(ErrorConstants.INVALID_CLIENT); } - if (expDate == null || iatDate == null) { - log.error("Client assertion missing exp/iat for clientId {}", clientId); - throw new EsignetException(ErrorConstants.INVALID_CLIENT); - } long now = Instant.now().getEpochSecond(); long exp = expDate.toInstant().getEpochSecond(); From 4b19bc962e7d387dd8bd69f73aecf2ae2add1fb7 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Wed, 1 Jul 2026 14:45:31 +0530 Subject: [PATCH 08/11] updated positive flow testcase Signed-off-by: Harsh Kashiwal --- .../test/java/io/mosip/esignet/services/TokenServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d03be4862..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 @@ -456,7 +456,7 @@ public void verifyClientAssertion_withECKey_thenPass() throws Exception { .subject("client-id") .issuer("client-id") .audience("audience") - .issueTime(new Date(System.currentTimeMillis() - 1000)) + .issueTime(new Date(System.currentTimeMillis())) .expirationTime(new Date(System.currentTimeMillis() + 60000)) .jwtID(IdentityProviderUtil.createTransactionId(null)) .build(); From 9f4d2b241f23b35075f9491e620cf953d9332620 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Fri, 3 Jul 2026 16:14:14 +0530 Subject: [PATCH 09/11] resolved review comments, used redisTemplate Signed-off-by: Harsh Kashiwal --- .../esignet/services/CacheUtilService.java | 23 ++++++++----------- .../esignet/services/TokenServiceImpl.java | 5 ---- 2 files changed, 9 insertions(+), 19 deletions(-) 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 563212bad..df7d82bd5 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 @@ -23,15 +23,11 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; -import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStringCommands; -import org.springframework.data.redis.connection.ReturnType; -import org.springframework.data.redis.core.types.Expiration; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.util.List; +import java.time.Duration; import java.util.Objects; import static io.mosip.esignet.core.util.IdentityProviderUtil.ALGO_SHA3_256; @@ -55,6 +51,9 @@ public class CacheUtilService { @Autowired private RedisConnectionFactory redisConnectionFactory; + @Autowired(required = false) + private StringRedisTemplate stringRedisTemplate; + @Cacheable(value = Constants.PRE_AUTH_SESSION_CACHE, key = "#transactionId") public OIDCTransaction setTransaction(String transactionId, OIDCTransaction oidcTransaction) { return oidcTransaction; @@ -183,14 +182,10 @@ public boolean checkAndMarkJti(String jti, long ttlSeconds) { } if ("redis".equalsIgnoreCase(cacheType)) { - try (RedisConnection connection = redisConnectionFactory.getConnection()) { - byte[] key = JTI_KEY_FORMAT.formatted(cacheKeyPrefix, jti) - .getBytes(StandardCharsets.UTF_8); - Boolean inserted = connection.stringCommands().set( - key, - new byte[]{1}, - Expiration.seconds(ttlSeconds), - RedisStringCommands.SetOption.SET_IF_ABSENT); + 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; 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 3cd3a2388..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 @@ -238,11 +238,6 @@ private void enforceJtiReplayProtection(JWTClaimsSet claims, String clientId) { maxJtiCacheTtlSeconds ); - if (jtiTtlSeconds <= 0) { - log.error("Client assertion already expired for clientId {}", clientId); - throw new EsignetException(ErrorConstants.INVALID_CLIENT); - } - if (cacheUtilService.checkAndMarkJti(jti, jtiTtlSeconds)) { log.error("Replay detected for jti {} (clientId {})", jti, clientId); throw new EsignetException(ErrorConstants.INVALID_CLIENT); From 1f8da68e7ef4ac1d11670c069988037b2176d656 Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Fri, 3 Jul 2026 22:17:42 +0530 Subject: [PATCH 10/11] removed unused variables Signed-off-by: Harsh Kashiwal --- .../main/java/io/mosip/esignet/services/CacheUtilService.java | 4 ---- 1 file changed, 4 deletions(-) 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 df7d82bd5..3f8d39d33 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 @@ -23,7 +23,6 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; -import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -48,9 +47,6 @@ public class CacheUtilService { @Autowired CacheManager cacheManager; - @Autowired - private RedisConnectionFactory redisConnectionFactory; - @Autowired(required = false) private StringRedisTemplate stringRedisTemplate; From 45b9b27b861713cc59dcc7e5a85ae1a2a2077bbc Mon Sep 17 00:00:00 2001 From: Harsh Kashiwal Date: Tue, 14 Jul 2026 12:31:48 +0530 Subject: [PATCH 11/11] resolved review comments considered exp claim Signed-off-by: Harsh Kashiwal --- .../esignet/core/constants/Constants.java | 1 - .../esignet/advice/DpopValidationFilter.java | 11 ++- .../resources/application-default.properties | 4 +- .../advice/DpopValidationFilterTest.java | 71 ++++++++++++++++--- .../resources/application-test.properties | 6 +- .../esignet/services/CacheUtilService.java | 46 +++++------- 6 files changed, 92 insertions(+), 47 deletions(-) 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 fc717db20..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 @@ -316,9 +316,16 @@ private void replayCheck(JWTClaimsSet claims) { throw new InvalidDpopHeaderException(); } Date iatDate = claims.getIssueTime(); + Date expDate = claims.getExpirationTime(); long iat = iatDate.toInstant().getEpochSecond(); - long expirySec = iat + maxDPOPIatAgeSeconds + 2L * maxClockSkewSeconds; - long dpopJtiTtlSeconds = Math.max(1L, expirySec - Instant.now().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 f5a80b992..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' : ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} , \ 'kbispec': ${mosip.esignet.kbispec.ttl.seconds}} ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- 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 a534e12c2..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 @@ -427,8 +427,8 @@ private String createDpopJwtWithCustomIat(String httpMethod, String htuClaim, In /** * Happy path — verifies that the TTL handed to the JTI cache equals - * {@code maxDPOPIatAgeSeconds + 2 * maxClockSkewSeconds} when iat = now. - * With the configured defaults (60 + 2*10), the expected TTL is ~80s. + * {@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 { @@ -445,15 +445,15 @@ public void testDpopHeader_replayCheck_capturesIatAnchoredTtl() throws Exception verify(filterChain).doFilter(request, response); long ttl = ttlCaptor.getValue(); - // iat ≈ now ⇒ expirySec = now + 60 + 20 ⇒ TTL ≈ 80s (allow ±2s for scheduling jitter) - assertTrue(ttl >= 78 && ttl <= 80, - "Expected TTL ~80s (maxDPOPIatAgeSeconds + 2*maxClockSkewSeconds), got " + ttl); + // 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 + 20 - now ≈ 50s. + * Expected TTL = (iat - 30) + 60 + 10 - now ≈ 40s. */ @Test public void testDpopHeader_replayCheck_olderIat_shortensTtl() throws Exception { @@ -471,9 +471,62 @@ public void testDpopHeader_replayCheck_olderIat_shortensTtl() throws Exception { verify(filterChain).doFilter(request, response); long ttl = ttlCaptor.getValue(); - // expirySec = (now-30) + 60 + 20 = now + 50 ⇒ TTL ≈ 50s - assertTrue(ttl >= 48 && ttl <= 50, - "Expected TTL ~50s for iat=now-30s, got " + ttl); + // 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 ccee12dd7..7f0fe1709 100644 --- a/esignet-service/src/test/resources/application-test.properties +++ b/esignet-service/src/test/resources/application-test.properties @@ -154,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': ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} } + '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 3f8d39d33..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; @@ -168,42 +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, long ttlSeconds) { + if (!"redis".equalsIgnoreCase(cacheType)) { + return false; + } + if (ttlSeconds <= 0) { log.error("Non-positive ttl for jti {} — treating as replay", jti); return true; } - if ("redis".equalsIgnoreCase(cacheType)) { - 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, mirrors checkNonce posture + 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 } - - // Non-prod path (spring.cache.type=simple) - 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); - return true; - } - jtiCache.put(jti, true); - return false; } public void updateNonceInCachedTransaction(String cacheKey, String newNonce, Long newExpiryTime, String cacheName) {