From 59536bececbc885d1988cc9cf6faecdee0efdc01 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Sat, 9 Dec 2023 16:39:16 +0900 Subject: [PATCH] Client policies : executor for enforcing DPoP closes #25315 Signed-off-by: Takashi Norimatsu --- .../topics/clients/client-policies.adoc | 1 + .../executor/DPoPBindEnforcerExecutor.java | 161 +++++++ .../DPoPBindEnforcerExecutorFactory.java | 76 ++++ ...ecutor.ClientPolicyExecutorProviderFactory | 3 +- .../keycloak/testsuite/util/OAuthClient.java | 4 + .../keycloak/testsuite/oauth/DPoPTest.java | 392 +++++++++++++----- .../testsuite/util/ClientPoliciesUtil.java | 110 +++++ 7 files changed, 653 insertions(+), 94 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutorFactory.java diff --git a/docs/documentation/server_admin/topics/clients/client-policies.adoc b/docs/documentation/server_admin/topics/clients/client-policies.adoc index 21e2ae4be1..aec63a3841 100644 --- a/docs/documentation/server_admin/topics/clients/client-policies.adoc +++ b/docs/documentation/server_admin/topics/clients/client-policies.adoc @@ -127,6 +127,7 @@ One of several purposes for this executor is to realize the security requirement * Enforce checking if a client is the one to which an intent was issued in a use case where an intent is issued before starting an authorization code flow to get an access token like UK OpenBanking * Enforce prohibiting implicit and hybrid flow * Enforce checking if a PAR request includes necessary parameters included by an authorization request +* Enforce <<_dpop-bound-tokens,DPoP-binding tokens>> is used (available when `dpop` feature is enabled) [[_client_policy_profile]] === Profile diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java new file mode 100644 index 0000000000..4cc5522811 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java @@ -0,0 +1,161 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.clientpolicy.executor; + +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.Profile; +import org.keycloak.common.VerificationException; +import org.keycloak.common.Profile.Feature; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +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; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.ClientCRUDContext; +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 com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.ws.rs.core.MultivaluedMap; + +public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(DPoPBindEnforcerExecutor.class); + + private final KeycloakSession session; + private Configuration configuration; + + public DPoPBindEnforcerExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public void setupConfiguration(Configuration config) { + this.configuration = config; + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + @JsonProperty("auto-configure") + protected Boolean autoConfigure; + + public Boolean isAutoConfigure() { + return autoConfigure; + } + + public void setAutoConfigure(Boolean autoConfigure) { + this.autoConfigure = autoConfigure; + } + } + + @Override + public String getProviderId() { + return DPoPBindEnforcerExecutorFactory.PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + HttpRequest request = session.getContext().getHttpRequest(); + switch (context.getEvent()) { + case REGISTER: + case UPDATE: + ClientCRUDContext clientUpdateContext = (ClientCRUDContext)context; + autoConfigure(clientUpdateContext.getProposedClientRepresentation()); + validate(clientUpdateContext.getProposedClientRepresentation()); + break; + case TOKEN_REQUEST: + case TOKEN_REFRESH: + case USERINFO_REQUEST: + case BACKCHANNEL_TOKEN_REQUEST: + // Codes for processing these requests verifies DPoP. + // If this verification is done twice, DPoPReplayCheck fails. Therefore, the executor only checks existence of DPoP Proof + if (request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) == null) { + throw new ClientPolicyException(OAuthErrorException.INVALID_DPOP_PROOF, "DPoP proof is missing"); + } + break; + case TOKEN_REVOKE: + checkTokenRevoke((TokenRevokeContext) context, request); + break; + default: + return; + } + } + + private void autoConfigure(ClientRepresentation rep) { + if (configuration.isAutoConfigure()) { + OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).setUseDPoP(true); + } + } + + private void validate(ClientRepresentation rep) throws ClientPolicyException { + boolean useDPoPToken = OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).isUseDPoP(); + if (!useDPoPToken) { + throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Invalid client metadata: DPoP token in disabled"); + } + } + + private void checkTokenRevoke(TokenRevokeContext context, HttpRequest request) throws ClientPolicyException { + DPoP dPoP = retrieveAndVerifyDPoP(request); + + MultivaluedMap revokeParameters = context.getParams(); + String encodedRevokeToken = revokeParameters.getFirst("token"); + + AccessToken token = session.tokens().decode(encodedRevokeToken, AccessToken.class); + if (token == null) { + // this executor does not treat this error case. + return; + } + + validateBinding(token, dPoP); + } + + private DPoP retrieveAndVerifyDPoP(HttpRequest request) throws ClientPolicyException { + DPoP dPoP = null; + try { + dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate(); + } catch (VerificationException ex) { + logger.tracev("dpop verification error = {0}", ex.getMessage()); + throw new ClientPolicyException(OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage()); + } + return dPoP; + } + + private void validateBinding(AccessToken token, DPoP dPoP) throws ClientPolicyException { + try { + DPoPUtil.validateBinding(token, dPoP); + } catch (VerificationException ex) { + logger.tracev("dpop bind refresh token verification error = {0}", ex.getMessage()); + throw new ClientPolicyException(OAuthErrorException.INVALID_TOKEN, "DPoP proof and token binding verification failed"); + } + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutorFactory.java new file mode 100644 index 0000000000..db5a36949f --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutorFactory.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "dpop-bind-enforcer"; + + public static final String AUTO_CONFIGURE = "auto-configure"; + + 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); + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new DPoPBindEnforcerExecutor(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "It enforces a client to enable DPoP bind token setting."; + } + + @Override + public List getConfigProperties() { + return Collections.singletonList(AUTO_CONFIGURE_PROPERTY); + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Feature.DPOP); + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index 280afeee58..81c292527b 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -21,4 +21,5 @@ org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutorFactory org.keycloak.services.clientpolicy.executor.SuppressRefreshTokenRotationExecutorFactory org.keycloak.services.clientpolicy.executor.RegistrationAccessTokenRotationDisabledExecutorFactory org.keycloak.services.clientpolicy.executor.RejectImplicitGrantExecutorFactory -org.keycloak.services.clientpolicy.executor.SecureParContentsExecutorFactory \ No newline at end of file +org.keycloak.services.clientpolicy.executor.SecureParContentsExecutorFactory +org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 3a947c0510..53218e4984 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -970,6 +970,10 @@ public class OAuthClient { parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId)); } + if (dpopProof != null) { + post.addHeader("DPoP", dpopProof); + } + UrlEncodedFormEntity formEntity; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java index 787fc3a513..1a2f4de8d9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java @@ -17,25 +17,28 @@ package org.keycloak.testsuite.oauth; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.keycloak.jose.jwk.JWKUtil.toIntegerBytes; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRsaJwk; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof; import java.io.IOException; -import java.security.InvalidAlgorithmParameterException; -import java.security.Key; import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.SecureRandom; -import java.security.interfaces.ECPublicKey; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.ECGenParameterSpec; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.function.Consumer; @@ -47,44 +50,54 @@ import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.client.registration.Auth; +import org.keycloak.client.registration.ClientRegistration; +import org.keycloak.client.registration.ClientRegistrationException; import org.keycloak.common.Profile; -import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.Time; import org.keycloak.crypto.Algorithm; -import org.keycloak.crypto.AsymmetricSignatureSignerContext; -import org.keycloak.crypto.KeyType; -import org.keycloak.crypto.KeyUse; -import org.keycloak.crypto.KeyWrapper; -import org.keycloak.crypto.ECDSASignatureSignerContext; -import org.keycloak.crypto.SignatureException; -import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.events.EventType; import org.keycloak.jose.jwk.ECPublicJWK; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.RSAPublicJWK; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; -import org.keycloak.representations.dpop.DPoP; +import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; +import org.keycloak.representations.idm.ClientInitialAccessPresentation; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.condition.ClientAccessTypeConditionFactory; +import org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory; import org.keycloak.services.resources.Cors; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.Matchers; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse; import org.keycloak.testsuite.util.ServerURLs; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder; import org.keycloak.util.JWKSUtils; import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.HttpMethod; @EnableFeature(value = Profile.Feature.DPOP, skipRestart = true) @@ -105,6 +118,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { private JWSHeader jwsRsaHeader; private JWSHeader jwsEcHeader; + private ClientRegistration reg; + @Rule public AssertEvents events = new AssertEvents(this); @@ -490,6 +505,170 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET); } + @Test + public void testDPoPBindEnforcerExecutor() throws Exception { + setInitialAccessTokenForDynamicClientRegistration(); + + KeyPair ecKeyPair = generateEcdsaKey("secp256r1"); + KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048); + JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic()); + JWK jwkEc = createEcJwk(ecKeyPair.getPublic()); + + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile("MyProfile", "Le Premier Profil") + .addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.FALSE)) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy("MyPolicy", "La Primera Plitica", Boolean.TRUE) + .addCondition(ClientAccessTypeConditionFactory.PROVIDER_ID, + createClientAccessTypeConditionConfig(Arrays.asList(ClientAccessTypeConditionFactory.TYPE_PUBLIC))) + .addProfile("MyProfile") + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register by Admin REST API - fail + try { + createClientByAdmin(generateSuffixedName("App-by-Admin"), (ClientRepresentation rep) -> { + rep.setPublicClient(Boolean.TRUE); + }); + fail(); + } catch (ClientPolicyException e) { + assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getMessage()); + } + + // register by Admin REST API - success + String cAppAdminAlphaId = createClientByAdmin(generateSuffixedName("App-by-Admin-Alpha"), (ClientRepresentation clientRep) -> { + clientRep.setPublicClient(Boolean.TRUE); + clientRep.setAttributes(new HashMap<>()); + clientRep.getAttributes().put(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, Boolean.TRUE.toString()); + }); + + // update by Admin REST API - fail + try { + updateClientByAdmin(cAppAdminAlphaId, (ClientRepresentation clientRep) -> { + clientRep.getAttributes().put(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, Boolean.FALSE.toString()); + }); + } catch (ClientPolicyException cpe) { + assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, cpe.getError()); + } + ClientRepresentation cRep = getClientByAdmin(cAppAdminAlphaId); + assertEquals(Boolean.TRUE.toString(), cRep.getAttributes().get(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS)); + String appAlphaClientId = cRep.getClientId(); + + json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile("MyProfile", "Le Premier Profil") + .addExecutor(DPoPBindEnforcerExecutorFactory.PROVIDER_ID, createDPoPBindEnforcerExecutorConfig(Boolean.TRUE)) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register by Dynamic Client Registration - success + String cAppDynamicBetaId = createClientDynamically(generateSuffixedName("App-in-Dynamic-Beta"), (OIDCClientRepresentation clientRep) -> { + clientRep.setTokenEndpointAuthMethod("none"); + clientRep.setDpopBoundAccessTokens(Boolean.FALSE); + }); + events.expect(EventType.CLIENT_REGISTER).client(cAppDynamicBetaId).user(is(emptyOrNullString())).assertEvent(); + OIDCClientRepresentation oidcClientRep = getClientDynamically(cAppDynamicBetaId); + assertEquals(Boolean.TRUE, oidcClientRep.getDpopBoundAccessTokens()); + + // token request without a DPoP proof - fail + oauth.clientId(appAlphaClientId); + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null); + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError()); + assertEquals("DPoP proof is missing", response.getErrorDescription()); + oauth.idTokenHint(response.getIdToken()).openLogout(); + + // token request with a valid DPoP proof - success + // EC key for client alpha + oauth.doSilentLogin(); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + JWSHeader jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc); + String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()); + oauth.dpopProof(dpopProofEcEncoded); + response = oauth.doAccessTokenRequest(code, null); + + assertEquals(Status.OK.getStatusCode(), response.getStatusCode()); + String encodedAccessToken = response.getAccessToken(); + String encodedRefreshToken = response.getRefreshToken(); + String encodedIdToken = response.getIdToken(); + AccessToken accessToken = oauth.verifyToken(encodedAccessToken); + jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv()); + jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX()); + jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY()); + String jkt = JWKSUtils.computeThumbprint(jwkEc); + assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); + RefreshToken refreshToken = oauth.parseRefreshToken(encodedRefreshToken); + assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint()); + + // userinfo request without a DPoP proof - fail + oauth.dpopProof(null); + OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken); + assertEquals(401, userInfoResponse.getStatusCode()); + + // userinfo request with a valid DPoP proof - success + jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc); + dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()); + oauth.dpopProof(dpopProofEcEncoded); + userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken); + assertEquals(200, userInfoResponse.getStatusCode()); + assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername()); + + // token refresh without a DPoP Proof - fail + oauth.dpopProof(null); + response = oauth.doRefreshTokenRequest(encodedRefreshToken, null); + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError()); + assertEquals("DPoP proof is missing", response.getErrorDescription()); + + // token refresh with a valid DPoP Proof - success + dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()); + oauth.dpopProof(dpopProofEcEncoded); + response = oauth.doRefreshTokenRequest(encodedRefreshToken, null); + assertEquals(Status.OK.getStatusCode(), response.getStatusCode()); + encodedAccessToken = response.getAccessToken(); + encodedRefreshToken = response.getRefreshToken(); + accessToken = oauth.verifyToken(encodedAccessToken); + jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv()); + jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX()); + jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY()); + jkt = JWKSUtils.computeThumbprint(jwkEc); + assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); + refreshToken = oauth.parseRefreshToken(encodedRefreshToken); + assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint()); + + // revoke token without a valid DPoP proof - fail + JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa); + String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getTokenRevocationUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate()); + oauth.dpopProof(dpopProofRsaEncoded); + CloseableHttpResponse closableHttpResponse = oauth.doTokenRevoke(encodedAccessToken, "access_token", null); + assertThat(closableHttpResponse, Matchers.statusCodeIsHC(Status.BAD_REQUEST)); + + // revoke token with a valid DPoP proof - success + dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getTokenRevocationUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()); + oauth.dpopProof(dpopProofEcEncoded); + closableHttpResponse = oauth.doTokenRevoke(encodedAccessToken, "access_token", null); + assertThat(closableHttpResponse, Matchers.statusCodeIsHC(Status.OK)); + String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(appAlphaClientId, null, encodedAccessToken); + TokenMetadataRepresentation tokenMetadataRepresentation = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertFalse(tokenMetadataRepresentation.isActive()); + + updatePolicies("{}"); + updateProfiles("{}"); + + oauth.idTokenHint(encodedIdToken).openLogout(); + } + private OAuthClient.AccessTokenResponse getDPoPBindAccessToken(KeyPair rsaKeyPair) throws Exception { oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID); oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); @@ -534,79 +713,6 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { assertEquals(errorDescription, response.getErrorDescription()); } - private JWK createRsaJwk(Key publicKey) { - RSAPublicKey rsaKey = (RSAPublicKey) publicKey; - - RSAPublicJWK k = new RSAPublicJWK(); - k.setKeyType(KeyType.RSA); - k.setModulus(Base64Url.encode(toIntegerBytes(rsaKey.getModulus()))); - k.setPublicExponent(Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent()))); - - return k; - } - - private JWK createEcJwk(Key publicKey) { - ECPublicKey ecKey = (ECPublicKey) publicKey; - - int fieldSize = ecKey.getParams().getCurve().getField().getFieldSize(); - ECPublicJWK k = new ECPublicJWK(); - k.setKeyType(KeyType.EC); - k.setCrv("P-" + fieldSize); - k.setX(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineX(), fieldSize))); - k.setY(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineY(), fieldSize))); - - return k; - } - - private static KeyPair generateEcdsaKey(String ecDomainParamName) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); - SecureRandom randomGen = SecureRandom.getInstance("SHA1PRNG"); - ECGenParameterSpec ecSpec = new ECGenParameterSpec(ecDomainParamName); - keyGen.initialize(ecSpec, randomGen); - KeyPair keyPair = keyGen.generateKeyPair(); - return keyPair; - } - - private static SignatureSignerContext createSignatureSignerContext(KeyWrapper keyWrapper) { - switch (keyWrapper.getType()) { - case KeyType.RSA: - return new AsymmetricSignatureSignerContext(keyWrapper); - case KeyType.EC: - return new ECDSASignatureSignerContext(keyWrapper); - default: - throw new IllegalArgumentException("No signer provider for key algorithm type " + keyWrapper.getType()); - } - } - - private static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, String algorithm, JWSHeader jwsHeader, PrivateKey privateKey) throws IOException { - - String dpopProofHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader)); - - DPoP dpop = new DPoP(); - dpop.id(jti); - dpop.setHttpMethod(htm); - dpop.setHttpUri(htu); - dpop.iat(iat); - - String dpopProofPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(dpop)); - - try { - KeyWrapper keyWrapper = new KeyWrapper(); - keyWrapper.setKid(jwsHeader.getKeyId()); - keyWrapper.setAlgorithm(algorithm); - keyWrapper.setPrivateKey(privateKey); - keyWrapper.setType(privateKey.getAlgorithm()); - keyWrapper.setUse(KeyUse.SIG); - SignatureSignerContext sigCtx = createSignatureSignerContext(keyWrapper); - - String data = dpopProofHeaderEncoded + "." + dpopProofPayloadEncoded; - byte[] signatureByteArray = sigCtx.sign(data.getBytes()); - return data + "." + Base64Url.encode(signatureByteArray); - } catch (SignatureException e) { - throw new RuntimeException(e); - } - } - private void changeDPoPBound(String clientId, boolean isEnabled) { ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId); ClientRepresentation clientRep = clientResource.toRepresentation(); @@ -614,7 +720,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { clientResource.update(clientRep); } - private String createClientByAdmin(String clientName, Consumer op) { + private String createClientByAdmin(String clientName, Consumer op) throws ClientPolicyException { ClientRepresentation clientRep = new ClientRepresentation(); clientRep.setClientId(clientName); clientRep.setName(clientName); @@ -626,6 +732,16 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setPostLogoutRedirectUris(Collections.singletonList("+")); op.accept(clientRep); Response resp = adminClient.realm(REALM_NAME).clients().create(clientRep); + if (resp.getStatus() == Response.Status.BAD_REQUEST.getStatusCode()) { + String respBody = resp.readEntity(String.class); + Map responseJson = null; + try { + responseJson = JsonSerialization.readValue(respBody, Map.class); + } catch (IOException e) { + fail(); + } + throw new ClientPolicyException(responseJson.get(OAuth2Constants.ERROR), responseJson.get(OAuth2Constants.ERROR_DESCRIPTION)); + } resp.close(); assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus()); // registered components will be removed automatically when a test method finishes regardless of its success or failure. @@ -633,4 +749,94 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { testContext.getOrCreateCleanup(REALM_NAME).addClientUuid(cId); return cId; } + + private void updateProfiles(String json) throws ClientPolicyException { + try { + ClientProfilesRepresentation clientProfiles = JsonSerialization.readValue(json, ClientProfilesRepresentation.class); + adminClient.realm(REALM_NAME).clientPoliciesProfilesResource().updateProfiles(clientProfiles); + } catch (BadRequestException e) { + throw new ClientPolicyException("update profiles failed", e.getResponse().getStatusInfo().toString()); + } catch (Exception e) { + throw new ClientPolicyException("update profiles failed", e.getMessage()); + } + } + + private void updatePolicies(String json) throws ClientPolicyException { + try { + ClientPoliciesRepresentation clientPolicies = json==null ? null : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class); + adminClient.realm(REALM_NAME).clientPoliciesPoliciesResource().updatePolicies(clientPolicies); + } catch (BadRequestException e) { + throw new ClientPolicyException("update policies failed", e.getResponse().getStatusInfo().toString()); + } catch (IOException e) { + throw new ClientPolicyException("update policies failed", e.getMessage()); + } + } + + private String generateSuffixedName(String name) { + return name + "-" + UUID.randomUUID().toString().subSequence(0, 7); + } + + private void updateClientByAdmin(String cId, Consumer op) throws ClientPolicyException { + ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(cId); + ClientRepresentation clientRep = clientResource.toRepresentation(); + op.accept(clientRep); + try { + clientResource.update(clientRep); + } catch (BadRequestException bre) { + processClientPolicyExceptionByAdmin(bre); + } + } + + private void processClientPolicyExceptionByAdmin(BadRequestException bre) throws ClientPolicyException { + Response resp = bre.getResponse(); + if (resp.getStatus() != Response.Status.BAD_REQUEST.getStatusCode()) { + resp.close(); + return; + } + + String respBody = resp.readEntity(String.class); + Map responseJson = null; + try { + responseJson = JsonSerialization.readValue(respBody, Map.class); + } catch (IOException e) { + fail(); + } + throw new ClientPolicyException(responseJson.get(OAuth2Constants.ERROR), responseJson.get(OAuth2Constants.ERROR_DESCRIPTION)); + } + + private ClientRepresentation getClientByAdmin(String cId) throws ClientPolicyException { + ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(cId); + try { + return clientResource.toRepresentation(); + } catch (BadRequestException bre) { + processClientPolicyExceptionByAdmin(bre); + } + return null; + } + + private String createClientDynamically(String clientName, Consumer op) throws ClientRegistrationException { + OIDCClientRepresentation clientRep = new OIDCClientRepresentation(); + clientRep.setClientName(clientName); + clientRep.setClientUri(ServerURLs.getAuthServerContextRoot()); + clientRep.setRedirectUris(Collections.singletonList(ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth")); + op.accept(clientRep); + OIDCClientRepresentation response = reg.oidc().create(clientRep); + reg.auth(Auth.token(response)); + // registered components will be removed automatically when a test method finishes regardless of its success or failure. + String clientId = response.getClientId(); + testContext.getOrCreateCleanup(REALM_NAME).addClientUuid(clientId); + return clientId; + } + + private void setInitialAccessTokenForDynamicClientRegistration() { + // get initial access token for Dynamic Client Registration with authentication + reg = ClientRegistration.create().url(suiteContext.getAuthServerInfo().getContextRoot() + "/auth", REALM_NAME).build(); + ClientInitialAccessPresentation token = adminClient.realm(REALM_NAME).clientInitialAccess().create(new ClientInitialAccessCreatePresentation(0, 10)); + reg.auth(Auth.token(token)); + } + + private OIDCClientRepresentation getClientDynamically(String clientId) throws ClientRegistrationException { + return reg.oidc().get(clientId); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java index 509f061c89..1df75b3e17 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java @@ -21,7 +21,22 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.util.Base64Url; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.KeyType; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureException; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.ECPublicJWK; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.RSAPublicJWK; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutor; +import org.keycloak.representations.dpop.DPoP; import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; @@ -30,6 +45,7 @@ import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.services.clientpolicy.ClientPolicyEvent; import org.keycloak.services.clientpolicy.condition.ClientAccessTypeCondition; import org.keycloak.services.clientpolicy.condition.ClientRolesCondition; @@ -39,6 +55,7 @@ import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceGroupsCon import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceHostsCondition; import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceRolesCondition; import org.keycloak.services.clientpolicy.executor.ConsentRequiredExecutor; +import org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutor; import org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutor; import org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforcerExecutor; import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutor; @@ -50,14 +67,27 @@ import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmForSignedJwtExecutor; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExceptionCondition; import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutor; import org.keycloak.util.JsonSerialization; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.List; import static org.junit.Assert.fail; +import static org.keycloak.jose.jwk.JWKUtil.toIntegerBytes; public final class ClientPoliciesUtil { @@ -235,6 +265,12 @@ public final class ClientPoliciesUtil { return config; } + public static DPoPBindEnforcerExecutor.Configuration createDPoPBindEnforcerExecutorConfig(Boolean autoConfigure) { + DPoPBindEnforcerExecutor.Configuration config = new DPoPBindEnforcerExecutor.Configuration(); + config.setAutoConfigure(autoConfigure); + return config; + } + public static class ClientPoliciesBuilder { private final ClientPoliciesRepresentation policiesRep; @@ -381,4 +417,78 @@ public final class ClientPoliciesUtil { config.setRoles(roles); return config; } + + // DPoP + public static JWK createRsaJwk(Key publicKey) { + RSAPublicKey rsaKey = (RSAPublicKey) publicKey; + + RSAPublicJWK k = new RSAPublicJWK(); + k.setKeyType(KeyType.RSA); + k.setModulus(Base64Url.encode(toIntegerBytes(rsaKey.getModulus()))); + k.setPublicExponent(Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent()))); + + return k; + } + + public static JWK createEcJwk(Key publicKey) { + ECPublicKey ecKey = (ECPublicKey) publicKey; + + int fieldSize = ecKey.getParams().getCurve().getField().getFieldSize(); + ECPublicJWK k = new ECPublicJWK(); + k.setKeyType(KeyType.EC); + k.setCrv("P-" + fieldSize); + k.setX(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineX(), fieldSize))); + k.setY(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineY(), fieldSize))); + + return k; + } + + public static KeyPair generateEcdsaKey(String ecDomainParamName) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); + SecureRandom randomGen = SecureRandom.getInstance("SHA1PRNG"); + ECGenParameterSpec ecSpec = new ECGenParameterSpec(ecDomainParamName); + keyGen.initialize(ecSpec, randomGen); + KeyPair keyPair = keyGen.generateKeyPair(); + return keyPair; + } + + public static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, String algorithm, JWSHeader jwsHeader, PrivateKey privateKey) throws IOException { + + String dpopProofHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader)); + + DPoP dpop = new DPoP(); + dpop.id(jti); + dpop.setHttpMethod(htm); + dpop.setHttpUri(htu); + dpop.iat(iat); + + String dpopProofPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(dpop)); + + try { + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setKid(jwsHeader.getKeyId()); + keyWrapper.setAlgorithm(algorithm); + keyWrapper.setPrivateKey(privateKey); + keyWrapper.setType(privateKey.getAlgorithm()); + keyWrapper.setUse(KeyUse.SIG); + SignatureSignerContext sigCtx = createSignatureSignerContext(keyWrapper); + + String data = dpopProofHeaderEncoded + "." + dpopProofPayloadEncoded; + byte[] signatureByteArray = sigCtx.sign(data.getBytes()); + return data + "." + Base64Url.encode(signatureByteArray); + } catch (SignatureException e) { + throw new RuntimeException(e); + } + } + + private static SignatureSignerContext createSignatureSignerContext(KeyWrapper keyWrapper) { + switch (keyWrapper.getType()) { + case KeyType.RSA: + return new AsymmetricSignatureSignerContext(keyWrapper); + case KeyType.EC: + return new ECDSASignatureSignerContext(keyWrapper); + default: + throw new IllegalArgumentException("No signer provider for key algorithm type " + keyWrapper.getType()); + } + } }