diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 0000000..b45aef9
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,36 @@
+name: Maven CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ cache: 'maven'
+
+ - name: Build with Maven
+ run: mvn -B package
+
+ - name: Locate built JARfile
+ id: jar
+ run: echo "jarfile=$(find target/ -name "keycloak-discord-*.jar" -not -name "*slim*" -not -name "*source*")" >> $GITHUB_OUTPUT
+
+ - name: Set Artifact name
+ id: jarname
+ run: echo "jarname=$(find target/ -name "keycloak-discord-*.jar" -not -name "*slim*" -not -name "*source*" | sed 's:.*/::')" >> $GITHUB_OUTPUT
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.jarname.outputs.jarname }}
+ path: ${{ steps.jar.outputs.jarfile }}
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..5ddd176
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/.idea.keycloak-discord.iml
+/modules.xml
+/contentModel.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d11611..05b493e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,50 @@
+# [0.8.0](https://github.com/maaroen/keycloak-discord/compare/v0.7.0...v0.8.0) (2025-07-02)
+
+
+### Features
+
+* update to keycloak 26.0.5 ([bb66494](https://github.com/maaroen/keycloak-discord/commit/bb66494085a170d046d2c377e53548146cf90fd9))
+* update to keycloak 26.2.5 ([edf6dbb](https://github.com/maaroen/keycloak-discord/commit/edf6dbb35464353b6bcbfdf5c652e864bb557cce))
+* update to keycloak 26.2.5 ([9f0673a](https://github.com/maaroen/keycloak-discord/commit/9f0673a5de15ad7e2a813a99aba9ed21cec0c596))
+
+# [0.8.0](https://github.com/maaroen/keycloak-discord/compare/v0.7.0...v0.8.0) (2025-07-02)
+
+
+### Features
+
+* update to keycloak 26.0.5 ([bb66494](https://github.com/maaroen/keycloak-discord/commit/bb66494085a170d046d2c377e53548146cf90fd9))
+* update to keycloak 26.2.5 ([edf6dbb](https://github.com/maaroen/keycloak-discord/commit/edf6dbb35464353b6bcbfdf5c652e864bb557cce))
+* update to keycloak 26.2.5 ([9f0673a](https://github.com/maaroen/keycloak-discord/commit/9f0673a5de15ad7e2a813a99aba9ed21cec0c596))
+
+# [0.9.0](https://github.com/maaroen/keycloak-discord/compare/v0.8.0...v0.9.0) (2025-07-02)
+
+
+### Features
+
+* update to keycloak 26.2.5 ([edf6dbb](https://github.com/maaroen/keycloak-discord/commit/edf6dbb35464353b6bcbfdf5c652e864bb557cce))
+
+# [0.8.0](https://github.com/maaroen/keycloak-discord/compare/v0.7.0...v0.8.0) (2025-07-02)
+
+
+### Features
+
+* update to keycloak 26.2.5 ([9f0673a](https://github.com/maaroen/keycloak-discord/commit/9f0673a5de15ad7e2a813a99aba9ed21cec0c596))
+
+# [0.8.0](https://github.com/maaroen/keycloak-discord/compare/v0.7.0...v0.8.0) (2025-07-02)
+
+
+### Features
+
+* update to keycloak 26.2.5 ([9f0673a](https://github.com/maaroen/keycloak-discord/commit/9f0673a5de15ad7e2a813a99aba9ed21cec0c596))
+
+# [0.7.0](https://github.com/maaroen/keycloak-discord/compare/v0.6.1...v0.7.0) (2025-03-08)
+
+### Features
+* Updated to keycloak 26.1.3
+* Added Discord role syncing support (work done by [NotActuallyTerry](https://github.com/NotActuallyTerry/keycloak-discord))
+* Added a fix to also sync roles (delete all) if no roles were returned for the discord guild (Inspired by [pierrearma](https://github.com/NotActuallyTerry/keycloak-discord/pull/2))
+
+
## [0.6.1](https://github.com/wadahiro/keycloak-discord/compare/v0.6.0...v0.6.1) (2024-11-02)
diff --git a/README.md b/README.md
index e5df20b..c5c7824 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,22 @@ Note: You don't need to setup the theme in `master` realm from v0.3.0.
3. (Optional) Set Guild Id(s) to allow federation if you want.
+### Syncing roles
+
+To sync roles from Discord -> Keycloak, do the following:
+
+1. Under the `discord` Identity Provider, fill out `Discord Roles mapping` value with the roles you want synced:
+ - The format is `Discord-Guild-ID:Discord-Role-ID:Group-Name`, like so: `613425648685547541:613426529623605268:discord-devs-moderators`
+ - You can specify multiple roles by separating them with commas: `613425648685547541:613426529623605268:discord-devs-moderators,613425648685547541:936746847437983786:discord-devs-modmail`
+ - If you want to add a role just because the user is on the server, you can use the everyone role ID, which is the same as the guild ID : `613425648685547541:613425648685547541:everyone`
+3. Set up a Mapper under the `discord` Identity Provider:
+ - Set Mapper Type to `Claim to Group Mapper`
+ - Set Claim to `discord-groups`
+ - Tick Create Groups if not exists
+
+If the above doesn't get role syncing working, fiddle around with the Sync mode override. (I have mine set to `Force`, so it re-imports info on every login)
+
+
## Source Build
Clone this repository and run `mvn package`.
diff --git a/pom.xml b/pom.xml
index 6aed171..baaadaa 100755
--- a/pom.xml
+++ b/pom.xml
@@ -7,11 +7,11 @@
org.keycloak.extensions
keycloak-discord
- 0.6.2-SNAPSHOT
+ 0.8.1-SNAPSHOT
jar
- 26.0.5
+ 26.2.5
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..b3200f5
--- /dev/null
+++ b/src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java
@@ -0,0 +1,289 @@
+package br.com.luizcarlosvianamelo.keycloak.broker.oidc.mappers;
+
+import org.jboss.logging.Logger;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory;
+import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
+import org.keycloak.broker.oidc.OIDCIdentityProvider;
+import org.keycloak.broker.oidc.mappers.AbstractClaimMapper;
+import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
+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";
+
+ private static final String CLEAR_ROLES_IF_NONE = "clearRolesIfNone";
+
+ 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);
+
+ property = new ProviderConfigProperty();
+ property.setName(CLEAR_ROLES_IF_NONE);
+ property.setLabel("Clear discord roles if no roles found");
+ property.setHelpText("Should Discord roles be cleared out if no roles can be retrieved for example when a user is no longer part of the discord server");
+
+ 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);
+ }
+
+ public static List getClaimValue(BrokeredIdentityContext context, String claim) {
+ JsonNode profileJsonNode = (JsonNode) context.getContextData().get(OIDCIdentityProvider.USER_INFO);
+ var roles = AbstractJsonUserAttributeMapper.getJsonValue(profileJsonNode, claim);
+ if(roles == null) {
+ return new ArrayList<>();
+ }
+ // convert to string list if not list
+ List newList = new ArrayList<>();
+ if (!List.class.isAssignableFrom(roles.getClass())) {
+ newList.add(roles.toString());
+ }
+ else {
+ newList = (List)roles;
+ }
+ return newList;
+ }
+
+ 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
+ List newGroupsList = getClaimValue(context, groupClaimName);
+ boolean clearRolesIfNone = Boolean.parseBoolean(mapperModel.getConfig().get(CLEAR_ROLES_IF_NONE));
+ // Clear roles if config option enabled
+ if (newGroupsList.isEmpty() && !clearRolesIfNone) {
+ 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());
+
+
+ // 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 = newGroupsList
+ .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.isEmpty();
+ }
+}
diff --git a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
index b14fc3e..a82a232 100755
--- a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
+++ b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
@@ -18,7 +18,9 @@
package org.keycloak.social.discord;
import com.fasterxml.jackson.databind.JsonNode;
-import jakarta.ws.rs.core.Response;
+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 +33,9 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.messages.Messages;
+import jakarta.ws.rs.core.Response;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Set;
/**
@@ -45,14 +50,19 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider> mappedRoles = getConfig().getMappedRolesAsMap();
+ for (String guild : mappedRoles.keySet()) {
+ JsonNode guildMember;
+ try {
+ guildMember = SimpleHttp.doGet(String.format(GUILD_MEMBER_URL, guild), session).header("Authorization", "Bearer " + accessToken).asJson();
+ if (guildMember.has("joined_at") && mappedRoles.get(guild).containsKey(guild) ) {
+ groups.add(mappedRoles.get(guild).get(guild));
+ }
+ for (JsonNode role : guildMember.get("roles")) {
+ String roleString = role.textValue();
+ if (mappedRoles.get(guild).containsKey(roleString)) {
+ groups.add(mappedRoles.get(guild).get(roleString));
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Could not obtain guild member data from discord.");
+ }
+ }
+ }
+ if (profile instanceof ObjectNode) {
+ ((ObjectNode) profile).set("discord-groups", groups);
+ }
+
return extractIdentityFromProfile(null, profile);
}
@@ -121,10 +157,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/java/org/keycloak/social/discord/DiscordIdentityProviderFactory.java b/src/main/java/org/keycloak/social/discord/DiscordIdentityProviderFactory.java
index d441458..c25ca5e 100755
--- a/src/main/java/org/keycloak/social/discord/DiscordIdentityProviderFactory.java
+++ b/src/main/java/org/keycloak/social/discord/DiscordIdentityProviderFactory.java
@@ -58,6 +58,18 @@ public class DiscordIdentityProviderFactory extends AbstractIdentityProviderFact
.label("Guild Id(s) to allow federation")
.helpText("If you want to allow federation for specific guild, enter the guild id. Please use a comma as a separator for multiple guilds.")
.add()
+ .property()
+ .name("mappedRoles")
+ .type(ProviderConfigProperty.STRING_TYPE)
+ .label("Discord Roles mapping")
+ .helpText("Map Discord roles to Keycloak groups. The expected format is '::'. Use a comma as a separator for multiple mappings.")
+ .add()
+ .property()
+ .name("promptNone")
+ .type("boolean")
+ .label("Skip Discord prompt")
+ .helpText("Should Discord skip the prompt for users that have already granted access to our application?")
+ .add()
.build();
}
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