Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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

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.

We should consider minimum of exp+jti_skew and iat+maxDPOPIatAgeSeconds+jti_skew

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 exp is an optional cliam and may not be present always, but still taking exp into consideration if it is present then according to that updated is in newer commit

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,10 @@
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Objects;

import static io.mosip.esignet.core.util.IdentityProviderUtil.ALGO_SHA3_256;
Expand All @@ -34,9 +36,20 @@
@Service
public class CacheUtilService {

@Value("${spring.cache.type}")
private String cacheType;

@Value("${mosip.esignet.cache.keyprefix:esignet}")
private String cacheKeyPrefix;

private static final String JTI_KEY_FORMAT = "%s:jti::%s";

@Autowired
CacheManager cacheManager;

@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;

@Cacheable(value = Constants.PRE_AUTH_SESSION_CACHE, key = "#transactionId")
public OIDCTransaction setTransaction(String transactionId, OIDCTransaction oidcTransaction) {
return oidcTransaction;
Expand Down Expand Up @@ -158,9 +171,33 @@ 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{
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
}
}

// 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,39 @@ 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);
}

long now = Instant.now().getEpochSecond();
long exp = expDate.toInstant().getEpochSecond();
long iat = iatDate.toInstant().getEpochSecond();

// Guarantee cache always outlives validity → close the (MAX_CAP, exp) replay window.
long declaredLifetime = exp - iat;
if (declaredLifetime <= 0 || declaredLifetime > maxJtiCacheTtlSeconds) {
log.error("Client assertion lifetime {}s outside (0,{}] for clientId {}",
declaredLifetime, maxJtiCacheTtlSeconds, clientId);
throw new EsignetException(ErrorConstants.INVALID_CLIENT);
}

long jtiTtlSeconds = Math.min(
(exp - now) + Math.max(jtiCacheSkewBufferSeconds,maxClockSkew),
maxJtiCacheTtlSeconds
);

if (cacheUtilService.checkAndMarkJti(jti, jtiTtlSeconds)) {
log.error("Replay detected for jti {} (clientId {})", jti, clientId);
throw new EsignetException(ErrorConstants.INVALID_CLIENT);
}
}

/**
* Gets the valid audience list for client assertion verification.
* If the server profile has 'client_auth_assertion_audience' configured for the 'strict_audience_check' feature,
Expand Down
Loading
Loading