Client policies : executor for enforcing DPoP

closes #25315

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2023-12-09 16:39:16 +09:00 committed by Marek Posolda
parent 0ca73829d0
commit 59536becec
7 changed files with 653 additions and 94 deletions

View file

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

View file

@ -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<DPoPBindEnforcerExecutor.Configuration> {
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<Configuration> 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<String, String> 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");
}
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(AUTO_CONFIGURE_PROPERTY);
}
@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Feature.DPOP);
}
}

View file

@ -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
org.keycloak.services.clientpolicy.executor.SecureParContentsExecutorFactory
org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory

View file

@ -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");

View file

@ -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<ClientRepresentation> op) {
private String createClientByAdmin(String clientName, Consumer<ClientRepresentation> 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<String, String> 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<ClientRepresentation> 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<String, String> 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<OIDCClientRepresentation> 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);
}
}

View file

@ -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());
}
}
}