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) {
|
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) {
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue