feat: eliminate client secret requirement

This commit eliminates neccessity for providing client secret when
constructing client via Admin Client API. The requirement for client
secret became obsolete when Keycloak onboarded a X509 certificate
authorizer.

closes #33755

Signed-off-by: Igor Petrov <igor.petrov-ext@camunda.com>
This commit is contained in:
Igor Petrov 2024-10-10 21:33:00 +03:00 committed by Marek Posolda
parent e9823d0504
commit 8e872818c5
4 changed files with 123 additions and 9 deletions

View file

@ -139,10 +139,6 @@ public class KeycloakBuilder {
if (password == null) { if (password == null) {
throw new IllegalStateException("password required"); throw new IllegalStateException("password required");
} }
} else if (CLIENT_CREDENTIALS.equals(grantType)) {
if (clientSecret == null) {
throw new IllegalStateException("clientSecret required with grant_type=client_credentials");
}
} }
if (authorization == null && clientId == null) { if (authorization == null && clientId == null) {

View file

@ -60,10 +60,6 @@ public class TokenManager {
} }
this.tokenService = Keycloak.getClientProvider().targetProxy(target, TokenService.class); this.tokenService = Keycloak.getClientProvider().targetProxy(target, TokenService.class);
this.accessTokenGrantType = config.getGrantType(); this.accessTokenGrantType = config.getGrantType();
if (CLIENT_CREDENTIALS.equals(accessTokenGrantType) && config.isPublicClient()) {
throw new IllegalArgumentException("Can't use " + GRANT_TYPE + "=" + CLIENT_CREDENTIALS + " with public client");
}
} }
public String getAccessTokenString() { public String getAccessTokenString() {

View file

@ -18,10 +18,13 @@
package org.keycloak.testsuite.util; package org.keycloak.testsuite.util;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.security.KeyManagementException; import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException; import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException; import java.security.cert.CertificateException;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
@ -99,6 +102,22 @@ public class AdminClientUtil {
.scope(scope).build(); .scope(scope).build();
} }
public static Keycloak createMTlsAdminClientWithClientCredentialsWithoutSecret(
final String realmName, String clientId, String scope)
throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
boolean ignoreUnknownProperties = false;
ResteasyClient resteasyClient = createResteasyClientWithKeystoreAndTruststore();
return KeycloakBuilder.builder()
.serverUrl(getAuthServerContextRoot() + "/auth")
.realm(realmName)
.grantType(OAuth2Constants.CLIENT_CREDENTIALS)
.clientId(clientId)
.resteasyClient(resteasyClient)
.scope(scope).build();
}
public static Keycloak createAdminClient() throws Exception { public static Keycloak createAdminClient() throws Exception {
return createAdminClient(false, getAuthServerContextRoot()); return createAdminClient(false, getAuthServerContextRoot());
} }
@ -145,6 +164,30 @@ public class AdminClientUtil {
return resteasyClientBuilder.build(); return resteasyClientBuilder.build();
} }
public static ResteasyClient createResteasyClientWithKeystoreAndTruststore() throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
ResteasyClientBuilder resteasyClientBuilder = (ResteasyClientBuilder) ResteasyClientBuilder.newBuilder();
if ("true".equals(System.getProperty("auth.server.ssl.required"))) {
File truststore = new File(PROJECT_BUILD_DIRECTORY, "dependency/keystore/keycloak.truststore");
try {
resteasyClientBuilder.sslContext(getSSLContextWithTruststoreAndKeystore(
truststore, "secret",
new File(PROJECT_BUILD_DIRECTORY, "dependency/keystore/keycloak.jks"), "secret"));
} catch (UnrecoverableKeyException e) {
throw new RuntimeException(e);
}
System.setProperty("javax.net.ssl.trustStore", truststore.getAbsolutePath());
}
resteasyClientBuilder
.hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.WILDCARD)
.connectionPoolSize(NUMBER_OF_CONNECTIONS)
.httpEngine(getCustomClientHttpEngine(resteasyClientBuilder, 1, null));
return resteasyClientBuilder.build();
}
private static SSLContext getSSLContextWithTruststore(File file, String password) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException { private static SSLContext getSSLContextWithTruststore(File file, String password) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
if (!file.isFile()) { if (!file.isFile()) {
throw new RuntimeException("Truststore file not found: " + file.getAbsolutePath()); throw new RuntimeException("Truststore file not found: " + file.getAbsolutePath());
@ -156,6 +199,27 @@ public class AdminClientUtil {
return theContext; return theContext;
} }
private static SSLContext getSSLContextWithTruststoreAndKeystore(
File trustStore, String truststorePassword, File keystore, String keystorePassword)
throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException, UnrecoverableKeyException {
if (!trustStore.isFile()) {
throw new RuntimeException("Truststore file not found: " + trustStore.getAbsolutePath());
}
if (!keystore.isFile()) {
throw new RuntimeException("Keystore file not found: " + keystore.getAbsolutePath());
}
KeyStore ks = KeyStore.getInstance("JKS");
ks.load(new FileInputStream(keystore), keystorePassword.toCharArray());
SSLContext theContext = SSLContexts.custom()
.setProtocol("TLS")
.loadTrustMaterial(trustStore, truststorePassword == null ? null : truststorePassword.toCharArray())
.loadKeyMaterial(ks, keystorePassword.toCharArray())
.build();
return theContext;
}
public static ClientHttpEngine getCustomClientHttpEngine(ResteasyClientBuilder resteasyClientBuilder, int validateAfterInactivity, Boolean followRedirects) { public static ClientHttpEngine getCustomClientHttpEngine(ResteasyClientBuilder resteasyClientBuilder, int validateAfterInactivity, Boolean followRedirects) {
return new CustomClientHttpEngineBuilder43(validateAfterInactivity, followRedirects).resteasyClientBuilder(resteasyClientBuilder).build(); return new CustomClientHttpEngineBuilder43(validateAfterInactivity, followRedirects).resteasyClientBuilder(resteasyClientBuilder).build();
} }

View file

@ -21,12 +21,14 @@ package org.keycloak.testsuite.admin;
import java.util.List; import java.util.List;
import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.Map;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.models.AdminRoles; import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
@ -52,7 +54,7 @@ import java.util.Objects;
public class AdminClientTest extends AbstractKeycloakTest { public class AdminClientTest extends AbstractKeycloakTest {
private static String realmName; private static String realmName;
private static String userId; private static String userId;
private static String userName; private static String userName;
@ -60,6 +62,11 @@ public class AdminClientTest extends AbstractKeycloakTest {
private static String clientId; private static String clientId;
private static String clientSecret; private static String clientSecret;
private static String x509ClientUUID;
private static String x509ClientId;
private static String x509UserName;
@Rule @Rule
public AssertEvents events = new AssertEvents(this); public AssertEvents events = new AssertEvents(this);
@ -83,6 +90,17 @@ public class AdminClientTest extends AbstractKeycloakTest {
.build(); .build();
realm.client(enabledAppWithSkipRefreshToken); realm.client(enabledAppWithSkipRefreshToken);
x509ClientId = "x509-client-sa";
ClientRepresentation x509ServiceAccountClient = ClientBuilder.create()
.clientId(x509ClientId)
.serviceAccountsEnabled(true)
.build();
x509ServiceAccountClient.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
x509ServiceAccountClient.setAttributes(Map.of(
X509ClientAuthenticator.ATTR_SUBJECT_DN, "(.*?)(?:$)",
X509ClientAuthenticator.ATTR_ALLOW_REGEX_PATTERN_COMPARISON, "true"));
realm.client(x509ServiceAccountClient);
userId = KeycloakModelUtils.generateId(); userId = KeycloakModelUtils.generateId();
userName = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + enabledAppWithSkipRefreshToken.getClientId(); userName = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + enabledAppWithSkipRefreshToken.getClientId();
UserBuilder serviceAccountUser = UserBuilder.create() UserBuilder serviceAccountUser = UserBuilder.create()
@ -91,6 +109,17 @@ public class AdminClientTest extends AbstractKeycloakTest {
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN); .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN);
realm.user(serviceAccountUser); realm.user(serviceAccountUser);
// This user is associated with the x509-client-sa service account above and
// give the service account a service account role "realm-management:realm-admin".
// Without the "realm-management:realm-admin" role we won't be able to test any actual
// admin call.
x509UserName = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + x509ServiceAccountClient.getClientId();
UserBuilder x509ServiceAccountUser = UserBuilder.create()
.username(x509UserName)
.serviceAccountId(x509ServiceAccountClient.getClientId())
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN);
realm.user(x509ServiceAccountUser);
UserBuilder defaultUser = UserBuilder.create() UserBuilder defaultUser = UserBuilder.create()
.id(KeycloakModelUtils.generateId()) .id(KeycloakModelUtils.generateId())
.username("test-user@localhost") .username("test-user@localhost")
@ -105,6 +134,7 @@ public class AdminClientTest extends AbstractKeycloakTest {
public void importRealm(RealmRepresentation realm) { public void importRealm(RealmRepresentation realm) {
super.importRealm(realm); super.importRealm(realm);
if (Objects.equals(realm.getRealm(), realmName)) { if (Objects.equals(realm.getRealm(), realmName)) {
x509ClientUUID = adminClient.realm(realmName).clients().findByClientId(x509ClientId).get(0).getId();
clientUUID = adminClient.realm(realmName).clients().findByClientId(clientId).get(0).getId(); clientUUID = adminClient.realm(realmName).clients().findByClientId(clientId).get(0).getId();
userId = adminClient.realm(realmName).users().searchByUsername(userName, true).get(0).getId(); userId = adminClient.realm(realmName).users().searchByUsername(userName, true).get(0).getId();
} }
@ -183,12 +213,40 @@ public class AdminClientTest extends AbstractKeycloakTest {
clientId, clientSecret, scopeName)) { clientId, clientSecret, scopeName)) {
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken(); final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
Assert.assertTrue(accessToken.getScope().contains(scopeName)); Assert.assertTrue(accessToken.getScope().contains(scopeName));
Assert.assertNotNull(adminClient.realm(realmName).clientScopes().get(scopeId).toRepresentation());
} }
// without scope // without scope
try (Keycloak adminClient = AdminClientUtil.createAdminClientWithClientCredentials(realmName, try (Keycloak adminClient = AdminClientUtil.createAdminClientWithClientCredentials(realmName,
clientId, clientSecret, null)) { clientId, clientSecret, null)) {
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken(); final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
Assert.assertFalse(accessToken.getScope().contains(scopeName)); Assert.assertFalse(accessToken.getScope().contains(scopeName));
Assert.assertNotNull(adminClient.realm(realmName).clientScopes().get(scopeId).toRepresentation());
}
}
// A client secret in not necessary when authentication is
// performed via X.509 authorizer.
@Test
public void noClientSecretWithClientCredentialsAuthSuccess() throws Exception {
final RealmResource testRealm = adminClient.realm(realmName);
final String scopeName = "dummyScope";
String scopeId = createScope(testRealm, scopeName, KeycloakModelUtils.generateId());
testRealm.clients().get(x509ClientUUID).addOptionalClientScope(scopeId);
// with scope and no client secret
try (Keycloak adminClient = AdminClientUtil.
createMTlsAdminClientWithClientCredentialsWithoutSecret(realmName, x509ClientId, scopeName)) {
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
Assert.assertTrue(accessToken.getScope().contains(scopeName));
Assert.assertNotNull(adminClient.realm(realmName).clientScopes().get(scopeId).toRepresentation());
}
// without scope and no client secret
try (Keycloak adminClient = AdminClientUtil.
createMTlsAdminClientWithClientCredentialsWithoutSecret(realmName, x509ClientId, null)) {
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
Assert.assertFalse(accessToken.getScope().contains(scopeName));
Assert.assertNotNull(adminClient.realm(realmName).clientScopes().get(scopeId).toRepresentation());
} }
} }