Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' : ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} , \
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.
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': ${mosip.esignet.client-assertion.jti.cache.max-ttl-seconds} }

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

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;

import static io.mosip.esignet.core.util.IdentityProviderUtil.ALGO_SHA3_256;
Expand All @@ -34,9 +41,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
private RedisConnectionFactory redisConnectionFactory;

@Cacheable(value = Constants.PRE_AUTH_SESSION_CACHE, key = "#transactionId")
public OIDCTransaction setTransaction(String transactionId, OIDCTransaction oidcTransaction) {
return oidcTransaction;
Expand Down Expand Up @@ -158,9 +176,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) {
Comment thread
KashiwalHarsh marked this conversation as resolved.
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.
Outdated

// 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 @@ -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;

Expand Down Expand Up @@ -194,10 +200,8 @@ public void verifyClientAssertionToken(String clientId, String jwk, String clien
List<String> 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);
Comment thread
sacrana0 marked this conversation as resolved.
}
} catch (EsignetException e) {
throw e;
Expand All @@ -207,6 +211,48 @@ public void verifyClientAssertionToken(String clientId, String jwk, String clien
}
}

private void enforceJtiReplayProtection(JWTClaimsSet claims, String clientId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is this logic different from the logic written in dpopValidationFilter? can we not have a common method both can use?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anushasunkada the two methods look similar but are for two different JWT. The client_assertion flow treats the exp claim as the source of truth for expiry and limits the token’s lifetime based on the declared expiration time. The DPoP proof flow relies on the iat timestamp for freshness rather than an exp claim, so its effective validity is determined by the accepted iat window and allowed clock skew.

They also raise different exception types (INVALID_CLIENT vs InvalidDpopHeaderException) with different HTTP semantics.

The genuine shared step - "set jti in cache with TTL, reject if it already exists" - is already extracted into CacheUtilService.checkAndMarkJti(jti, ttl)

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);
}
}

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

Expand Down Expand Up @@ -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());
}
Expand All @@ -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();
Expand All @@ -349,6 +353,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 @@ -361,7 +457,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