keycloak-aplcache
Changes
services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java 62(+50 -12)
Details
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
index 5ad579e..f6b7b91 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
@@ -1,10 +1,13 @@
package org.keycloak.authentication.authenticators.client;
+import org.apache.commons.codec.binary.StringUtils;
+import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.idm.CredentialRepresentation;
@@ -17,10 +20,16 @@ import javax.ws.rs.core.Response;
import java.security.GeneralSecurityException;
import java.security.cert.X509Certificate;
import java.util.*;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
public class X509ClientAuthenticator extends AbstractClientAuthenticator {
public static final String PROVIDER_ID = "client-x509";
+ public static final String ATTR_PREFIX = "x509";
+ public static final String ATTR_SUBJECT_DN = ATTR_PREFIX + ".subjectdn";
+
protected static ServicesLogger logger = ServicesLogger.LOGGER;
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
@@ -38,7 +47,8 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
return;
}
- X509Certificate[] certs = new X509Certificate[0];
+ X509Certificate[] certs = null;
+ ClientModel client = null;
try {
certs = provider.getCertificateChain(context.getHttpRequest());
String client_id = null;
@@ -52,17 +62,17 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
client_id = formData.getFirst(OAuth2Constants.CLIENT_ID);
}
+ if (client_id == null && queryParams != null) {
+ client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID);
+ }
+
if (client_id == null) {
- if (queryParams != null) {
- client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID);
- } else {
- Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
- context.challenge(challengeResponse);
- return;
- }
+ Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
+ context.challenge(challengeResponse);
+ return;
}
- ClientModel client = context.getRealm().getClientByClientId(client_id);
+ client = context.getRealm().getClientByClientId(client_id);
if (client == null) {
context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND, null);
return;
@@ -77,6 +87,7 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
} catch (GeneralSecurityException e) {
logger.errorf("[X509ClientCertificateAuthenticator:authenticate] Exception: %s", e.getMessage());
context.attempted();
+ return;
}
if (certs == null || certs.length == 0) {
@@ -87,6 +98,34 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
return;
}
+ String subjectDNRegexp = client.getAttribute(ATTR_SUBJECT_DN);
+ if (subjectDNRegexp == null || subjectDNRegexp.length() == 0) {
+ logger.errorf("[X509ClientCertificateAuthenticator:authenticate] " + ATTR_SUBJECT_DN + " is null or empty");
+ context.attempted();
+ return;
+ }
+ Pattern subjectDNPattern = Pattern.compile(subjectDNRegexp);
+
+ Optional<String> matchedCertificate = Arrays.stream(certs)
+ .map(certificate -> certificate.getSubjectDN().getName())
+ .filter(subjectdn -> subjectDNPattern.matcher(subjectdn).matches())
+ .findFirst();
+
+ if (!matchedCertificate.isPresent()) {
+ // We do quite expensive operation here, so better check the logging level beforehand.
+ if (logger.isDebugEnabled()) {
+ logger.debug("[X509ClientCertificateAuthenticator:authenticate] Couldn't match any certificate for pattern " + subjectDNRegexp);
+ logger.debug("[X509ClientCertificateAuthenticator:authenticate] Available SubjectDNs: " +
+ Arrays.stream(certs)
+ .map(cert -> cert.getSubjectDN().getName())
+ .collect(Collectors.toList()));
+ }
+ context.attempted();
+ return;
+ } else {
+ logger.debug("[X509ClientCertificateAuthenticator:authenticate] Matched " + matchedCertificate.get() + " certificate.");
+ }
+
context.success();
}
@@ -111,11 +150,10 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
- Map<String, Object> result = new HashMap<>();
- return result;
+ return Collections.emptyMap();
}
- @Override
+ @Override
public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
if (loginProtocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
Set<String> results = new HashSet<>();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
index 613e0e5..00ea389 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
@@ -2,10 +2,14 @@ package org.keycloak.testsuite.client;
import java.io.IOException;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
+import java.util.Map;
import java.util.function.Supplier;
+import org.apache.commons.collections.map.UnmodifiableMap;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
@@ -36,9 +40,11 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
private static final String CLIENT_ID = "confidential-x509";
private static final String DISABLED_CLIENT_ID = "confidential-disabled-x509";
+ private static final String EXACT_SUBJECT_DN_CLIENT_ID = "confidential-subjectdn-x509";
private static final String USER = "keycloak-user@localhost";
private static final String PASSWORD = "password";
private static final String REALM = "test";
+ private static final String EXACT_CERTIFICATE_SUBJECT_DN = "CN=Keycloak, OU=Keycloak, O=Red Hat, L=Boston, ST=MA, C=US";
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
@@ -46,11 +52,19 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
properConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
properConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
properConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ properConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, "(.*?)(?:$)"));
ClientRepresentation disabledConfiguration = KeycloakModelUtils.createClient(testRealm, DISABLED_CLIENT_ID);
disabledConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
disabledConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
disabledConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ disabledConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, "(.*?)(?:$)"));
+
+ ClientRepresentation exactSubjectDNConfiguration = KeycloakModelUtils.createClient(testRealm, EXACT_SUBJECT_DN_CLIENT_ID);
+ exactSubjectDNConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
+ exactSubjectDNConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
+ exactSubjectDNConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ exactSubjectDNConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, EXACT_CERTIFICATE_SUBJECT_DN));
}
@BeforeClass
@@ -71,6 +85,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
}
@Test
+ public void testSuccessfulClientInvocationWithProperCertificateAndSubjectDN() throws Exception {
+ //given
+ Supplier<CloseableHttpClient> clientWithProperCertificate = MutualTLSUtils::newCloseableHttpClientWithDefaultKeyStoreAndTrustStore;
+
+ //when
+ OAuthClient.AccessTokenResponse token = loginAndGetAccessTokenResponse(EXACT_SUBJECT_DN_CLIENT_ID, clientWithProperCertificate);
+
+ //then
+ assertTokenObtained(token);
+ }
+
+ @Test
public void testSuccessfulClientInvocationWithClientIdInQueryParams() throws Exception {
//given//when
OAuthClient.AccessTokenResponse token = null;
@@ -84,6 +110,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
}
@Test
+ public void testFailedClientInvocationWithProperCertificateAndWrongSubjectDN() throws Exception {
+ //given
+ Supplier<CloseableHttpClient> clientWithProperCertificate = MutualTLSUtils::newCloseableHttpClientWithOtherKeyStoreAndTrustStore;
+
+ //when
+ OAuthClient.AccessTokenResponse token = loginAndGetAccessTokenResponse(EXACT_SUBJECT_DN_CLIENT_ID, clientWithProperCertificate);
+
+ //then
+ assertTokenNotObtained(token);
+ }
+
+ @Test
public void testFailedClientInvocationWithoutCertificateCertificate() throws Exception {
//given
Supplier<CloseableHttpClient> clientWithoutCertificate = MutualTLSUtils::newCloseableHttpClientWithoutKeyStoreAndTrustStore;
diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 93d19b3..65017c8 100644
--- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -1506,4 +1506,6 @@ manage-members-authz-group-scope-description=Policies that decide if an admin ca
advanced-client-settings=Advanced Settings
advanced-client-settings.tooltip=Expand this section to configure advanced settings of this client
tls-client-certificate-bound-access-tokens=OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled
-tls-client-certificate-bound-access-tokens.tooltip=This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.
\ No newline at end of file
+tls-client-certificate-bound-access-tokens.tooltip=This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.
+subjectdn=Subject DN
+subjectdn-tooltip=A regular expression for validating Subject DN in the Client Certificate. Use "(.*?)(?:$)" to match all kind of expressions.
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
index 7215044..f531b03 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
@@ -59,6 +59,9 @@ module.controller('ClientCredentialsCtrl', function($scope, $location, realm, cl
case 'client-secret-jwt':
$scope.clientAuthenticatorConfigPartial = 'client-credentials-secret-jwt.html';
break;
+ case 'client-x509':
+ $scope.clientAuthenticatorConfigPartial = 'client-credentials-x509.html';
+ break;
default:
$scope.currentAuthenticatorConfigProperties = clientConfigProperties[val];
$scope.clientAuthenticatorConfigPartial = 'client-credentials-generic.html';
@@ -127,6 +130,48 @@ module.controller('ClientSecretCtrl', function($scope, $location, ClientSecret,
};
});
+module.controller('ClientX509Ctrl', function($scope, $location, Client, Notifications) {
+ console.log('ClientX509Ctrl invoked');
+
+ $scope.clientCopy = angular.copy($scope.client);
+ $scope.changed = false;
+
+ $scope.$watch('client', function() {
+ if (!angular.equals($scope.client, $scope.clientCopy)) {
+ $scope.changed = true;
+ }
+ }, true);
+
+ $scope.save = function() {
+ if (!$scope.client.attributes["x509.subjectdn"]) {
+ Notifications.error("The SubjectDN must not be empty.");
+ } else {
+ Client.update({
+ realm : $scope.realm.realm,
+ client : $scope.client.id
+ }, $scope.client, function() {
+ $scope.changed = false;
+ $scope.clientCopy = angular.copy($scope.client);
+ Notifications.success("Client authentication configuration has been saved to the client.");
+ }, function() {
+ Notifications.error("The SubjectDN was not changed due to a problem.");
+ $scope.subjectdn = "error";
+ });
+ }
+ };
+
+ $scope.$watch(function() {
+ return $location.path();
+ }, function() {
+ $scope.path = $location.path().substring(1).split("/");
+ });
+
+ $scope.reset = function() {
+ $scope.client.attributes["x509.subjectdn"] = $scope.clientCopy.attributes["x509.subjectdn"];
+ $location.url("/realms/" + $scope.realm.realm + "/clients/" + $scope.client.id + "/credentials");
+ };
+});
+
module.controller('ClientSignedJWTCtrl', function($scope, $location, Client, ClientCertificate, Notifications, $route) {
var signingKeyInfo = ClientCertificate.get({ realm : $scope.realm.realm, client : $scope.client.id, attribute: 'jwt.credential' },
function() {
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html
new file mode 100644
index 0000000..28bafcd
--- /dev/null
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html
@@ -0,0 +1,21 @@
+<div>
+ <form class="form-horizontal no-margin-top" name="credentialForm" novalidate kc-read-only="!client.access.configure" data-ng-controller="ClientX509Ctrl">
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="subjectdn"><span class="required">*</span>{{:: 'subjectdn' | translate}}</label>
+ <kc-tooltip>{{:: 'subjectdn-tooltip' | translate}}</kc-tooltip>
+ <div class="col-sm-6">
+ <div class="row">
+ <div class="col-sm-6">
+ <input class="form-control" type="text" id="subjectdn" data-ng-model="client.attributes['x509.subjectdn']">
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <div class="col-md-10 col-md-offset-2" data-ng-show="client.access.configure">
+ <button kc-save data-ng-disabled="!changed" data-ng-click="save()">{{:: 'save' | translate}}</button>
+ <button kc-reset data-ng-disabled="!changed" data-ng-click="reset()">{{:: 'cancel' | translate}}</button>
+ </div>
+ </div>
+ </form>
+</div>