diff --git a/pom.xml b/pom.xml index 6aed171..06a1616 100755 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,8 @@ 26.0.5 + 6.1.0-M1 + 5.21.0 @@ -39,6 +41,30 @@ provided ${version.keycloak} + + org.junit.jupiter + junit-jupiter + test + ${version.junit} + + + org.junit.platform + junit-platform-commons + test + ${version.junit} + + + org.mockito + mockito-core + test + ${version.mockito} + + + org.mockito + mockito-junit-jupiter + test + ${version.mockito} + diff --git a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java index b14fc3e..115370c 100755 --- a/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java +++ b/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java @@ -18,6 +18,7 @@ package org.keycloak.social.discord; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; @@ -32,6 +33,7 @@ import org.keycloak.services.ErrorPageException; import org.keycloak.services.messages.Messages; import java.util.Set; +import java.util.regex.Pattern; /** * @author Hiroyuki Wada @@ -45,9 +47,13 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProviderDiscord returns an avatar hash (or null), but OIDC expects a direct URL + * to the image in the 'picture' claim.

+ * @param user The context where the 'picture' attribute will be set + * @param profile The raw Discord user profile JSON containing the 'avatar' hash + */ + private void setUserPicture(BrokeredIdentityContext user, JsonNode profile) { + if (user.getId() == null || !DISCORD_ID_PATTERN.matcher(user.getId()).matches()) { + return; + } + + String avatarHash = getJsonProperty(profile, "avatar"); + if (avatarHash == null || avatarHash.isEmpty() || !AVATAR_HASH_PATTERN.matcher(avatarHash).matches()) { + return; + } + String extension = "png"; + if (avatarHash.startsWith("a_")) { + extension = "gif"; + } + // TODO Image size to be configured via provider config in the future + String finalURL = String.format(USER_PICTURE_URL, user.getId(), avatarHash, extension, "256"); + user.setUserAttribute("picture", finalURL); + if (profile instanceof ObjectNode objectNodeProfile) { + objectNodeProfile.put("picture", finalURL); + } + } + @Override protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { log.debug("doGetFederatedIdentity()"); @@ -95,11 +132,10 @@ public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider !s.isBlank()) + .collect(Collectors.joining(",")); + getConfig().put(ALLOWED_GUILDS, cleanGuilds); + } else { + getConfig().put(ALLOWED_GUILDS, null); + } } public boolean hasAllowedGuilds() { - String guilds = getConfig().get("allowedGuilds"); + String guilds = getConfig().get(ALLOWED_GUILDS); return guilds != null && !guilds.trim().isEmpty(); } public Set getAllowedGuildsAsSet() { if (hasAllowedGuilds()) { - String guilds = getConfig().get("allowedGuilds"); - return Arrays.stream(guilds.split(",")).map(x -> x.trim()).collect(Collectors.toSet()); + String guilds = getConfig().get(ALLOWED_GUILDS); + return Arrays.stream(guilds.split(",")).map(String::trim).collect(Collectors.toSet()); } return Collections.emptySet(); } public void setPrompt(String prompt) { - getConfig().put("prompt", prompt); + getConfig().put(PROMPT, prompt); } } diff --git a/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderConfigTest.java b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderConfigTest.java new file mode 100644 index 0000000..b154bba --- /dev/null +++ b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderConfigTest.java @@ -0,0 +1,68 @@ +package org.keycloak.social.discord; + +import org.junit.jupiter.api.Test; +import org.keycloak.models.IdentityProviderModel; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DiscordIdentityProviderConfigTest { + + @Test + void given_noAllowedGuilds_expect_emptyResult() { + IdentityProviderModel model = new IdentityProviderModel(); + DiscordIdentityProviderConfig config = new DiscordIdentityProviderConfig(model); + + assertFalse(config.hasAllowedGuilds()); + + config.setAllowedGuilds(""); + assertFalse(config.hasAllowedGuilds()); + assertTrue(config.getAllowedGuildsAsSet().isEmpty()); + + config.setAllowedGuilds(" "); + assertFalse(config.hasAllowedGuilds()); + assertTrue(config.getAllowedGuildsAsSet().isEmpty()); + + config.setAllowedGuilds(",,,"); + assertFalse(config.hasAllowedGuilds()); + assertTrue(config.getAllowedGuildsAsSet().isEmpty()); + + config.setAllowedGuilds(", ,, , ,"); + assertFalse(config.hasAllowedGuilds()); + assertTrue(config.getAllowedGuildsAsSet().isEmpty()); + } + + @Test + void given_allowedGuildsPresent_expect_results() { + List guilds = List.of("0123456789123456789", "9876543210987654321"); + String guildsAsString = "0123456789123456789,9876543210987654321"; + + IdentityProviderModel model = new IdentityProviderModel(); + DiscordIdentityProviderConfig config = new DiscordIdentityProviderConfig(model); + + String expectedGuild = guilds.get(0); + config.setAllowedGuilds(expectedGuild); + assertTrue(config.hasAllowedGuilds()); + assertEquals(1, config.getAllowedGuildsAsSet().size()); + assertTrue(config.getAllowedGuildsAsSet().contains(expectedGuild)); + assertEquals(expectedGuild, config.getAllowedGuilds()); + + config.setAllowedGuilds("," + expectedGuild + ",,"); + assertTrue(config.hasAllowedGuilds()); + assertEquals(1, config.getAllowedGuildsAsSet().size()); + assertTrue(config.getAllowedGuildsAsSet().contains(expectedGuild)); + + config.setAllowedGuilds(String.join(",", guilds)); + assertTrue(config.hasAllowedGuilds()); + assertEquals(guilds.size(), config.getAllowedGuildsAsSet().size()); + assertTrue(config.getAllowedGuildsAsSet().containsAll(guilds)); + assertEquals(guildsAsString, config.getAllowedGuilds()); + + config.setAllowedGuilds(String.join(", ,, , ,", guilds)); + assertTrue(config.hasAllowedGuilds()); + assertEquals(guilds.size(), config.getAllowedGuildsAsSet().size()); + assertTrue(config.getAllowedGuildsAsSet().containsAll(guilds)); + assertEquals(guildsAsString, config.getAllowedGuilds()); + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderFactoryTest.java b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderFactoryTest.java new file mode 100644 index 0000000..120543a --- /dev/null +++ b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderFactoryTest.java @@ -0,0 +1,18 @@ +package org.keycloak.social.discord; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DiscordIdentityProviderFactoryTest { + + @Test + void getName() { + DiscordIdentityProviderFactory factory = new DiscordIdentityProviderFactory(); + + String name = factory.getName(); + assertNotNull(name); + assertNotEquals(0, name.length()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderTest.java b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderTest.java new file mode 100644 index 0000000..a244f84 --- /dev/null +++ b/src/test/java/org/keycloak/social/discord/DiscordIdentityProviderTest.java @@ -0,0 +1,133 @@ +package org.keycloak.social.discord; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.KeycloakSession; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DiscordIdentityProviderTest { + + DiscordIdentityProvider provider; + + ObjectMapper mapper; + + @Mock + KeycloakSession session; + + @Mock + DiscordIdentityProviderConfig config; + + @BeforeEach + void setUp() { + when(config.getAlias()).thenReturn("discord"); + + provider = new DiscordIdentityProvider(session, config); + + mapper = new ObjectMapper(); + } + + @Test + void testNameDiscriminatorHandling() throws Exception { + String jsonProfileLegacy = """ +{ + "id": "80351110224678912", + "username": "Nelly", + "discriminator": "1337", + "email": "nelly@discord.com" +} + """; + JsonNode profile = mapper.readTree(jsonProfileLegacy); + BrokeredIdentityContext user = provider.extractIdentityFromProfile(null, profile); + + assertEquals("80351110224678912", user.getId()); + assertEquals("nelly#1337", user.getUsername()); + + } + + @Test + void testNameDiscriminatorHandling_NoDiscriminator() throws Exception { + String jsonProfileNew = """ +{ + "id": "80351110224678912", + "username": "nelly", + "discriminator": "0", + "email": "nelly@discord.com" +} + """; + JsonNode profile = mapper.readTree(jsonProfileNew); + BrokeredIdentityContext user = provider.extractIdentityFromProfile(null, profile); + + assertEquals("80351110224678912", user.getId()); + assertEquals("nelly", user.getUsername()); + assertEquals("nelly@discord.com", user.getEmail()); + + } + + @Test + void testExtractIdentity_NoAvatar() throws Exception { + String jsonProfile = """ +{ + "id": "80351110224678912", + "username": "nelly", + "discriminator": "0", + "email": "nelly@discord.com" +} + """; + JsonNode profile = mapper.readTree(jsonProfile); + + BrokeredIdentityContext user = provider.extractIdentityFromProfile(null, profile); + + assertNull(user.getUserAttribute("picture")); + + } + + @Test + void testExtractIdentity_WithStaticAvatar() throws Exception { + String jsonProfile = """ +{ + "id": "80351110224678912", + "username": "nelly", + "discriminator": "0", + "avatar": "8342729096ea3675442027381ff50dfe", + "email": "nelly@discord.com" +} + """; + JsonNode profile = mapper.readTree(jsonProfile); + + BrokeredIdentityContext user = provider.extractIdentityFromProfile(null, profile); + + String expectedURL = "https://cdn.discordapp.com/avatars/80351110224678912/8342729096ea3675442027381ff50dfe.png?size=256"; + assertEquals(expectedURL, user.getUserAttribute("picture")); + + } + + @Test + void testExtractIdentity_WithAnimatedAvatar() throws Exception { + String jsonProfile = """ +{ + "id": "80351110224678912", + "username": "nelly", + "discriminator": "0", + "avatar": "a_8342729096ea3675442027381ff50dfe", + "email": "nelly@discord.com" +} + """; + JsonNode profile = mapper.readTree(jsonProfile); + + BrokeredIdentityContext user = provider.extractIdentityFromProfile(null, profile); + + String expectedURL = "https://cdn.discordapp.com/avatars/80351110224678912/a_8342729096ea3675442027381ff50dfe.gif?size=256"; + assertEquals(expectedURL, user.getUserAttribute("picture")); + + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/social/discord/DiscordUserAttributeMapperTest.java b/src/test/java/org/keycloak/social/discord/DiscordUserAttributeMapperTest.java new file mode 100644 index 0000000..b97dac3 --- /dev/null +++ b/src/test/java/org/keycloak/social/discord/DiscordUserAttributeMapperTest.java @@ -0,0 +1,16 @@ +package org.keycloak.social.discord; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DiscordUserAttributeMapperTest { + @Test + void getCompatibleProviders() { + DiscordUserAttributeMapper mapper = new DiscordUserAttributeMapper(); + String[] providers = mapper.getCompatibleProviders(); + assertNotNull(providers); + assertNotEquals(0, providers.length); + } + +} \ No newline at end of file