Browse Source

Merge 607fe7ba42 into c097c0d7b6

pull/58/merge
Jeroen Nederlof 4 months ago
committed by GitHub
parent
commit
aef8c5ca37
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 36
      .github/workflows/maven.yml
  2. 13
      .idea/.gitignore
  3. 8
      .idea/indexLayout.xml
  4. 6
      .idea/vcs.xml
  5. 47
      CHANGELOG.md
  6. 16
      README.md
  7. 4
      pom.xml
  8. 289
      src/main/java/br/com/luizcarlosvianamelo/keycloak/broker/oidc/mappers/ClaimToGroupMapper.java
  9. 49
      src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java
  10. 43
      src/main/java/org/keycloak/social/discord/DiscordIdentityProviderConfig.java
  11. 12
      src/main/java/org/keycloak/social/discord/DiscordIdentityProviderFactory.java
  12. 1
      src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper

36
.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 }}

13
.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

8
.idea/indexLayout.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/vcs.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

47
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)

16
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`.

4
pom.xml

@ -7,11 +7,11 @@
<groupId>org.keycloak.extensions</groupId>
<artifactId>keycloak-discord</artifactId>
<version>0.6.2-SNAPSHOT</version>
<version>0.8.1-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<version.keycloak>26.0.5</version.keycloak>
<version.keycloak>26.2.5</version.keycloak>
</properties>
<dependencies>

289
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<ProviderConfigProperty> 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<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);
}
public static List<String> 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<String> newList = new ArrayList<>();
if (!List.class.isAssignableFrom(roles.getClass())) {
newList.add(roles.toString());
}
else {
newList = (List<String>)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<String> 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<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 = newGroupsList
.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.isEmpty();
}
}

49
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<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);
config.setAuthorizationUrl(AUTH_URL);
config.setTokenUrl(TOKEN_URL);
config.setUserInfoUrl(PROFILE_URL);
if (config.setPromptNone()) {
config.setPrompt("none");
}
}
@Override
@ -88,7 +98,7 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider<Disc
@Override
protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
log.debug("doGetFederatedIdentity()");
JsonNode profile = null;
JsonNode profile;
try {
profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson();
} catch (Exception e) {
@ -100,6 +110,32 @@ 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;
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<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;
}
}

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

@ -22,6 +22,8 @@ import org.keycloak.models.IdentityProviderModel;
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;
@ -41,6 +43,14 @@ public class DiscordIdentityProviderConfig extends OAuth2IdentityProviderConfig
return getConfig().get("allowedGuilds");
}
public boolean setPromptNone() {
if(getConfig().containsKey("promptNone")) {
return true;
} else {
return false;
}
}
public void setAllowedGuilds(String allowedGuilds) {
getConfig().put("allowedGuilds", allowedGuilds);
}
@ -58,6 +68,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);
}

12
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 '<guild_id>:<role_id>:<group_name>'. 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();
}

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
Loading…
Cancel
Save