diff --git a/pom.xml b/pom.xml
index 0bcc03a..2d6cdb1 100755
--- a/pom.xml
+++ b/pom.xml
@@ -11,7 +11,7 @@
jar
- 17.0.0
+ 20.0.0
diff --git a/src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java b/src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java
new file mode 100644
index 0000000..86a042a
--- /dev/null
+++ b/src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java
@@ -0,0 +1,264 @@
+package br.com.luizcarlosvianamelo.keycloak.broker.oidc.mappers;
+
+import org.jboss.logging.Logger;
+import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory;
+import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
+import org.keycloak.broker.oidc.mappers.AbstractClaimMapper;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.models.*;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import org.keycloak.social.discord.DiscordIdentityProviderFactory;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Class with the implementation of the identity provider mapper that sync the
+ * user's groups received from an external IdP into the Keycloak groups.
+ *
+ * @author Luiz Carlos Viana Melo
+ */
+public class ClaimToGroupMapper extends AbstractClaimMapper {
+
+ // logger ------------------------------------------------
+
+ private static final Logger logger = Logger.getLogger(ClaimToGroupMapper.class);
+
+ // global properties -------------------------------------
+
+ private static final String PROVIDER_ID = "oidc-group-idp-mapper";
+
+ private static final String[] COMPATIBLE_PROVIDERS = {
+ KeycloakOIDCIdentityProviderFactory.PROVIDER_ID,
+ OIDCIdentityProviderFactory.PROVIDER_ID,
+ DiscordIdentityProviderFactory.PROVIDER_ID
+ };
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ private static final String CONTAINS_TEXT = "contains_text";
+
+ private static final String CREATE_GROUPS = "create_groups";
+
+ static {
+ ProviderConfigProperty property;
+
+ property = new ProviderConfigProperty();
+ property.setName(CLAIM);
+ property.setLabel("Claim");
+ property.setHelpText("Name of claim to search for in token. This claim must be a string array with " +
+ "the names of the groups which the user is member. You can reference nested claims using a " +
+ "'.', i.e. 'address.locality'. To use dot (.) literally, escape it with backslash (\\.)");
+
+ property.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(property);
+
+ property = new ProviderConfigProperty();
+ property.setName(CONTAINS_TEXT);
+ property.setLabel("Contains text");
+ property.setHelpText("Only sync groups that contains this text in its name. If empty, sync all groups.");
+
+ property.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(property);
+
+ property = new ProviderConfigProperty();
+ property.setName(CREATE_GROUPS);
+ property.setLabel("Create groups if not exists");
+ property.setHelpText("Indicates if missing groups must be created in the realms. Otherwise, they will " +
+ "be ignored.");
+
+ property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
+ CONFIG_PROPERTIES.add(property);
+ }
+
+ // properties --------------------------------------------
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String[] getCompatibleProviders() {
+ return COMPATIBLE_PROVIDERS;
+ }
+
+ @Override
+ public String getDisplayCategory() {
+ return "Group Importer";
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Claim to Group Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "If a claim exists, sync the IdP user's groups with realm groups";
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ // actions -----------------------------------------------
+
+
+ @Override
+ public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
+ super.importNewUser(session, realm, user, mapperModel, context);
+
+ this.syncGroups(realm, user, mapperModel, context);
+ }
+
+ @Override
+ public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
+
+ this.syncGroups(realm, user, mapperModel, context);
+ }
+
+ private void syncGroups(RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
+
+ // check configurations
+ String groupClaimName = mapperModel.getConfig().get(CLAIM);
+ String containsText = mapperModel.getConfig().get(CONTAINS_TEXT);
+ boolean createGroups = Boolean.parseBoolean(mapperModel.getConfig().get(CREATE_GROUPS));
+
+ // do nothing if no claim was adjusted
+ if (isEmpty(groupClaimName))
+ return;
+
+ // get new groups
+ Object newGroupsObj = getClaimValue(context, groupClaimName);
+ // don't modify groups membership if the claim was not found
+ if (newGroupsObj == null) {
+ logger.debugf("Realm [%s], IdP [%s]: no group claim (claim name: [%s]) for user [%s], ignoring...",
+ realm.getName(),
+ mapperModel.getIdentityProviderAlias(),
+ groupClaimName,
+ user.getUsername());
+ return;
+ }
+
+ logger.debugf("Realm [%s], IdP [%s]: starting mapping groups for user [%s]",
+ realm.getName(),
+ mapperModel.getIdentityProviderAlias(),
+ user.getUsername());
+
+ // convert to string list if not list
+ if (!List.class.isAssignableFrom(newGroupsObj.getClass())) {
+ List newList = new ArrayList<>();
+ newList.add(newGroupsObj.toString());
+ newGroupsObj = newList;
+ }
+
+ // get user current groups
+ Set currentGroups = user.getGroupsStream()
+ .filter(g -> isEmpty(containsText) || g.getName().contains(containsText))
+ .collect(Collectors.toSet());
+
+ logger.debugf("Realm [%s], IdP [%s]: current groups for user [%s]: %s",
+ realm.getName(),
+ mapperModel.getIdentityProviderAlias(),
+ user.getUsername(),
+ currentGroups
+ .stream()
+ .map(GroupModel::getName)
+ .collect(Collectors.joining(","))
+ );
+
+ // filter the groups by its name
+ @SuppressWarnings("unchecked")
+ Set newGroupsNames = ((List) newGroupsObj)
+ .stream()
+ .filter(t -> isEmpty(containsText) || t.contains(containsText))
+ .collect(Collectors.toSet());
+
+ // get new groups
+ Set newGroups = getNewGroups(realm, newGroupsNames, createGroups);
+
+ logger.debugf("Realm [%s], IdP [%s]: new groups for user [%s]: %s",
+ realm.getName(),
+ mapperModel.getIdentityProviderAlias(),
+ user.getUsername(),
+ newGroups
+ .stream()
+ .map(GroupModel::getName)
+ .collect(Collectors.joining(","))
+ );
+
+ // get the groups from which the user will be removed
+ Set removeGroups = getGroupsToBeRemoved(currentGroups, newGroups);
+ for (GroupModel group : removeGroups)
+ user.leaveGroup(group);
+
+ // get the groups where the user will be added
+ Set addGroups = getGroupsToBeAdded(currentGroups, newGroups);
+ for (GroupModel group : addGroups)
+ user.joinGroup(group);
+
+ logger.debugf("Realm [%s], IdP [%s]: finishing mapping groups for user [%s]",
+ realm.getName(),
+ mapperModel.getIdentityProviderAlias(),
+ user.getUsername());
+ }
+
+ private Set getNewGroups(RealmModel realm, Set newGroupsNames, boolean createGroups) {
+
+ Set groups = new HashSet<>();
+
+ for (String groupName : newGroupsNames) {
+ GroupModel group = getGroupByName(realm, groupName);
+
+ // create group if not found
+ if (group == null && createGroups) {
+ logger.debugf("Realm [%s]: creating group [%s]",
+ realm.getName(),
+ groupName);
+
+ group = realm.createGroup(groupName);
+ }
+
+ if (group != null)
+ groups.add(group);
+ }
+
+ return groups;
+ }
+
+ private static GroupModel getGroupByName(RealmModel realm, String name) {
+
+ Optional group = realm.getGroupsStream()
+ .filter(g -> g.getName().equals(name))
+ .findFirst();
+
+ return group.orElse(null);
+ }
+
+ private static Set getGroupsToBeRemoved(Set currentGroups, Set newGroups) {
+ // perform a set difference
+ Set resultSet = new HashSet<>(currentGroups);
+
+ // (Current - New) will result in a set with the groups from which the user will be removed
+ resultSet.removeAll(newGroups);
+
+ return resultSet;
+ }
+
+ private static Set getGroupsToBeAdded(Set currentGroups, Set newGroups) {
+ // perform a set difference
+ Set resultSet = new HashSet<>(newGroups);
+
+ // (New - Current) will result in a set with the groups where the user will be added
+ resultSet.removeAll(currentGroups);
+
+ return resultSet;
+ }
+
+ private static boolean isEmpty(String str) {
+ return str == null || str.length() == 0;
+ }
+}
diff --git a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
index 10f183e..3b3ba2b 100755
--- a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
+++ b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
@@ -18,6 +18,9 @@
package org.keycloak.social.discord;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import org.jboss.logging.Logger;
import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
@@ -31,6 +34,9 @@ import org.keycloak.services.ErrorPageException;
import org.keycloak.services.messages.Messages;
import javax.ws.rs.core.Response;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
import java.util.Set;
/**
@@ -45,8 +51,10 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider> mappedRoles = getConfig().getMappedRolesAsMap();
+ for (String guild : mappedRoles.keySet()) {
+ JsonNode guildMember = null;
+ try {
+ guildMember = SimpleHttp.doGet(String.format(GUILD_MEMBER_URL, guild), session).header("Authorization", "Bearer " + accessToken).asJson();
+ for (JsonNode role : guildMember.get("roles")) {
+ String roleString = role.textValue();
+ if (mappedRoles.get(guild).containsKey(roleString)) {
+ groups.add("discord-" + mappedRoles.get(guild).get(roleString));
+ }
+ }
+ } catch (Exception e) {
+ throw new IdentityBrokerException("Could not obtain guild member data from discord.", e);
+ }
+ }
+ }
+ if (profile instanceof ObjectNode) {
+ ((ObjectNode) profile).put("discord-groups", groups);
+ }
+
return extractIdentityFromProfile(null, profile);
}
@@ -115,10 +146,13 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider> getMappedRolesAsMap() {
+ if (hasMappedRoles()) {
+ String mappedRoles = getMappedRoles();
+ Map> parsedRoles = new HashMap<>();
+ for (String rawRole : mappedRoles.split(",")) {
+ rawRole = rawRole.trim();
+ String fragments[] = rawRole.split(":");
+ if (fragments.length != 3) {
+ continue;
+ }
+ if (!parsedRoles.containsKey(fragments[0])) {
+ parsedRoles.put(fragments[0], new HashMap<>());
+ }
+ parsedRoles.get(fragments[0]).put(fragments[1], fragments[2]);
+ }
+ return parsedRoles;
+ }
+ return Collections.emptyMap();
+ }
+
public void setPrompt(String prompt) {
getConfig().put("prompt", prompt);
}
diff --git a/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper
index fd0a661..5ce2a4f 100644
--- a/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper
+++ b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper
@@ -1 +1,2 @@
org.keycloak.social.discord.DiscordUserAttributeMapper
+br.com.luizcarlosvianamelo.keycloak.broker.oidc.mappers.ClaimToGroupMapper
diff --git a/src/main/resources/theme-resources/messages/admin-messages_en.properties b/src/main/resources/theme-resources/messages/admin-messages_en.properties
index 8b39257..cff564f 100755
--- a/src/main/resources/theme-resources/messages/admin-messages_en.properties
+++ b/src/main/resources/theme-resources/messages/admin-messages_en.properties
@@ -1,7 +1,9 @@
discord-client-id=Client Id
discord-client-secret=Client Secret
discord-allowed-guilds=Guild Id(s) to allow federation
+discord-mapped-roles=Discord Roles mapping
discord.client-id.tooltip=Client Id for the application you created in your discord developer portal.
discord.client-secret.tooltip=Client Secret for the application that you created in your discord developer portal.
discord.allowed-guilds.tooltip=If you want to allow federation for specific guild, enter the guild id. Please use a comma as a separator for multiple guilds.
-discord.default-scopes.tooltip=The scopes to be sent when asking for authorization. See discord OAuth2 documentation for possible values. If you do not specify anything, scope defaults to 'identify email' In addition, plus 'guilds' if you enter guild id(s) to allow federation.
\ No newline at end of file
+discord.mapped-roles.tooltip=Map Discord roles to Keycloak groups. The expected format is '::'. Use a comma as a separator for multiple mappings.
+discord.default-scopes.tooltip=The scopes to be sent when asking for authorization. See discord OAuth2 documentation for possible values. If you do not specify anything, scope defaults to 'identify email' In addition, plus 'guilds' if you enter guild id(s) to allow federation.
diff --git a/src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html b/src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html
index 320fa2c..48224c1 100755
--- a/src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html
+++ b/src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html
@@ -4,4 +4,11 @@
{{:: 'discord.allowed-guilds.tooltip' | translate}}
-
\ No newline at end of file
+
+