DPoP replay check should take clockSkew into account

Closes #43505

Signed-off-by: rmartinc <rmartinc@redhat.com>
(cherry picked from commit 62f68b2f19)
This commit is contained in:
rmartinc
2025-10-21 17:06:58 +02:00
committed by Marek Posolda
parent 59b20d1d63
commit d415cc1385
2 changed files with 41 additions and 3 deletions

View File

@@ -210,7 +210,7 @@ public class DPoPUtil {
DPoPClaimsCheck.INSTANCE,
new DPoPHTTPCheck(uri, method),
new DPoPIsActiveCheck(session, lifetime, clockSkew),
new DPoPReplayCheck(session, lifetime));
new DPoPReplayCheck(session, lifetime + clockSkew));
if (accessToken != null) {
verifier.withChecks(new DPoPAccessTokenHashCheck(accessToken));

View File

@@ -259,6 +259,36 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
successTokenProceduresWithDPoP(dpopProofEcEncoded, jktEc, true, true);
}
@Test
public void testDPoPByPublicClientClockSkew() throws Exception {
getTestingClient().testing().setTestingInfinispanTimeService();
try {
sendAuthorizationRequestWithDPoPJkt(null);
// get a DPoP proof 10 seconds in the future
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST, oauth.getEndpoints().getToken(),
(long) (Time.currentTime() + 10), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate(), null);
AccessTokenResponse response = successTokenProceduresWithDPoP(dpopProofEcEncoded, jktEc, true, true, false);
setTimeOffset(25); // 25 <= 10+10+15, proof not expired because clockSkew, detected by replay check
response = oauth.refreshRequest(response.getRefreshToken()).dpopProof(dpopProofEcEncoded).send();
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("DPoP proof has already been used", response.getErrorDescription());
setTimeOffset(36); // 36 > 10+10+15, proof expired definitely
response = oauth.refreshRequest(response.getRefreshToken()).dpopProof(dpopProofEcEncoded).send();
assertEquals(400, response.getStatusCode());
assertEquals(response.getError(), OAuthErrorException.INVALID_REQUEST);
assertEquals("DPoP proof is not active", response.getErrorDescription());
oauth.logoutForm().idTokenHint(response.getIdToken()).open();
} finally {
getTestingClient().testing().revertTestingInfinispanTimeService();
}
}
@Test
public void testDPoPByPublicClientTokenRefreshWithoutDPoPProof() throws Exception {
// use pre-computed EC key
@@ -1205,7 +1235,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
oauth.loginForm().dpopJkt(dpopJkt).doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
}
private void successTokenProceduresWithDPoP(String dpopProofEncoded, String jkt, boolean accessTokenBound, boolean refreshTokenBound) throws Exception {
private AccessTokenResponse successTokenProceduresWithDPoP(String dpopProofEncoded, String jkt, boolean accessTokenBound, boolean refreshTokenBound) throws Exception {
return successTokenProceduresWithDPoP(dpopProofEncoded, jkt, accessTokenBound, refreshTokenBound, true);
}
private AccessTokenResponse successTokenProceduresWithDPoP(String dpopProofEncoded, String jkt, boolean accessTokenBound,
boolean refreshTokenBound, boolean performLogout) throws Exception {
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.accessTokenRequest(code).dpopProof(dpopProofEncoded).send();
assertEquals(accessTokenBound ? TokenUtil.TOKEN_TYPE_DPOP : TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
@@ -1256,8 +1291,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
}
// logout
if (performLogout) {
oauth.logoutForm().idTokenHint(response.getIdToken()).open();
}
return response;
}
private void failureRefreshTokenProceduresWithoutDPoP(String dpopProofEncoded, String jkt) throws Exception {
String code = oauth.parseLoginResponse().getCode();