Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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,11 @@ private void replayCheck(JWTClaimsSet claims) {
log.error("Missing jti claim");
throw new InvalidDpopHeaderException();
}
if (cacheUtilService.checkAndMarkJti(jti)) {
Date iatDate = claims.getIssueTime();
long iat = iatDate.toInstant().getEpochSecond();
long expirySec = iat + maxDPOPIatAgeSeconds + 2L * maxClockSkewSeconds;
long dpopJtiTtlSeconds = Math.max(1L, expirySec - Instant.now().getEpochSecond());
Comment thread
sacrana0 marked this conversation as resolved.
Outdated
Comment thread
anushasunkada 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 @@ -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;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<Long> 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<Long> 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);
}

}
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) {
Comment thread
anushasunkada marked this conversation as resolved.
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) + Math.max(jtiCacheSkewBufferSeconds,maxClockSkew),
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
Loading
Loading