Browse Source

Discord roles to Keycloak groups mapping support

pull/58/head
Pierre Jeanjean 2 years ago
parent
commit
3cc340cd71
  1. 2
      pom.xml
  2. 264
      src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java
  3. 40
      src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
  4. 35
      src/main/java/org/keycloak/social/discord/DiscordIdentityProviderConfig.java
  5. 1
      src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper
  6. 4
      src/main/resources/theme-resources/messages/admin-messages_en.properties
  7. 9
      src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html

2
pom.xml

@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<version.keycloak>17.0.0</version.keycloak>
<version.keycloak>20.0.0</version.keycloak>
</properties>
<dependencies>

264
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<ProviderConfigProperty> 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<ProviderConfigProperty> 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<String> newList = new ArrayList<>();
newList.add(newGroupsObj.toString());
newGroupsObj = newList;
}
// get user current groups
Set<GroupModel> 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<String> newGroupsNames = ((List<String>) newGroupsObj)
.stream()
.filter(t -> isEmpty(containsText) || t.contains(containsText))
.collect(Collectors.toSet());
// get new groups
Set<GroupModel> 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<GroupModel> removeGroups = getGroupsToBeRemoved(currentGroups, newGroups);
for (GroupModel group : removeGroups)
user.leaveGroup(group);
// get the groups where the user will be added
Set<GroupModel> 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<GroupModel> getNewGroups(RealmModel realm, Set<String> newGroupsNames, boolean createGroups) {
Set<GroupModel> 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<GroupModel> group = realm.getGroupsStream()
.filter(g -> g.getName().equals(name))
.findFirst();
return group.orElse(null);
}
private static Set<GroupModel> getGroupsToBeRemoved(Set<GroupModel> currentGroups, Set<GroupModel> newGroups) {
// perform a set difference
Set<GroupModel> 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<GroupModel> getGroupsToBeAdded(Set<GroupModel> currentGroups, Set<GroupModel> newGroups) {
// perform a set difference
Set<GroupModel> 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;
}
}

40
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<Disc
public static final String TOKEN_URL = "https://discord.com/api/oauth2/token";
public static final String PROFILE_URL = "https://discord.com/api/users/@me";
public static final String GROUP_URL = "https://discord.com/api/users/@me/guilds";
public static final String GUILD_MEMBER_URL = "https://discord.com/api/users/@me/guilds/%s/member";
public static final String DEFAULT_SCOPE = "identify email";
public static final String GUILDS_SCOPE = "guilds";
public static final String ROLES_SCOPE = "guilds.members.read";
public DiscordIdentityProvider(KeycloakSession session, DiscordIdentityProviderConfig config) {
super(session, config);
@ -94,6 +102,29 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider<Disc
throw new ErrorPageException(session, Response.Status.FORBIDDEN, Messages.INVALID_REQUESTER);
}
}
ArrayNode groups = JsonNodeFactory.instance.arrayNode();
if (getConfig().hasMappedRoles()) {
Map<String, HashMap<String, String>> 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<Disc
@Override
protected String getDefaultScopes() {
String scopes = DEFAULT_SCOPE;
if (getConfig().hasAllowedGuilds()) {
return DEFAULT_SCOPE + " " + GUILDS_SCOPE;
} else {
return DEFAULT_SCOPE;
scopes += " " + GUILDS_SCOPE;
}
if (getConfig().hasMappedRoles()) {
scopes += " " + ROLES_SCOPE;
}
return scopes;
}
}

35
src/main/java/org/keycloak/social/discord/DiscordIdentityProviderConfig.java

@ -19,6 +19,8 @@ package org.keycloak.social.discord;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@ -58,6 +60,39 @@ public class DiscordIdentityProviderConfig extends OAuth2IdentityProviderConfig
return Collections.emptySet();
}
public String getMappedRoles() {
return getConfig().get("mappedRoles");
}
public void setMappedRoles(String mappedRoles) {
getConfig().put("mappedRoles", mappedRoles);
}
public boolean hasMappedRoles() {
String mappedRoles = getConfig().get("mappedRoles");
return mappedRoles != null && !mappedRoles.trim().isEmpty();
}
public Map<String, HashMap<String, String>> getMappedRolesAsMap() {
if (hasMappedRoles()) {
String mappedRoles = getMappedRoles();
Map<String, HashMap<String, String>> 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);
}

1
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

4
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.
discord.mapped-roles.tooltip=Map Discord roles to Keycloak groups. The expected format is '<guild_id>:<role_id>:<group_name>'. 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.

9
src/main/resources/theme-resources/resources/partials/realm-identity-provider-discord-ext.html

@ -4,4 +4,11 @@
<input class="form-control" id="baseUrl" type="text" ng-model="identityProvider.config.allowedGuilds">
</div>
<kc-tooltip>{{:: 'discord.allowed-guilds.tooltip' | translate}}</kc-tooltip>
</div>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="mappedRoles">{{:: 'discord-mapped-roles' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="baseUrl" type="text" ng-model="identityProvider.config.mappedRoles">
</div>
<kc-tooltip>{{:: 'discord.mapped-roles.tooltip' | translate}}</kc-tooltip>
</div>
Loading…
Cancel
Save