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