KEYCLOAK-18904 Support cert-bound tokens when doing client credentials grant. Client policies support for client credentials grant

This commit is contained in:
mposolda 2021-07-27 21:24:23 +02:00 committed by Marek Posolda
parent ce80a3ba9b
commit 4520cbd38c
14 changed files with 186 additions and 29 deletions

View file

@ -32,6 +32,7 @@ public enum ClientPolicyEvent {
UNREGISTER,
AUTHORIZATION_REQUEST,
TOKEN_REQUEST,
SERVICE_ACCOUNT_TOKEN_REQUEST,
TOKEN_REFRESH,
TOKEN_REVOKE,
TOKEN_INTROSPECT,

View file

@ -74,6 +74,7 @@ import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.ServiceAccountTokenRequestContext;
import org.keycloak.services.clientpolicy.context.TokenRefreshContext;
import org.keycloak.services.clientpolicy.context.TokenRequestContext;
import org.keycloak.services.managers.AppAuthManager;
@ -443,21 +444,7 @@ public class TokenEndpoint {
responseBuilder.generateRefreshToken();
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()) {
responseBuilder.getRefreshToken().setCertConf(certConf);
}
} else {
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
checkMtlsHoKToken(responseBuilder, OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken());
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
@ -483,6 +470,24 @@ public class TokenEndpoint {
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
}
private void checkMtlsHoKToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken) {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
if (useRefreshToken) {
responseBuilder.getRefreshToken().setCertConf(certConf);
}
} else {
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
}
private void checkParamsForPkceEnforcedClient(String codeVerifier, String codeChallenge, String codeChallengeMethod, String authUserId, String authUsername) {
// check whether code verifier is specified
if (codeVerifier == null) {
@ -753,6 +758,13 @@ public class TokenEndpoint {
userSession.setNote(ServiceAccountConstants.CLIENT_HOST, clientConnection.getRemoteHost());
userSession.setNote(ServiceAccountConstants.CLIENT_ADDRESS, clientConnection.getRemoteAddr());
try {
session.clientPolicy().triggerOnEvent(new ServiceAccountTokenRequestContext(formParams, clientSessionCtx.getClientSession()));
} catch (ClientPolicyException cpe) {
event.error(cpe.getError());
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
}
updateUserSessionFromClientAuth(userSession);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
@ -765,6 +777,8 @@ public class TokenEndpoint {
responseBuilder.getAccessToken().setSessionState(null);
}
checkMtlsHoKToken(responseBuilder, useRefreshToken);
String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();

View file

@ -68,6 +68,7 @@ public class ClientAccessTypeCondition extends AbstractClientPolicyConditionProv
switch (context.getEvent()) {
case AUTHORIZATION_REQUEST:
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case TOKEN_REFRESH:
case TOKEN_REVOKE:
case TOKEN_INTROSPECT:

View file

@ -70,6 +70,7 @@ public class ClientRolesCondition extends AbstractClientPolicyConditionProvider<
switch (context.getEvent()) {
case AUTHORIZATION_REQUEST:
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case TOKEN_REFRESH:
case TOKEN_REVOKE:
case TOKEN_INTROSPECT:

View file

@ -37,6 +37,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.ClientPolicyVote;
import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext;
import org.keycloak.services.clientpolicy.context.ServiceAccountTokenRequestContext;
import org.keycloak.services.clientpolicy.context.TokenRequestContext;
/**
@ -91,6 +92,9 @@ public class ClientScopesCondition extends AbstractClientPolicyConditionProvider
case TOKEN_REQUEST:
if (isScopeMatched(((TokenRequestContext)context).getParseResult().getClientSession())) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;
case SERVICE_ACCOUNT_TOKEN_REQUEST:
if (isScopeMatched(((ServiceAccountTokenRequestContext)context).getClientSession())) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;
case BACKCHANNEL_AUTHENTICATION_REQUEST:
if (isScopeMatched(((BackchannelAuthenticationRequestContext)context).getParsedRequest())) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;

View file

@ -0,0 +1,53 @@
/*
* Copyright 2021 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.context;
import javax.ws.rs.core.MultivaluedMap;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ServiceAccountTokenRequestContext implements ClientPolicyContext {
private final MultivaluedMap<String, String> params;
private final AuthenticatedClientSessionModel clientSession;
public ServiceAccountTokenRequestContext(MultivaluedMap<String, String> params,
AuthenticatedClientSessionModel clientSession) {
this.params = params;
this.clientSession = clientSession;
}
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.SERVICE_ACCOUNT_TOKEN_REQUEST;
}
public MultivaluedMap<String, String> getParams() {
return params;
}
public AuthenticatedClientSessionModel getClientSession() {
return clientSession;
}
}

View file

@ -45,6 +45,7 @@ public class ConfidentialClientAcceptExecutor implements ClientPolicyExecutorPro
switch (context.getEvent()) {
case AUTHORIZATION_REQUEST:
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case BACKCHANNEL_AUTHENTICATION_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
checkIsConfidentialClient();

View file

@ -90,6 +90,7 @@ public class HolderOfKeyEnforcerExecutor implements ClientPolicyExecutorProvider
validate(clientUpdateContext.getProposedClientRepresentation());
break;
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf == null) {

View file

@ -93,6 +93,7 @@ public class SecureClientAuthenticatorExecutor implements ClientPolicyExecutorPr
validateDuringClientCRUD(clientUpdateContext.getProposedClientRepresentation());
break;
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case TOKEN_REFRESH:
case TOKEN_REVOKE:
case TOKEN_INTROSPECT:

View file

@ -77,6 +77,7 @@ public class SecureSigningAlgorithmForSignedJwtExecutor implements ClientPolicyE
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case TOKEN_REFRESH:
case TOKEN_REVOKE:
case TOKEN_INTROSPECT:

View file

@ -51,6 +51,7 @@ public class TestRaiseExeptionExecutor implements ClientPolicyExecutorProvider<C
case REGISTERED:
case UPDATED:
case UNREGISTER:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case BACKCHANNEL_AUTHENTICATION_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
case PUSHED_AUTHORIZATION_REQUEST:

View file

@ -689,7 +689,7 @@ public class OAuthClient {
}
public AccessTokenResponse doClientCredentialsGrantAccessTokenRequest(String clientSecret) throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
try (CloseableHttpClient client = httpClient.get()) {
HttpPost post = new HttpPost(getServiceAccountUrl());
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);

View file

@ -35,7 +35,6 @@ import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -100,7 +99,6 @@ import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
@ -1923,6 +1921,48 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
}
}
@Test
public void testClientPolicyTriggeredForServiceAccountRequest() throws Exception {
String clientId = "service-account-app";
String clientSecret = "app-secret";
createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setSecret(clientSecret);
clientRep.setStandardFlowEnabled(Boolean.FALSE);
clientRep.setImplicitFlowEnabled(Boolean.FALSE);
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
clientRep.setPublicClient(Boolean.FALSE);
clientRep.setBearerOnly(Boolean.FALSE);
});
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Den Forste Profilen")
.addExecutor(TestRaiseExeptionExecutorFactory.PROVIDER_ID, null)
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE)
.addCondition(AnyClientConditionFactory.PROVIDER_ID, createAnyClientConditionConfig())
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
String origClientId = oauth.getClientId();
oauth.clientId("service-account-app");
try {
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("app-secret");
assertEquals(400, response.getStatusCode());
assertEquals(ClientPolicyEvent.SERVICE_ACCOUNT_TOKEN_REQUEST.toString(), response.getError());
assertEquals("Exception thrown intentionally", response.getErrorDescription());
} finally {
oauth.clientId(origClientId);
}
}
private List<String> getAttributeMultivalued(ClientRepresentation clientRep, String attrKey) {
String attrValue = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(attrKey);
if (attrValue == null) return Collections.emptyList();

View file

@ -18,6 +18,7 @@ import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
@ -65,6 +66,7 @@ import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.util.JsonSerialization;
import org.openqa.selenium.WebDriver;
@ -77,7 +79,7 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
@Different
protected WebDriver driver2;
private static final List<String> CLIENT_LIST = Arrays.asList("test-app", "named-test-app");
private static final List<String> CLIENT_LIST = Arrays.asList("test-app", "named-test-app", "service-account-client");
public static class HoKAssertEvents extends AssertEvents {
@ -133,6 +135,10 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
confApp.setSecret("secret1");
confApp.setServiceAccountsEnabled(Boolean.TRUE);
ClientRepresentation serviceAccountApp = KeycloakModelUtils.createClient(testRealm, "service-account-client");
serviceAccountApp.setSecret("secret1");
serviceAccountApp.setServiceAccountsEnabled(Boolean.TRUE);
ClientRepresentation pubApp = KeycloakModelUtils.createClient(testRealm, "public-cli");
pubApp.setPublicClient(Boolean.TRUE);
@ -634,16 +640,46 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
}
@Test
public void serviceAccountWithClientCertificate() throws Exception {
oauth.clientId("service-account-client");
AccessTokenResponse response;
Supplier<CloseableHttpClient> previous = oauth.getHttpClient();
try {
// Request without HoK should fail
oauth.httpClient(MutualTLSUtils::newCloseableHttpClientWithoutKeyStoreAndTrustStore);
response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Client Certification missing for MTLS HoK Token Binding", response.getErrorDescription());
// Request with HoK - success
oauth.httpClient(MutualTLSUtils::newCloseableHttpClientWithDefaultKeyStoreAndTrustStore);
response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
assertEquals(200, response.getStatusCode());
// Success Pattern
verifyHoKTokenCertThumbPrint(response, MutualTLSUtils.getThumbprintFromDefaultClientCert(), false);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
} finally {
oauth.httpClient(previous);
}
}
private void verifyHoKTokenDefaultCertThumbPrint(AccessTokenResponse response) throws Exception {
verifyHoKTokenCertThumbPrint(response, MutualTLSUtils.getThumbprintFromDefaultClientCert());
verifyHoKTokenCertThumbPrint(response, MutualTLSUtils.getThumbprintFromDefaultClientCert(), true);
}
private void verifyHoKTokenOtherCertThumbPrint(AccessTokenResponse response) throws Exception {
verifyHoKTokenCertThumbPrint(response, MutualTLSUtils.getThumbprintFromOtherClientCert());
verifyHoKTokenCertThumbPrint(response, MutualTLSUtils.getThumbprintFromOtherClientCert(), true);
}
private void verifyHoKTokenCertThumbPrint(AccessTokenResponse response, String certThumbPrint) {
private void verifyHoKTokenCertThumbPrint(AccessTokenResponse response, String certThumbPrint, boolean checkRefreshToken) {
JWSInput jws = null;
AccessToken at = null;
try {
@ -654,13 +690,15 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), at.getCertConf().getCertThumbprint().getBytes()));
RefreshToken rt = null;
try {
jws = new JWSInput(response.getRefreshToken());
rt = jws.readJsonContent(RefreshToken.class);
} catch (JWSInputException e) {
Assert.fail(e.toString());
if (checkRefreshToken) {
RefreshToken rt = null;
try {
jws = new JWSInput(response.getRefreshToken());
rt = jws.readJsonContent(RefreshToken.class);
} catch (JWSInputException e) {
Assert.fail(e.toString());
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), rt.getCertConf().getCertThumbprint().getBytes()));
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), rt.getCertConf().getCertThumbprint().getBytes()));
}
}