keycloak-memoizeit

Details

diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
index 417d41b..c402573 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
@@ -54,6 +54,7 @@ import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.security.SecureRandom;
 import java.security.cert.X509Certificate;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -409,14 +410,25 @@ public final class KeycloakModelUtils {
     }
 
 
-    public static List<String> resolveAttribute(UserModel user, String name) {
+    public static Collection<String> resolveAttribute(UserModel user, String name, boolean aggregateAttrs) {
         List<String> values = user.getAttribute(name);
-        if (!values.isEmpty()) return values;
+        Set<String> aggrValues = new HashSet<String>();
+        if (!values.isEmpty()) {
+            if (!aggregateAttrs) {
+                return values;
+            }
+            aggrValues.addAll(values);
+        }
         for (GroupModel group : user.getGroups()) {
             values = resolveAttribute(group, name);
-            if (values != null) return values;
+            if (values != null && !values.isEmpty()) {
+                if (!aggregateAttrs) {
+                    return values;
+                }
+                aggrValues.addAll(values);
+            }
         }
-        return Collections.emptyList();
+        return aggrValues;
     }
 
 
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java
index 12a1c1c..920059b 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java
@@ -26,6 +26,7 @@ import org.keycloak.provider.ProviderConfigProperty;
 import org.keycloak.representations.IDToken;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -57,6 +58,12 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
         property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
         configProperties.add(property);
 
+        property = new ProviderConfigProperty();
+        property.setName(ProtocolMapperUtils.AGGREGATE_ATTRS);
+        property.setLabel(ProtocolMapperUtils.AGGREGATE_ATTRS_LABEL);
+        property.setHelpText(ProtocolMapperUtils.AGGREGATE_ATTRS_HELP_TEXT);
+        property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
+        configProperties.add(property);
     }
 
     public static final String PROVIDER_ID = "oidc-usermodel-attribute-mapper";
@@ -90,7 +97,8 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
 
         UserModel user = userSession.getUser();
         String attributeName = mappingModel.getConfig().get(ProtocolMapperUtils.USER_ATTRIBUTE);
-        List<String> attributeValue = KeycloakModelUtils.resolveAttribute(user, attributeName);
+        boolean aggregateAttrs = Boolean.valueOf(mappingModel.getConfig().get(ProtocolMapperUtils.AGGREGATE_ATTRS));
+        Collection<String> attributeValue = KeycloakModelUtils.resolveAttribute(user, attributeName, aggregateAttrs);
         if (attributeValue == null) return;
         OIDCAttributeMapperHelper.mapClaim(token, mappingModel, attributeValue);
     }
@@ -99,6 +107,15 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
                                                         String userAttribute,
                                                         String tokenClaimName, String claimType,
                                                         boolean accessToken, boolean idToken, boolean multivalued) {
+        return createClaimMapper(name, userAttribute, tokenClaimName, claimType,
+                accessToken, idToken, multivalued, false);
+    }
+
+    public static ProtocolMapperModel createClaimMapper(String name,
+                                                        String userAttribute,
+                                                        String tokenClaimName, String claimType,
+                                                        boolean accessToken, boolean idToken,
+                                                        boolean multivalued, boolean aggregateAttrs) {
         ProtocolMapperModel mapper = OIDCAttributeMapperHelper.createClaimMapper(name, userAttribute,
                 tokenClaimName, claimType,
                 accessToken, idToken,
@@ -107,6 +124,9 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
         if (multivalued) {
             mapper.getConfig().put(ProtocolMapperUtils.MULTIVALUED, "true");
         }
+        if (aggregateAttrs) {
+            mapper.getConfig().put(ProtocolMapperUtils.AGGREGATE_ATTRS, "true");
+        }
 
         return mapper;
     }
diff --git a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java
index 4db5393..fd1133e 100755
--- a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java
+++ b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java
@@ -44,6 +44,7 @@ public class ProtocolMapperUtils {
     public static final String USER_ATTRIBUTE = "user.attribute";
     public static final String USER_SESSION_NOTE = "user.session.note";
     public static final String MULTIVALUED = "multivalued";
+    public static final String AGGREGATE_ATTRS = "aggregate.attrs";
     public static final String USER_MODEL_PROPERTY_LABEL = "usermodel.prop.label";
     public static final String USER_MODEL_PROPERTY_HELP_TEXT = "usermodel.prop.tooltip";
     public static final String USER_MODEL_ATTRIBUTE_LABEL = "usermodel.attr.label";
@@ -64,7 +65,9 @@ public class ProtocolMapperUtils {
     public static final String USER_SESSION_MODEL_NOTE_LABEL = "userSession.modelNote.label";
     public static final String USER_SESSION_MODEL_NOTE_HELP_TEXT = "userSession.modelNote.tooltip";
     public static final String MULTIVALUED_LABEL = "multivalued.label";
+    public static final String AGGREGATE_ATTRS_LABEL = "aggregate.attrs.label";
     public static final String MULTIVALUED_HELP_TEXT = "multivalued.tooltip";
+    public static final String AGGREGATE_ATTRS_HELP_TEXT = "aggregate.attrs.tooltip";
 
     // Role name mapper can move some roles to different positions
     public static final int PRIORITY_ROLE_NAMES_MAPPER = 10;
diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/AttributeStatementHelper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/AttributeStatementHelper.java
index d61966e..7b97b9a 100755
--- a/services/src/main/java/org/keycloak/protocol/saml/mappers/AttributeStatementHelper.java
+++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/AttributeStatementHelper.java
@@ -26,6 +26,7 @@ import org.keycloak.provider.ProviderConfigProperty;
 import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -53,7 +54,7 @@ public class AttributeStatementHelper {
     }
 
     public static void addAttributes(AttributeStatementType attributeStatement, ProtocolMapperModel mappingModel,
-                                    List<String> attributeValues) {
+                                    Collection<String> attributeValues) {
 
         AttributeType attribute = createAttributeType(mappingModel);
         attributeValues.forEach(attribute::addAttributeValue);
diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/UserAttributeStatementMapper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/UserAttributeStatementMapper.java
index ab8a5e4..0eb92e2 100755
--- a/services/src/main/java/org/keycloak/protocol/saml/mappers/UserAttributeStatementMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/UserAttributeStatementMapper.java
@@ -27,6 +27,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
 import org.keycloak.protocol.ProtocolMapperUtils;
 import org.keycloak.provider.ProviderConfigProperty;
 
+import java.util.Collection;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -48,6 +49,12 @@ public class UserAttributeStatementMapper extends AbstractSAMLProtocolMapper imp
         configProperties.add(property);
         AttributeStatementHelper.setConfigProperties(configProperties);
 
+        property = new ProviderConfigProperty();
+        property.setName(ProtocolMapperUtils.AGGREGATE_ATTRS);
+        property.setLabel(ProtocolMapperUtils.AGGREGATE_ATTRS_LABEL);
+        property.setHelpText(ProtocolMapperUtils.AGGREGATE_ATTRS_HELP_TEXT);
+        property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
+        configProperties.add(property);
     }
 
     public static final String PROVIDER_ID = "saml-user-attribute-mapper";
@@ -80,7 +87,8 @@ public class UserAttributeStatementMapper extends AbstractSAMLProtocolMapper imp
     public void transformAttributeStatement(AttributeStatementType attributeStatement, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
         UserModel user = userSession.getUser();
         String attributeName = mappingModel.getConfig().get(ProtocolMapperUtils.USER_ATTRIBUTE);
-        List<String> attributeValues = KeycloakModelUtils.resolveAttribute(user, attributeName);
+        boolean aggregateAttrs = Boolean.valueOf(mappingModel.getConfig().get(ProtocolMapperUtils.AGGREGATE_ATTRS));
+        Collection<String> attributeValues = KeycloakModelUtils.resolveAttribute(user, attributeName, aggregateAttrs);
         if (attributeValues.isEmpty()) return;
         AttributeStatementHelper.addAttributes(attributeStatement, mappingModel, attributeValues);
     }
diff --git a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java
index e795526..d4a7f64 100755
--- a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java
+++ b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java
@@ -202,6 +202,10 @@ public class SendUsernameServlet extends HttpServlet {
         for (String attr : principal.getAttributes("hardcoded-attribute")) {
             output += attr + ",";
         }
+        output += "<br /> group-attribute: ";
+        for (String attr : principal.getAttributes("group-attribute")) {
+            output += attr + ",";
+        }
 
         return output;
     }
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java
index 9611217..47a2b9b 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java
@@ -43,6 +43,9 @@ import java.net.URI;
 import java.net.URL;
 import java.security.KeyPair;
 import java.security.PublicKey;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -100,6 +103,7 @@ import org.junit.Test;
 import org.keycloak.admin.client.resource.ClientResource;
 import org.keycloak.admin.client.resource.ProtocolMappersResource;
 import org.keycloak.admin.client.resource.RoleScopeResource;
+import org.keycloak.admin.client.resource.UserResource;
 import org.keycloak.common.util.Base64;
 import org.keycloak.common.util.KeyUtils;
 import org.keycloak.common.util.PemUtils;
@@ -116,6 +120,7 @@ import org.keycloak.protocol.saml.mappers.AttributeStatementHelper;
 import org.keycloak.protocol.saml.mappers.RoleListMapper;
 import org.keycloak.representations.idm.ClientRepresentation;
 import org.keycloak.representations.idm.ComponentRepresentation;
+import org.keycloak.representations.idm.GroupRepresentation;
 import org.keycloak.representations.idm.ProtocolMapperRepresentation;
 import org.keycloak.representations.idm.RealmRepresentation;
 import org.keycloak.representations.idm.RoleRepresentation;
@@ -140,6 +145,7 @@ import org.keycloak.testsuite.auth.page.login.SAMLPostLoginTenant2;
 import org.keycloak.testsuite.page.AbstractPage;
 import org.keycloak.testsuite.saml.AbstractSamlTest;
 import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
+import org.keycloak.testsuite.util.ProtocolMapperUtil;
 import org.keycloak.testsuite.util.SamlClient;
 import org.keycloak.testsuite.util.SamlClient.Binding;
 import org.keycloak.testsuite.util.SamlClientBuilder;
@@ -1158,6 +1164,233 @@ public class SAMLServletAdapterTest extends AbstractServletsAdapterTest {
         Assert.assertThat(pageSource, not(containsString("SAML response: null")));
     }
 
+    private static List<String> parseCommaSeparatedAttributes(String body, String attribute) {
+        int start = body.indexOf(attribute) + attribute.length();
+        if (start == -1) {
+            return Collections.emptyList();
+        }
+        int end = body.indexOf(System.getProperty("line.separator"), start);
+        if (end == -1) {
+            end = body.length();
+        }
+        String values = body.substring(start, end);
+        String[] parts = values.split(",");
+        return Arrays.asList(parts);
+    }
+
+    @Test
+    public void testUserAttributeStatementMapperUserGroupsAggregate() throws Exception {
+        UserResource userResource = ApiUtil.findUserByUsernameId(testRealmResource(), "bburke");
+        UserRepresentation user = userResource.toRepresentation();
+        user.setAttributes(new HashMap<>());
+        user.getAttributes().put("group-value", Arrays.asList("user-value1"));
+        userResource.update(user);
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        testRealmResource().groups().add(group1);
+        group1 = testRealmResource().getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+
+        ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2);
+        ProtocolMappersResource protocolMappersResource = clientResource.getProtocolMappers();
+
+        Map<String, String> config = new LinkedHashMap<>();
+        config.put("attribute.nameformat", "Basic");
+        config.put("user.attribute", "group-value");
+        config.put("attribute.name", "group-attribute");
+        config.put("aggregate.attrs", "true");
+        createProtocolMapper(protocolMappersResource, "group-value", "saml", "saml-user-attribute-mapper", config);
+
+        try {
+            employee2ServletPage.navigateTo();
+            assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
+            testRealmSAMLPostLoginPage.form().login("bburke", "password");
+
+            driver.navigate().to(employee2ServletPage.toString() + "/getAttributes");
+            waitForPageToLoad();
+
+            String body = driver.findElement(By.xpath("//body")).getText();
+            List<String> values = parseCommaSeparatedAttributes(body, " group-attribute: ");
+            Assert.assertEquals(3, values.size());
+            Assert.assertTrue(values.contains("user-value1"));
+            Assert.assertTrue(values.contains("value1"));
+            Assert.assertTrue(values.contains("value2"));
+
+            employee2ServletPage.logout();
+            checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage);
+        } finally {
+            // revert
+            user.getAttributes().remove("group-value");
+            userResource.update(user);
+            userResource.leaveGroup(group1.getId());
+            testRealmResource().groups().group(group1.getId()).remove();
+            ProtocolMapperRepresentation mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappersResource, "saml", "group-value");
+            protocolMappersResource.delete(mapper.getId());
+        }
+    }
+
+    @Test
+    public void testUserAttributeStatementMapperUserGroupsNoAggregate() throws Exception {
+        UserResource userResource = ApiUtil.findUserByUsernameId(testRealmResource(), "bburke");
+        UserRepresentation user = userResource.toRepresentation();
+        user.setAttributes(new HashMap<>());
+        user.getAttributes().put("group-value", Arrays.asList("user-value1"));
+        userResource.update(user);
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        testRealmResource().groups().add(group1);
+        group1 = testRealmResource().getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+
+        ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2);
+        ProtocolMappersResource protocolMappersResource = clientResource.getProtocolMappers();
+
+        Map<String, String> config = new LinkedHashMap<>();
+        config.put("attribute.nameformat", "Basic");
+        config.put("user.attribute", "group-value");
+        config.put("attribute.name", "group-attribute");
+        createProtocolMapper(protocolMappersResource, "group-value", "saml", "saml-user-attribute-mapper", config);
+
+        try {
+            employee2ServletPage.navigateTo();
+            assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
+            testRealmSAMLPostLoginPage.form().login("bburke", "password");
+
+            driver.navigate().to(employee2ServletPage.toString() + "/getAttributes");
+            waitForPageToLoad();
+
+            String body = driver.findElement(By.xpath("//body")).getText();
+            List<String> values = parseCommaSeparatedAttributes(body, " group-attribute: ");
+            Assert.assertEquals(1, values.size());
+            Assert.assertTrue(values.contains("user-value1"));
+
+            employee2ServletPage.logout();
+            checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage);
+        } finally {
+            // revert
+            user.getAttributes().remove("group-value");
+            userResource.update(user);
+            userResource.leaveGroup(group1.getId());
+            testRealmResource().groups().group(group1.getId()).remove();
+            ProtocolMapperRepresentation mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappersResource, "saml", "group-value");
+            protocolMappersResource.delete(mapper.getId());
+        }
+    }
+
+    @Test
+    public void testUserAttributeStatementMapperGroupsAggregate() throws Exception {
+        UserResource userResource = ApiUtil.findUserByUsernameId(testRealmResource(), "bburke");
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        testRealmResource().groups().add(group1);
+        group1 = testRealmResource().getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        GroupRepresentation group2 = new GroupRepresentation();
+        group2.setName("group2");
+        group2.setAttributes(new HashMap<>());
+        group2.getAttributes().put("group-value", Arrays.asList("value2", "value3"));
+        testRealmResource().groups().add(group2);
+        group2 = testRealmResource().getGroupByPath("/group2");
+        userResource.joinGroup(group2.getId());
+
+        ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2);
+        ProtocolMappersResource protocolMappersResource = clientResource.getProtocolMappers();
+
+        Map<String, String> config = new LinkedHashMap<>();
+        config.put("attribute.nameformat", "Basic");
+        config.put("user.attribute", "group-value");
+        config.put("attribute.name", "group-attribute");
+        config.put("aggregate.attrs", "true");
+        createProtocolMapper(protocolMappersResource, "group-value", "saml", "saml-user-attribute-mapper", config);
+
+        try {
+            employee2ServletPage.navigateTo();
+            assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
+            testRealmSAMLPostLoginPage.form().login("bburke", "password");
+
+            driver.navigate().to(employee2ServletPage.toString() + "/getAttributes");
+            waitForPageToLoad();
+
+            String body = driver.findElement(By.xpath("//body")).getText();
+            List<String> values = parseCommaSeparatedAttributes(body, " group-attribute: ");
+            Assert.assertEquals(3, values.size());
+            Assert.assertTrue(values.contains("value1"));
+            Assert.assertTrue(values.contains("value2"));
+            Assert.assertTrue(values.contains("value3"));
+
+            employee2ServletPage.logout();
+            checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage);
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            testRealmResource().groups().group(group1.getId()).remove();
+            userResource.leaveGroup(group2.getId());
+            testRealmResource().groups().group(group2.getId()).remove();
+            ProtocolMapperRepresentation mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappersResource, "saml", "group-value");
+            protocolMappersResource.delete(mapper.getId());
+        }
+    }
+
+    @Test
+    public void testUserAttributeStatementMapperGroupsNoAggregate() throws Exception {
+        UserResource userResource = ApiUtil.findUserByUsernameId(testRealmResource(), "bburke");
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        testRealmResource().groups().add(group1);
+        group1 = testRealmResource().getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        GroupRepresentation group2 = new GroupRepresentation();
+        group2.setName("group2");
+        group2.setAttributes(new HashMap<>());
+        group2.getAttributes().put("group-value", Arrays.asList("value2", "value3"));
+        testRealmResource().groups().add(group2);
+        group2 = testRealmResource().getGroupByPath("/group2");
+        userResource.joinGroup(group2.getId());
+
+        ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2);
+        ProtocolMappersResource protocolMappersResource = clientResource.getProtocolMappers();
+
+        Map<String, String> config = new LinkedHashMap<>();
+        config.put("attribute.nameformat", "Basic");
+        config.put("user.attribute", "group-value");
+        config.put("attribute.name", "group-attribute");
+        createProtocolMapper(protocolMappersResource, "group-value", "saml", "saml-user-attribute-mapper", config);
+
+        try {
+            employee2ServletPage.navigateTo();
+            assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
+            testRealmSAMLPostLoginPage.form().login("bburke", "password");
+
+            driver.navigate().to(employee2ServletPage.toString() + "/getAttributes");
+            waitForPageToLoad();
+
+            String body = driver.findElement(By.xpath("//body")).getText();
+            List<String> values = parseCommaSeparatedAttributes(body, " group-attribute: ");
+            Assert.assertEquals(2, values.size());
+            Assert.assertTrue((values.contains("value1") && values.contains("value2"))
+                    || (values.contains("value2") && values.contains("value3")));
+
+            employee2ServletPage.logout();
+            checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage);
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            testRealmResource().groups().group(group1.getId()).remove();
+            userResource.leaveGroup(group2.getId());
+            testRealmResource().groups().group(group2.getId()).remove();
+            ProtocolMapperRepresentation mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappersResource, "saml", "group-value");
+            protocolMappersResource.delete(mapper.getId());
+        }
+    }
+
     @Test
     public void testAttributes() throws Exception {
         ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java
index 25efa56..27b2d47 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java
@@ -54,6 +54,7 @@ import javax.ws.rs.core.Response;
 
 import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -117,6 +118,11 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
         if (mapper != null) {
             protocolMappers.delete(mapper.getId());
         }
+
+        mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappers, OIDCLoginProtocol.LOGIN_PROTOCOL, "group-value");
+        if (mapper != null) {
+            protocolMappers.delete(mapper.getId());
+        }
     }
 
     @Override
@@ -734,6 +740,374 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
         deleteMappers(protocolMappers);
     }
 
+    @Test
+    public void testGroupAttributeUserOneGroupNoMultivalueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        UserRepresentation user = userResource.toRepresentation();
+        user.setAttributes(new HashMap<>());
+        user.getAttributes().put("group-value", Arrays.asList("user-value1", "user-value2"));
+        userResource.update(user);
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, false, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof String);
+            assertTrue("user-value1".equals(idToken.getOtherClaims().get("group-value")) ||
+                    "user-value2".equals(idToken.getOtherClaims().get("group-value")));
+        } finally {
+            // revert
+            user.getAttributes().remove("group-value");
+            userResource.update(user);
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeUserOneGroupMultivalueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        UserRepresentation user = userResource.toRepresentation();
+        user.setAttributes(new HashMap<>());
+        user.getAttributes().put("group-value", Arrays.asList("user-value1", "user-value2"));
+        userResource.update(user);
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(2, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("user-value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("user-value2"));
+        } finally {
+            // revert
+            user.getAttributes().remove("group-value");
+            userResource.update(user);
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeUserOneGroupMultivalueAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        UserRepresentation user = userResource.toRepresentation();
+        user.setAttributes(new HashMap<>());
+        user.getAttributes().put("group-value", Arrays.asList("user-value1", "user-value2"));
+        userResource.update(user);
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, true)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(4, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("user-value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("user-value2"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value2"));
+        } finally {
+            // revert
+            user.getAttributes().remove("group-value");
+            userResource.update(user);
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeOneGroupNoMultivalueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, false, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof String);
+            assertTrue("value1".equals(idToken.getOtherClaims().get("group-value"))
+                    || "value2".equals(idToken.getOtherClaims().get("group-value")));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeOneGroupMultiValueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(2, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value2"));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeOneGroupMultiValueAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create a group1 with two values
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, true)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(2, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value2"));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeTwoGroupNoMultivalueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create two groups with two values (one is the same value)
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        GroupRepresentation group2 = new GroupRepresentation();
+        group2.setName("group2");
+        group2.setAttributes(new HashMap<>());
+        group2.getAttributes().put("group-value", Arrays.asList("value2", "value3"));
+        adminClient.realm("test").groups().add(group2);
+        group2 = adminClient.realm("test").getGroupByPath("/group2");
+        userResource.joinGroup(group2.getId());
+
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, false, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof String);
+            assertTrue("value1".equals(idToken.getOtherClaims().get("group-value"))
+                    || "value2".equals(idToken.getOtherClaims().get("group-value"))
+                    || "value3".equals(idToken.getOtherClaims().get("group-value")));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            userResource.leaveGroup(group2.getId());
+            adminClient.realm("test").groups().group(group2.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeTwoGroupMultiValueNoAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create two groups with two values (one is the same value)
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        GroupRepresentation group2 = new GroupRepresentation();
+        group2.setName("group2");
+        group2.setAttributes(new HashMap<>());
+        group2.getAttributes().put("group-value", Arrays.asList("value2", "value3"));
+        adminClient.realm("test").groups().add(group2);
+        group2 = adminClient.realm("test").getGroupByPath("/group2");
+        userResource.joinGroup(group2.getId());
+
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, false)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(2, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue((((List) idToken.getOtherClaims().get("group-value")).contains("value1")
+                    && ((List) idToken.getOtherClaims().get("group-value")).contains("value2"))
+                    || (((List) idToken.getOtherClaims().get("group-value")).contains("value2")
+                    && ((List) idToken.getOtherClaims().get("group-value")).contains("value3")));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            userResource.leaveGroup(group2.getId());
+            adminClient.realm("test").groups().group(group2.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
+    @Test
+    public void testGroupAttributeTwoGroupMultiValueAggregate() throws Exception {
+        // get the user
+        UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
+        // create two groups with two values (one is the same value)
+        GroupRepresentation group1 = new GroupRepresentation();
+        group1.setName("group1");
+        group1.setAttributes(new HashMap<>());
+        group1.getAttributes().put("group-value", Arrays.asList("value1", "value2"));
+        adminClient.realm("test").groups().add(group1);
+        group1 = adminClient.realm("test").getGroupByPath("/group1");
+        userResource.joinGroup(group1.getId());
+        GroupRepresentation group2 = new GroupRepresentation();
+        group2.setName("group2");
+        group2.setAttributes(new HashMap<>());
+        group2.getAttributes().put("group-value", Arrays.asList("value2", "value3"));
+        adminClient.realm("test").groups().add(group2);
+        group2 = adminClient.realm("test").getGroupByPath("/group2");
+        userResource.joinGroup(group2.getId());
+
+        // create the attribute mapper
+        ProtocolMappersResource protocolMappers = findClientResourceByClientId(adminClient.realm("test"), "test-app").getProtocolMappers();
+        protocolMappers.createMapper(createClaimMapper("group-value", "group-value", "group-value", "String", true, true, true, true)).close();
+
+        try {
+            // test it
+            OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password");
+
+            IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+            assertNotNull(idToken.getOtherClaims());
+            assertNotNull(idToken.getOtherClaims().get("group-value"));
+            assertTrue(idToken.getOtherClaims().get("group-value") instanceof List);
+            assertEquals(3, ((List) idToken.getOtherClaims().get("group-value")).size());
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value1"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value2"));
+            assertTrue(((List) idToken.getOtherClaims().get("group-value")).contains("value3"));
+        } finally {
+            // revert
+            userResource.leaveGroup(group1.getId());
+            adminClient.realm("test").groups().group(group1.getId()).remove();
+            userResource.leaveGroup(group2.getId());
+            adminClient.realm("test").groups().group(group2.getId()).remove();
+            deleteMappers(protocolMappers);
+        }
+    }
+
     private void assertRoles(List<String> actualRoleList, String ...expectedRoles){
         Assert.assertNames(actualRoleList, expectedRoles);
     }
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java
index 16e807b..b9f0d68 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java
@@ -87,7 +87,17 @@ public class ProtocolMapperUtil {
                                                                  String tokenClaimName, String claimType,
                                                                  boolean accessToken, boolean idToken, boolean multivalued) {
         return ModelToRepresentation.toRepresentation(UserAttributeMapper.createClaimMapper(name, userAttribute, tokenClaimName,
-                claimType, accessToken, idToken, multivalued));
+                claimType, accessToken, idToken, multivalued, false));
+
+    }
+
+    public static ProtocolMapperRepresentation createClaimMapper(String name,
+                                                                 String userAttribute,
+                                                                 String tokenClaimName, String claimType,
+                                                                 boolean accessToken, boolean idToken,
+                                                                 boolean multivalued, boolean aggregateAttrs) {
+        return ModelToRepresentation.toRepresentation(UserAttributeMapper.createClaimMapper(name, userAttribute, tokenClaimName,
+                claimType, accessToken, idToken, multivalued, aggregateAttrs));
 
     }
 
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 8a7382a..175a5a1 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
@@ -206,6 +206,8 @@ userSession.modelNote.label=User Session Note
 userSession.modelNote.tooltip=Name of stored user session note within the UserSessionModel.note map.
 multivalued.label=Multivalued
 multivalued.tooltip=Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim
+aggregate.attrs.label=Aggregate attribute values
+aggregate.attrs.tooltip=Indicates if attribute values should be aggregated with the group attributes. If using OpenID Connect mapper the multivalued option needs to be enabled too in order to get all the values. Duplicated values are discarded and the order of values is not guaranteed with this option.
 selectRole.label=Select Role
 selectRole.tooltip=Enter role in the textbox to the left, or click this button to browse and select the role you want.
 tokenClaimName.label=Token Claim Name
@@ -1537,4 +1539,4 @@ advanced-client-settings.tooltip=Expand this section to configure advanced setti
 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.
 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
+subjectdn-tooltip=A regular expression for validating Subject DN in the Client Certificate. Use "(.*?)(?:$)" to match all kind of expressions.