Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,18 @@ private void replayCheck(JWTClaimsSet claims) {
log.error("Missing jti claim");
throw new InvalidDpopHeaderException();
}
if (cacheUtilService.checkAndMarkJti(jti)) {
Date iatDate = claims.getIssueTime();
Date expDate = claims.getExpirationTime();
long iat = iatDate.toInstant().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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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' : 86400 , \
'kbispec': ${mosip.esignet.kbispec.ttl.seconds}}

## ------------------------------------------ Discovery openid-configuration -------------------------------------------
Expand Down Expand Up @@ -458,6 +456,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 on lines +460 to +463

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.

This is not required

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.

these porperties are used by TokenServiceImpl.enforceJtiReplayProtection on every client-assertion validation


# 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,130 @@ 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 + 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 {
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, 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 + 10 - now ≈ 40s.
*/
@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();
// 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<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();
// 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();
}

}
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 All @@ -151,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': 86400 }
'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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,8 +21,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 +34,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 @@ -155,18 +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) {
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);
public boolean checkAndMarkJti(String jti, long ttlSeconds) {
Comment thread
KashiwalHarsh marked this conversation as resolved.
if (!"redis".equalsIgnoreCase(cacheType)) {
return false;
}

if (ttlSeconds <= 0) {
log.error("Non-positive ttl for jti {} — treating as replay", jti);
return true;
}
jtiCache.put(jti, true);
return false;

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

public void updateNonceInCachedTransaction(String cacheKey, String newNonce, Long newExpiryTime, String cacheName) {
Expand Down
Loading
Loading