KEYCLOAK-14200 Client Policy - Executor : Enforce Holder-of-Key Token

Co-authored-by: Hryhorii Hevorkian <hhe@adorsys.com.ua>
This commit is contained in:
Takashi Norimatsu 2021-01-01 11:32:54 +09:00 committed by Marek Posolda
parent ab1dba5fa6
commit 5f445ec18e
9 changed files with 423 additions and 7 deletions

View file

@ -17,10 +17,16 @@
package org.keycloak.services.clientpolicy;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.keycloak.OAuthErrorException;
public class ClientPolicyException extends Exception {
private String error;
private String error = OAuthErrorException.INVALID_REQUEST;
private String errorDetail;
private Status errorStatus = Response.Status.BAD_REQUEST;
public ClientPolicyException(String error, String errorDetail) {
super(error);
@ -28,12 +34,26 @@ public class ClientPolicyException extends Exception {
setErrorDetail(errorDetail);
}
public ClientPolicyException(String error, String errorDetail, Status errorStatus) {
super(error);
setError(error);
setErrorDetail(errorDetail);
setErrorStatus(errorStatus);
}
public ClientPolicyException(String error, String errorDetail, Throwable throwable) {
super(throwable);
setError(error);
setErrorDetail(errorDetail);
}
public ClientPolicyException(String error, String errorDetail, Status errorStatus, Throwable throwable) {
super(throwable);
setError(error);
setErrorDetail(errorDetail);
setErrorStatus(errorStatus);
}
public String getError() {
return error;
}
@ -50,6 +70,14 @@ public class ClientPolicyException extends Exception {
this.errorDetail = errorDetail;
}
public Status getErrorStatus() {
return errorStatus;
}
public void setErrorStatus(Status errorStatus) {
this.errorStatus = errorStatus;
}
/**
* If {@link ClientPolicyException} is used to notify the event so that it needs not to have stack trace.
* @return always null

View file

@ -212,7 +212,7 @@ public class LogoutEndpoint {
try {
session.clientPolicy().triggerOnEvent(new LogoutRequestContext(form));
} catch (ClientPolicyException cpe) {
throw new ErrorResponseException(Errors.INVALID_REQUEST, cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
}
RefreshToken token = null;

View file

@ -539,7 +539,7 @@ public class TokenEndpoint {
session.clientPolicy().triggerOnEvent(new TokenRefreshContext(formParams));
} catch (ClientPolicyException cpe) {
event.error(cpe.getError());
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
}
AccessTokenResponse res;

View file

@ -52,6 +52,7 @@ import org.keycloak.services.clientpolicy.TokenRevokeContext;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil;
/**
@ -102,7 +103,7 @@ public class TokenRevocationEndpoint {
session.clientPolicy().triggerOnEvent(new TokenRevokeContext(formParams));
} catch (ClientPolicyException cpe) {
event.error(cpe.getError());
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
}
checkToken();

View file

@ -43,6 +43,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyException;
@ -136,7 +137,7 @@ public class UserInfoEndpoint {
try {
session.clientPolicy().triggerOnEvent(new UserInfoRequestContext(tokenString));
} catch (ClientPolicyException cpe) {
throw new ErrorResponseException(Errors.INVALID_REQUEST, cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
}
EventBuilder event = new EventBuilder(realm, session, clientConnection)

View file

@ -0,0 +1,162 @@
/*
* Copyright 2020 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.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.component.ComponentModel;
import org.keycloak.events.Errors;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.*;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
public class HolderOfKeyEnforceExecutor extends AbstractAugumentingClientRegistrationPolicyExecutor {
private final KeycloakSession session;
private final ComponentModel componentModel;
public HolderOfKeyEnforceExecutor(KeycloakSession session, ComponentModel componentModel) {
super(session, componentModel);
this.session = session;
this.componentModel = componentModel;
}
@Override
public String getName() {
return componentModel.getName();
}
@Override
public String getProviderId() {
return componentModel.getProviderId();
}
@Override
protected void augment(ClientRepresentation rep) {
if (Boolean.parseBoolean(componentModel.getConfig().getFirst(AbstractAugumentingClientRegistrationPolicyExecutor.IS_AUGMENT))) {
OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).setUseMtlsHoKToken(true);
}
}
@Override
protected void validate(ClientRepresentation rep) throws ClientPolicyException {
boolean useMtlsHokToken = OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).isUseMtlsHokToken();
if (!useMtlsHokToken) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Invalid client metadata: MTLS token in disabled");
}
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
super.executeOnEvent(context);
HttpRequest request = session.getContext().getContextObject(HttpRequest.class);
switch (context.getEvent()) {
case TOKEN_REQUEST:
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf == null) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding");
}
break;
case TOKEN_REFRESH:
checkTokenRefresh((TokenRefreshContext) context, request);
break;
case TOKEN_REVOKE:
checkTokenRevoke((TokenRevokeContext) context, request);
break;
case USERINFO_REQUEST:
checkUserInfo((UserInfoRequestContext) context, request);
break;
case LOGOUT_REQUEST:
checkLogout((LogoutRequestContext) context, request);
break;
}
}
private void checkLogout(LogoutRequestContext context, HttpRequest request) throws ClientPolicyException {
MultivaluedMap<String, String> formParameters = context.getParams();
String encodedRefreshToken = formParameters.getFirst(OAuth2Constants.REFRESH_TOKEN);
RefreshToken refreshToken = session.tokens().decode(encodedRefreshToken, RefreshToken.class);
if (refreshToken == null) {
// this executor does not treat this error case.
return;
}
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request, session)) {
throw new ClientPolicyException(Errors.NOT_ALLOWED, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC, Response.Status.UNAUTHORIZED);
}
}
private void checkUserInfo(UserInfoRequestContext context, HttpRequest request) throws ClientPolicyException {
String encodedAccessToken = context.getTokenString();
AccessToken accessToken = session.tokens().decode(encodedAccessToken, AccessToken.class);
if (accessToken == null) {
// this executor does not treat this error case.
return;
}
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(accessToken, request, session)) {
throw new ClientPolicyException(Errors.NOT_ALLOWED, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC, Response.Status.UNAUTHORIZED);
}
}
private void checkTokenRevoke(TokenRevokeContext context, HttpRequest request) throws ClientPolicyException {
MultivaluedMap<String, String> revokeParameters = context.getParams();
String encodedRevokeToken = revokeParameters.getFirst("token");
RefreshToken refreshToken = session.tokens().decode(encodedRevokeToken, RefreshToken.class);
if (refreshToken == null) {
// this executor does not treat this error case.
return;
}
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request, session)) {
throw new ClientPolicyException(Errors.NOT_ALLOWED, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC, Response.Status.UNAUTHORIZED);
}
}
private void checkTokenRefresh(TokenRefreshContext context, HttpRequest request) throws ClientPolicyException {
MultivaluedMap<String, String> formParameters = context.getParams();
String encodedRefreshToken = formParameters.getFirst(OAuth2Constants.REFRESH_TOKEN);
RefreshToken refreshToken = session.tokens().decode(encodedRefreshToken, RefreshToken.class);
if (refreshToken == null) {
// this executor does not treat this error case.
return;
}
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request, session)) {
throw new ClientPolicyException(Errors.NOT_ALLOWED, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC, Response.Status.UNAUTHORIZED);
}
}
}

View file

@ -0,0 +1,65 @@
/*
* Copyright 2020 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.keycloak.Config.Scope;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.Collections;
import java.util.List;
public class HolderOfKeyEnforceExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "holder-of-key-enforce-executor";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session, ComponentModel model) {
return new HolderOfKeyEnforceExecutor(session, model);
}
@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 prohibits the client whose MTLS certificate does not match with the certificate thumbprint from the tokens.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}

View file

@ -6,3 +6,4 @@ org.keycloak.services.clientpolicy.executor.SecureSessionEnforceExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmEnforceExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureRedirectUriEnforceExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmForSignedJwtEnforceExecutorFactory
org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforceExecutorFactory

View file

@ -124,6 +124,7 @@ import org.keycloak.services.clientpolicy.condition.ClientUpdateSourceRolesCondi
import org.keycloak.services.clientpolicy.condition.ClientRolesConditionFactory;
import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory;
import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider;
import org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforceExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureClientAuthEnforceExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureRedirectUriEnforceExecutorFactory;
import org.keycloak.services.clientpolicy.executor.PKCEEnforceExecutorFactory;
@ -144,6 +145,7 @@ import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResou
import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource;
import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource.AuthorizationEndpointRequestObject;
import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExeptionConditionFactory;
import org.keycloak.testsuite.util.MutualTLSUtils;
import org.keycloak.testsuite.util.OAuthClient;
import com.fasterxml.jackson.databind.JsonNode;
@ -1351,6 +1353,162 @@ public class ClientPolicyBasicsTest extends AbstractKeycloakTest {
}
}
@Test
public void testHolderOfKeyEnforceExecutor() throws Exception {
String policyName = "MyPolicy";
createPolicy(policyName, DefaultClientPolicyProviderFactory.PROVIDER_ID, null, null, null);
logger.info("... Created Policy : " + policyName);
createCondition("ClientRolesCondition", ClientRolesConditionFactory.PROVIDER_ID, null, (ComponentRepresentation provider) -> {
setConditionClientRoles(provider, Collections.singletonList("sample-client-role"));
});
registerCondition("ClientRolesCondition", policyName);
logger.info("... Registered Condition : ClientRolesCondition");
createExecutor("HolderOfKeyEnforceExecutor", HolderOfKeyEnforceExecutorFactory.PROVIDER_ID, null, (ComponentRepresentation provider) -> {
setExecutorAugmentActivate(provider);
});
registerExecutor("HolderOfKeyEnforceExecutor", policyName);
logger.info("... Registered Executor : HolderOfKeyEnforceExecutor");
String clientName = "Zahlungs-App";
String userPassword = "password";
String clientId = createClientDynamically(clientName, (OIDCClientRepresentation clientRep) -> {
clientRep.setTokenEndpointAuthMethod(OIDCLoginProtocol.TLS_CLIENT_AUTH);
});
try {
checkMtlsFlow(userPassword);
} finally {
deleteClientByAdmin(clientId);
}
}
private void checkMtlsFlow(String password) throws IOException {
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), "test-app");
ClientRepresentation clientRep = clientResource.toRepresentation();
clientRep.setDefaultRoles(new String[]{"sample-client-role"});
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUseMtlsHoKToken(true);
clientResource.update(clientRep);
// Check login.
OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin("test-user@localhost", password);
Assert.assertNull(loginResponse.getError());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
// Check token obtaining.
OAuthClient.AccessTokenResponse accessTokenResponse;
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
accessTokenResponse = oauth.doAccessTokenRequest(code, password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(200, accessTokenResponse.getStatusCode());
// Check token refresh.
OAuthClient.AccessTokenResponse accessTokenResponseRefreshed;
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
accessTokenResponseRefreshed = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken(), password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(200, accessTokenResponseRefreshed.getStatusCode());
// Check token introspection.
String tokenResponse;
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenResponse = oauth.introspectTokenWithClientCredential(TEST_CLIENT, password, "access_token", accessTokenResponse.getAccessToken(), client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
Assert.assertNotNull(tokenResponse);
TokenMetadataRepresentation tokenMetadataRepresentation = JsonSerialization.readValue(tokenResponse, TokenMetadataRepresentation.class);
Assert.assertTrue(tokenMetadataRepresentation.isActive());
// Check token revoke.
CloseableHttpResponse tokenRevokeResponse;
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenRevokeResponse = oauth.doTokenRevoke(accessTokenResponse.getRefreshToken(), "refresh_token", password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(200, tokenRevokeResponse.getStatusLine().getStatusCode());
// Check logout.
CloseableHttpResponse logoutResponse;
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
logoutResponse = oauth.doLogout(accessTokenResponse.getRefreshToken(), password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(204, logoutResponse.getStatusLine().getStatusCode());
// Check login.
loginResponse = oauth.doLogin("test-user@localhost", password);
Assert.assertNull(loginResponse.getError());
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
// Check token obtaining without certificate
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
accessTokenResponse = oauth.doAccessTokenRequest(code, password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(400, accessTokenResponse.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, accessTokenResponse.getError());
// Check frontchannel logout and login.
oauth.openLogout();
loginResponse = oauth.doLogin("test-user@localhost", password);
Assert.assertNull(loginResponse.getError());
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
// Check token obtaining.
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
accessTokenResponse = oauth.doAccessTokenRequest(code, password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(200, accessTokenResponse.getStatusCode());
// Check token refresh with other certificate
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithOtherKeyStoreAndTrustStore()) {
accessTokenResponseRefreshed = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken(), password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(401, accessTokenResponseRefreshed.getStatusCode());
assertEquals(Errors.NOT_ALLOWED, accessTokenResponseRefreshed.getError());
// Check token revoke with other certificate
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithOtherKeyStoreAndTrustStore()) {
tokenRevokeResponse = oauth.doTokenRevoke(accessTokenResponse.getRefreshToken(), "refresh_token", password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(401, tokenRevokeResponse.getStatusLine().getStatusCode());
// Check logout without certificate
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
logoutResponse = oauth.doLogout(accessTokenResponse.getRefreshToken(), password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
assertEquals(401, logoutResponse.getStatusLine().getStatusCode());
// Check logout.
try (CloseableHttpClient client = MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
logoutResponse = oauth.doLogout(accessTokenResponse.getRefreshToken(), password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
private CloseableHttpResponse sendRequest(String requestUrl, List<NameValuePair> parameters) throws Exception {
CloseableHttpClient client = new DefaultHttpClient();
try {