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:
parent
e9823d0504
commit
8e872818c5
4 changed files with 123 additions and 9 deletions
|
@ -139,10 +139,6 @@ public class KeycloakBuilder {
|
|||
if (password == null) {
|
||||
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) {
|
||||
|
|
|
@ -60,10 +60,6 @@ public class TokenManager {
|
|||
}
|
||||
this.tokenService = Keycloak.getClientProvider().targetProxy(target, TokenService.class);
|
||||
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() {
|
||||
|
|
|
@ -18,10 +18,13 @@
|
|||
package org.keycloak.testsuite.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.CertificateException;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
@ -99,6 +102,22 @@ public class AdminClientUtil {
|
|||
.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 {
|
||||
return createAdminClient(false, getAuthServerContextRoot());
|
||||
}
|
||||
|
@ -145,6 +164,30 @@ public class AdminClientUtil {
|
|||
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 {
|
||||
if (!file.isFile()) {
|
||||
throw new RuntimeException("Truststore file not found: " + file.getAbsolutePath());
|
||||
|
@ -156,6 +199,27 @@ public class AdminClientUtil {
|
|||
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) {
|
||||
return new CustomClientHttpEngineBuilder43(validateAfterInactivity, followRedirects).resteasyClientBuilder(resteasyClientBuilder).build();
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ package org.keycloak.testsuite.admin;
|
|||
import java.util.List;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.Map;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.admin.client.resource.ClientResource;
|
||||
import org.keycloak.admin.client.resource.RealmResource;
|
||||
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
|
||||
import org.keycloak.common.constants.ServiceAccountConstants;
|
||||
import org.keycloak.models.AdminRoles;
|
||||
import org.keycloak.models.Constants;
|
||||
|
@ -52,7 +54,7 @@ import java.util.Objects;
|
|||
public class AdminClientTest extends AbstractKeycloakTest {
|
||||
|
||||
private static String realmName;
|
||||
|
||||
|
||||
private static String userId;
|
||||
private static String userName;
|
||||
|
||||
|
@ -60,6 +62,11 @@ public class AdminClientTest extends AbstractKeycloakTest {
|
|||
private static String clientId;
|
||||
private static String clientSecret;
|
||||
|
||||
private static String x509ClientUUID;
|
||||
private static String x509ClientId;
|
||||
|
||||
private static String x509UserName;
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
|
@ -83,6 +90,17 @@ public class AdminClientTest extends AbstractKeycloakTest {
|
|||
.build();
|
||||
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();
|
||||
userName = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + enabledAppWithSkipRefreshToken.getClientId();
|
||||
UserBuilder serviceAccountUser = UserBuilder.create()
|
||||
|
@ -91,6 +109,17 @@ public class AdminClientTest extends AbstractKeycloakTest {
|
|||
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN);
|
||||
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()
|
||||
.id(KeycloakModelUtils.generateId())
|
||||
.username("test-user@localhost")
|
||||
|
@ -105,6 +134,7 @@ public class AdminClientTest extends AbstractKeycloakTest {
|
|||
public void importRealm(RealmRepresentation realm) {
|
||||
super.importRealm(realm);
|
||||
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();
|
||||
userId = adminClient.realm(realmName).users().searchByUsername(userName, true).get(0).getId();
|
||||
}
|
||||
|
@ -183,12 +213,40 @@ public class AdminClientTest extends AbstractKeycloakTest {
|
|||
clientId, clientSecret, scopeName)) {
|
||||
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
|
||||
Assert.assertTrue(accessToken.getScope().contains(scopeName));
|
||||
Assert.assertNotNull(adminClient.realm(realmName).clientScopes().get(scopeId).toRepresentation());
|
||||
}
|
||||
// without scope
|
||||
try (Keycloak adminClient = AdminClientUtil.createAdminClientWithClientCredentials(realmName,
|
||||
clientId, clientSecret, null)) {
|
||||
final AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue