Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated
if (cacheUtilService.checkAndMarkJti(jti, dpopJtiTtlSeconds)) {
log.error("Replay detected for jti: {}", jti);
throw new InvalidDpopHeaderException();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 , \
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated
'kbispec': ${mosip.esignet.kbispec.ttl.seconds}}

## ------------------------------------------ Discovery openid-configuration -------------------------------------------
Expand Down Expand Up @@ -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
Comment thread
KashiwalHarsh marked this conversation as resolved.

# 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' } }
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', \
Expand Down Expand Up @@ -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 }
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated

## ------------------------------------------ Discovery openid-configuration -------------------------------------------
mosipbox.public.url=http://localhost:8088
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
}
Comment thread
KashiwalHarsh marked this conversation as resolved.

// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Comment thread
sacrana0 marked this conversation as resolved.
}
} catch (EsignetException e) {
throw e;
Expand All @@ -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) {
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated
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);
}
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated

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<String> audience, int maxClockSkew, String alg) throws Exception {

JWK parsedJwk = JWK.parse(jwkJson);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand All @@ -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();
Expand All @@ -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<Long> 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)
Expand All @@ -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))
Comment thread
KashiwalHarsh marked this conversation as resolved.
Outdated
.expirationTime(new Date(System.currentTimeMillis() + 60000))
.jwtID(IdentityProviderUtil.createTransactionId(null))
.build();
Expand Down
Loading