From d03a08fad739866d409cc621ad86fa63a51aa53d Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 27 Apr 2026 15:47:43 +0200
Subject: [PATCH] [OID4VCI-FAPI2] Add DPoP as alternative holder of key proof
closes #48502
Signed-off-by: Thomas Diesler
---
.../clientpolicy/executor/HolderOfKeyEnforcerExecutor.java | 7 +++++--
.../test/java/org/keycloak/testsuite/client/CIBATest.java | 2 +-
.../test/java/org/keycloak/testsuite/client/FAPI1Test.java | 2 +-
3 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/HolderOfKeyEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/HolderOfKeyEnforcerExecutor.java
index ca36bcc949f4..f6d8b8369c04 100644
--- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/HolderOfKeyEnforcerExecutor.java
+++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/HolderOfKeyEnforcerExecutor.java
@@ -28,6 +28,7 @@
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
+import org.keycloak.representations.dpop.DPoP;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
@@ -37,6 +38,7 @@
import org.keycloak.services.clientpolicy.context.TokenRefreshContext;
import org.keycloak.services.clientpolicy.context.TokenRevokeContext;
import org.keycloak.services.clientpolicy.context.UserInfoRequestContext;
+import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -91,9 +93,10 @@ public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyExcep
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
+ DPoP dpop = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
AccessToken.Confirmation certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
- if (certConf == null) {
- throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding");
+ if (dpop == null && certConf == null) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Holder of Key Proof required (e.g. mTLS, DPoP)");
}
break;
case TOKEN_REFRESH:
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java
index 87634f48c2e2..8e72ebbde64a 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java
@@ -2267,7 +2267,7 @@ public void testHolderOfKeyEnforceExecutor() throws Exception {
AccessTokenResponse tokenRes = oauth.ciba().doBackchannelAuthenticationTokenRequest(response.getAuthReqId());
assertThat(tokenRes.getStatusCode(), is(equalTo(400)));
assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.INVALID_GRANT)));
- assertThat(tokenRes.getErrorDescription(), is(equalTo("Client Certification missing for MTLS HoK Token Binding")));
+ assertThat(tokenRes.getErrorDescription(), is(equalTo("Holder of Key Proof required (e.g. mTLS, DPoP)")));
events.expect(EventType.AUTHREQID_TO_TOKEN_ERROR).clearDetails().user((String)null).client(TEST_CLIENT_NAME).error(OAuthErrorException.INVALID_REQUEST).assertEvent();
oauth.httpClient().reset();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPI1Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPI1Test.java
index 6e2e8d389456..c8f5819e156f 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPI1Test.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPI1Test.java
@@ -466,7 +466,7 @@ public void testFAPIAdvancedLoginWithPrivateKeyJWT() throws Exception {
String signedJwt = createSignedRequestToken("foo", privateKey, publicKey, org.keycloak.crypto.Algorithm.PS256);
AccessTokenResponse tokenResponse = doAccessTokenRequestWithClientSignedJWT(code, signedJwt, DefaultHttpClient::new);
Assertions.assertEquals(OAuthErrorException.INVALID_GRANT,tokenResponse.getError());
- Assertions.assertEquals("Client Certification missing for MTLS HoK Token Binding", tokenResponse.getErrorDescription());
+ Assertions.assertEquals("Holder of Key Proof required (e.g. mTLS, DPoP)", tokenResponse.getErrorDescription());
// Login with private-key-jwt client authentication and MTLS added to HttpClient. TokenRequest should be successful now
oauth.loginForm().requestUri(requestUri).open();