Possibility to enforce authorization code binding to DPoP

closes #42740

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2025-09-18 18:25:13 +02:00 committed by Marek Posolda
parent 47f85631f3
commit 45fa5edbbb
5 changed files with 82 additions and 11 deletions

View File

@ -32,6 +32,7 @@ import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationReprese
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext;
import org.keycloak.services.clientpolicy.context.TokenRevokeContext;
import org.keycloak.services.util.DPoPUtil;
@ -65,6 +66,9 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
@JsonProperty("auto-configure")
protected Boolean autoConfigure;
@JsonProperty("enforce-authorization-code-binding-to-dpop")
protected Boolean enforceAuthorizationCodeBindingToDpop;
public Boolean isAutoConfigure() {
return autoConfigure;
}
@ -72,6 +76,14 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
public void setAutoConfigure(Boolean autoConfigure) {
this.autoConfigure = autoConfigure;
}
public Boolean getEnforceAuthorizationCodeBindingToDpop() {
return enforceAuthorizationCodeBindingToDpop;
}
public void setEnforceAuthorizationCodeBindingToDpop(Boolean enforceAuthorizationCodeBindingToDpop) {
this.enforceAuthorizationCodeBindingToDpop = enforceAuthorizationCodeBindingToDpop;
}
}
@Override
@ -95,6 +107,10 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
autoConfigure(clientUpdateContext.getProposedClientRepresentation());
validate(clientUpdateContext.getProposedClientRepresentation());
break;
case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authzRequestContext = (AuthorizationRequestContext) context;
checkOnAuthorizationRequest(authzRequestContext);
break;
case TOKEN_REQUEST:
case TOKEN_REFRESH:
case USERINFO_REQUEST:
@ -140,6 +156,13 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
validateBinding(token, dPoP);
}
private void checkOnAuthorizationRequest(AuthorizationRequestContext authzRequestContext) throws ClientPolicyException {
if (configuration.getEnforceAuthorizationCodeBindingToDpop() != null && configuration.getEnforceAuthorizationCodeBindingToDpop() && (authzRequestContext.getAuthorizationEndpointRequest().getDpopJkt() == null)) {
// Checking only the presence of the parameter here. As long as parameter is present, it is automatically saved to authenticationSession and checked later in token request
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter: dpop_jkt");
}
}
private DPoP retrieveAndVerifyDPoP(HttpRequest request) throws ClientPolicyException {
DPoP dPoP = null;
try {

View File

@ -17,15 +17,11 @@
package org.keycloak.services.clientpolicy.executor;
import java.util.Collections;
import java.util.List;
import org.keycloak.Config.Scope;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory {
@ -34,9 +30,14 @@ public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorPro
public static final String AUTO_CONFIGURE = "auto-configure";
public static final String ENFORCE_AUTHORIZATION_CODE_BINDING_TO_DPOP = "enforce-authorization-code-binding-to-dpop";
private static final ProviderConfigProperty AUTO_CONFIGURE_PROPERTY = new ProviderConfigProperty(
AUTO_CONFIGURE, "Auto-configure", "If On, then the during client creation or update, the configuration of the client will be auto-configured to use DPoP bind token", ProviderConfigProperty.BOOLEAN_TYPE, false);
private static final ProviderConfigProperty ENFORCE_AUTHORIZATION_CODE_BINDING_TO_DPOP_KEY = new ProviderConfigProperty(
ENFORCE_AUTHORIZATION_CODE_BINDING_TO_DPOP, "Enforce Authorization Code binding to DPoP key", "If On, then there is enforced authorization code binding to DPoP key. This means that parameter 'dpop_jkt' will be required in the OIDC/OAuth2 authentication requests and will be verified during token request if it matches DPoP proof. When this is false, it is still possible to use 'dpop_jkt' parameter, but it will not be required", ProviderConfigProperty.BOOLEAN_TYPE, false);
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new DPoPBindEnforcerExecutor(session);
@ -66,6 +67,6 @@ public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorPro
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(AUTO_CONFIGURE_PROPERTY);
return List.of(AUTO_CONFIGURE_PROPERTY, ENFORCE_AUTHORIZATION_CODE_BINDING_TO_DPOP_KEY);
}
}

View File

@ -368,7 +368,8 @@
{
"executor": "dpop-bind-enforcer",
"configuration": {
"auto-configure": "true"
"auto-configure": "true",
"enforce-authorization-code-binding-to-dpop": "false"
}
},
{
@ -454,7 +455,8 @@
{
"executor": "dpop-bind-enforcer",
"configuration": {
"auto-configure": "true"
"auto-configure": "true",
"enforce-authorization-code-binding-to-dpop": "false"
}
}
]
@ -532,7 +534,8 @@
{
"executor": "dpop-bind-enforcer",
"configuration": {
"auto-configure": "true"
"auto-configure": "true",
"enforce-authorization-code-binding-to-dpop": "false"
}
},
{

View File

@ -42,6 +42,7 @@ import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.jose.jwk.ECPublicJWK;
import org.keycloak.jose.jwk.JWK;
@ -81,6 +82,7 @@ import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.IntrospectionResponse;
import org.keycloak.testsuite.util.oauth.UserInfoResponse;
import org.keycloak.testsuite.util.ServerURLs;
@ -644,7 +646,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile("MyProfile", "Le Premier Profil")
.addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.FALSE))
.addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.FALSE, Boolean.FALSE))
.toRepresentation()
).toString();
updateProfiles(json);
@ -686,7 +688,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile("MyProfile", "Le Premier Profil")
.addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.TRUE))
.addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.TRUE, Boolean.FALSE))
.toRepresentation()
).toString();
updateProfiles(json);
@ -783,6 +785,47 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
oauth.logoutForm().idTokenHint(encodedIdToken).open();
}
@Test
public void testDPoPBindEnforcerExecutorWithEnforcedAuthzCodeBinding() throws Exception {
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile("MyProfile", "Le Premier Profil")
.addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.FALSE, Boolean.TRUE))
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy("MyPolicy", "La Primera Plitica", Boolean.TRUE)
.addCondition(ClientAccessTypeConditionFactory.PROVIDER_ID,
createClientAccessTypeConditionConfig(List.of(ClientAccessTypeConditionFactory.TYPE_PUBLIC)))
.addProfile("MyProfile")
.toRepresentation()
).toString();
updatePolicies(json);
// Login without dpop_jkt - failure
oauth.client(TEST_PUBLIC_CLIENT_ID);
oauth.openLoginForm();
AuthorizationEndpointResponse response = oauth.parseLoginResponse();
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Missing parameter: dpop_jkt", response.getErrorDescription());
events.expectClientPolicyError(EventType.LOGIN_ERROR, OAuthErrorException.INVALID_REQUEST,
Details.CLIENT_POLICY_ERROR, OAuthErrorException.INVALID_REQUEST,
"Missing parameter: dpop_jkt").client(oauth.getClientId()).user((String) null)
.assertEvent();
// Login with dpop_jkt -- should be OK
long clockSkew = 10;
sendAuthorizationRequestWithDPoPJkt(jktEc);
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST, oauth.getEndpoints().getToken(), (long) (Time.currentTime() + clockSkew), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate(), null);
successTokenProceduresWithDPoP(dpopProofEcEncoded, jktEc);
updatePolicies("{}");
updateProfiles("{}");
}
@Test
public void testDPoPProofWithClientCredentialsGrant() throws Exception {
modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> {

View File

@ -267,9 +267,10 @@ public final class ClientPoliciesUtil {
return config;
}
public static DPoPBindEnforcerExecutor.Configuration createDPoPBindEnforcerExecutorConfig(Boolean autoConfigure) {
public static DPoPBindEnforcerExecutor.Configuration createDPoPBindEnforcerExecutorConfig(Boolean autoConfigure, Boolean enforceAuthorizationCodeBindingToDpop) {
DPoPBindEnforcerExecutor.Configuration config = new DPoPBindEnforcerExecutor.Configuration();
config.setAutoConfigure(autoConfigure);
config.setEnforceAuthorizationCodeBindingToDpop(enforceAuthorizationCodeBindingToDpop);
return config;
}