diff --git a/core/src/main/java/dev/cloudeko/zenei/infrastructure/config/AvailableProviders.java b/core/src/main/java/dev/cloudeko/zenei/infrastructure/config/AvailableProviders.java new file mode 100644 index 0000000..234f6f4 --- /dev/null +++ b/core/src/main/java/dev/cloudeko/zenei/infrastructure/config/AvailableProviders.java @@ -0,0 +1,16 @@ +package dev.cloudeko.zenei.infrastructure.config; + +public enum AvailableProviders { + GITHUB("github"), + DISCORD("discord"); + + private final String providerName; + + AvailableProviders(String providerName) { + this.providerName = providerName; + } + + public String getProviderName() { + return providerName; + } +} diff --git a/core/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithExternalProviderTest.java b/core/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithExternalProviderTest.java index 0f24b8d..1bbb899 100644 --- a/core/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithExternalProviderTest.java +++ b/core/src/test/java/dev/cloudeko/zenei/auth/AuthenticationFlowWithExternalProviderTest.java @@ -1,22 +1,29 @@ package dev.cloudeko.zenei.auth; -import dev.cloudeko.zenei.application.web.model.response.TokenResponse; +import dev.cloudeko.zenei.resource.MockDiscordAuthorizationServerTestResource; import dev.cloudeko.zenei.resource.MockGithubAuthorizationServerTestResource; +import dev.cloudeko.zenei.resource.MockServerResource; import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.notNullValue; @QuarkusTest -@WithTestResource(MockGithubAuthorizationServerTestResource.class) +@WithTestResource.List({ + @WithTestResource(MockServerResource.class), + @WithTestResource(MockGithubAuthorizationServerTestResource.class), + @WithTestResource(MockDiscordAuthorizationServerTestResource.class) +}) public class AuthenticationFlowWithExternalProviderTest { @BeforeAll @@ -24,10 +31,11 @@ static void setup() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); } - @Test + @MethodSource("createProviderData") + @ParameterizedTest(name = "Test Case for provider: {0}") @DisplayName("Retrieve a access token using authorization (GET /external/login/github) should return (200 OK)") - void testGetUserInfo() { - given().get("/external/login/github") + void testGetUserInfo(String provider) { + given().get("/external/login/" + provider) .then() .statusCode(Response.Status.OK.getStatusCode()) .body( @@ -35,4 +43,8 @@ void testGetUserInfo() { "refresh_token", notNullValue() ); } + + static Stream createProviderData() { + return Stream.of(Arguments.of("github"), Arguments.of("discord")); + } } diff --git a/core/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java b/core/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java new file mode 100644 index 0000000..f7cc348 --- /dev/null +++ b/core/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java @@ -0,0 +1,24 @@ +package dev.cloudeko.zenei.resource; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +import java.util.Map; + +public abstract class AbstractMockAuthorizationServerTestResource implements QuarkusTestResourceLifecycleManager { + + protected final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Map start() { + WireMockServer wireMockServer = MockServerResource.getWireMockServer(); + return providerSpecificStubsAndConfig(wireMockServer); + } + + @Override + public void stop() { + } + + protected abstract Map providerSpecificStubsAndConfig(WireMockServer server); +} diff --git a/core/src/test/java/dev/cloudeko/zenei/resource/MockDiscordAuthorizationServerTestResource.java b/core/src/test/java/dev/cloudeko/zenei/resource/MockDiscordAuthorizationServerTestResource.java new file mode 100644 index 0000000..35514b9 --- /dev/null +++ b/core/src/test/java/dev/cloudeko/zenei/resource/MockDiscordAuthorizationServerTestResource.java @@ -0,0 +1,66 @@ +package dev.cloudeko.zenei.resource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import dev.cloudeko.zenei.extension.external.web.client.ExternalAccessToken; +import dev.cloudeko.zenei.extension.external.web.external.discord.DiscordUser; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.ConfigProvider; + +import java.util.Map; + +public class MockDiscordAuthorizationServerTestResource extends AbstractMockAuthorizationServerTestResource { + + @Override + protected Map providerSpecificStubsAndConfig(WireMockServer server) { + final var testPort = ConfigProvider.getConfig().getOptionalValue("quarkus.http.test-port", Integer.class).orElse(8081); + + try { + // Mock the Discord authorization URL + server.stubFor(WireMock.get(WireMock.urlPathEqualTo("/discord/login/oauth/authorize")) + .withQueryParam("client_id", WireMock.matching(".*")) + .withQueryParam("redirect_uri", WireMock.matching(".*")) + .withQueryParam("scope", WireMock.matching(".*")) + .willReturn(WireMock.aResponse() + .withStatus(Response.Status.FOUND.getStatusCode()) + .withHeader("Location", + "http://localhost:" + testPort + "/external/callback/discord?code=mock_code&state=mock_state"))); + + // Mock the Discord access token endpoint + server.stubFor(WireMock.post(WireMock.urlPathEqualTo("/discord/login/oauth/access_token")) + .withQueryParam("client_id", WireMock.matching(".*")) + .withQueryParam("client_secret", WireMock.matching(".*")) + .withQueryParam("code", WireMock.matching(".*")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString( + new ExternalAccessToken("mock_access_token", 3600L, "mock_refresh_token", "user,email", + "bearer") + )))); + + // Mock the Discord user data endpoints + server.stubFor(WireMock.get(WireMock.urlPathEqualTo("/discord/users/@me")) + .withHeader("Authorization", WireMock.equalTo("Bearer mock_access_token")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString( + new DiscordUser("12345L", "discord-user", "Discord Test User", "1234", "https://example.com/avatar.jpg", + "test@discord.com", true) + )))); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return Map.of( + "zenei.external.auth.providers.discord.base-uri", server.baseUrl() + "/discord", + "zenei.external.auth.providers.discord.client-id", "mock_client_id", + "zenei.external.auth.providers.discord.client-secret", "mock_client_secret", + "zenei.external.auth.providers.discord.authorization-uri", + server.baseUrl() + "/discord/login/oauth/authorize", + "zenei.external.auth.providers.discord.token-uri", + server.baseUrl() + "/discord/login/oauth/access_token", + "zenei.external.auth.providers.discord.redirect-uri", "http://localhost:8081/external/callback/discord", + "zenei.external.auth.providers.discord.scope", "user,email" + ); + } +} diff --git a/core/src/test/java/dev/cloudeko/zenei/resource/MockGithubAuthorizationServerTestResource.java b/core/src/test/java/dev/cloudeko/zenei/resource/MockGithubAuthorizationServerTestResource.java index c57e4ca..dfcf5e5 100644 --- a/core/src/test/java/dev/cloudeko/zenei/resource/MockGithubAuthorizationServerTestResource.java +++ b/core/src/test/java/dev/cloudeko/zenei/resource/MockGithubAuthorizationServerTestResource.java @@ -1,33 +1,25 @@ package dev.cloudeko.zenei.resource; -import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import dev.cloudeko.zenei.extension.external.web.client.ExternalAccessToken; import dev.cloudeko.zenei.extension.external.web.external.github.GithubUser; import dev.cloudeko.zenei.extension.external.web.external.github.GithubUserEmail; -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.ConfigProvider; import java.util.List; import java.util.Map; -public class MockGithubAuthorizationServerTestResource implements QuarkusTestResourceLifecycleManager { - - private final ObjectMapper objectMapper = new ObjectMapper(); - private WireMockServer wireMockServer; +public class MockGithubAuthorizationServerTestResource extends AbstractMockAuthorizationServerTestResource { @Override - public Map start() { - wireMockServer = new WireMockServer(); - wireMockServer.start(); - + protected Map providerSpecificStubsAndConfig(WireMockServer server) { final var testPort = ConfigProvider.getConfig().getOptionalValue("quarkus.http.test-port", Integer.class).orElse(8081); try { // Mock the GitHub authorization URL - wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/login/oauth/authorize")) + server.stubFor(WireMock.get(WireMock.urlPathEqualTo("/github/login/oauth/authorize")) .withQueryParam("client_id", WireMock.matching(".*")) .withQueryParam("redirect_uri", WireMock.matching(".*")) .withQueryParam("scope", WireMock.matching(".*")) @@ -37,7 +29,7 @@ public Map start() { "http://localhost:" + testPort + "/external/callback/github?code=mock_code&state=mock_state"))); // Mock the access token endpoint - wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/login/oauth/access_token")) + server.stubFor(WireMock.post(WireMock.urlPathEqualTo("/github/login/oauth/access_token")) .withQueryParam("client_id", WireMock.matching(".*")) .withQueryParam("client_secret", WireMock.matching(".*")) .withQueryParam("code", WireMock.matching(".*")) @@ -49,23 +41,23 @@ public Map start() { )))); // Mock the GitHub user data endpoints - wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/user")) + server.stubFor(WireMock.get(WireMock.urlPathEqualTo("/github/user")) .withHeader("Authorization", WireMock.equalTo("Bearer mock_access_token")) .willReturn(WireMock.aResponse() .withHeader("Content-Type", "application/json") .withBody(objectMapper.writeValueAsString( - new GithubUser(12345L, "testuser", "Test User", "https://example.com/avatar.jpg", - "testuser@example.com") + new GithubUser(12345L, "github-user", "Github Test User", "https://example.com/avatar.jpg", + "test@github.com") )))); - wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/user/emails")) + server.stubFor(WireMock.get(WireMock.urlPathEqualTo("/github/user/emails")) .withHeader("Authorization", WireMock.equalTo("Bearer mock_access_token")) .willReturn(WireMock.aResponse() .withHeader("Content-Type", "application/json") .withBody(objectMapper.writeValueAsString( List.of( - new GithubUserEmail("testuser@example.com", true, true), - new GithubUserEmail("secondary@example.com", false, true) + new GithubUserEmail("test@github.com", true, true), + new GithubUserEmail("secondary@github.com", false, true) ) )))); } catch (Exception e) { @@ -73,20 +65,13 @@ public Map start() { } return Map.of( - "zenei.external.auth.providers.github.base-uri", wireMockServer.baseUrl(), + "zenei.external.auth.providers.github.base-uri", server.baseUrl() + "/github", "zenei.external.auth.providers.github.client-id", "mock_client_id", "zenei.external.auth.providers.github.client-secret", "mock_client_secret", - "zenei.external.auth.providers.github.authorization-uri", wireMockServer.baseUrl() + "/login/oauth/authorize", - "zenei.external.auth.providers.github.token-uri", wireMockServer.baseUrl() + "/login/oauth/access_token", + "zenei.external.auth.providers.github.authorization-uri", server.baseUrl() + "/github/login/oauth/authorize", + "zenei.external.auth.providers.github.token-uri", server.baseUrl() + "/github/login/oauth/access_token", "zenei.external.auth.providers.github.redirect-uri", "http://localhost:8081/external/callback/github", "zenei.external.auth.providers.github.scope", "user,email" ); } - - @Override - public void stop() { - if (null != wireMockServer) { - wireMockServer.stop(); - } - } } diff --git a/core/src/test/java/dev/cloudeko/zenei/resource/MockServerResource.java b/core/src/test/java/dev/cloudeko/zenei/resource/MockServerResource.java new file mode 100644 index 0000000..075cf76 --- /dev/null +++ b/core/src/test/java/dev/cloudeko/zenei/resource/MockServerResource.java @@ -0,0 +1,31 @@ +package dev.cloudeko.zenei.resource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import lombok.Getter; + +import java.util.Map; + +public class MockServerResource implements QuarkusTestResourceLifecycleManager { + + @Getter + private static WireMockServer wireMockServer; + + @Override + public Map start() { + if (wireMockServer == null) { + wireMockServer = new WireMockServer(); + wireMockServer.start(); + } + + return Map.of(); + } + + @Override + public void stop() { + if (wireMockServer != null) { + wireMockServer.stop(); + wireMockServer = null; + } + } +} diff --git a/extensions/external-authentication/deployment/pom.xml b/extensions/external-authentication/deployment/pom.xml index 1b3cf73..b7f7f55 100644 --- a/extensions/external-authentication/deployment/pom.xml +++ b/extensions/external-authentication/deployment/pom.xml @@ -17,18 +17,10 @@ io.quarkus quarkus-arc-deployment - - io.quarkus - quarkus-mutiny-deployment - io.quarkus quarkus-rest-jackson-deployment - - io.quarkus - quarkus-security-deployment - io.quarkus quarkus-rest-client-reactive-jackson-deployment diff --git a/extensions/external-authentication/runtime/pom.xml b/extensions/external-authentication/runtime/pom.xml index 13a74db..5e6afa2 100644 --- a/extensions/external-authentication/runtime/pom.xml +++ b/extensions/external-authentication/runtime/pom.xml @@ -16,18 +16,10 @@ io.quarkus quarkus-arc - - io.quarkus - quarkus-mutiny - io.quarkus quarkus-rest-jackson - - io.quarkus - quarkus-security - io.quarkus quarkus-rest-client-reactive-jackson diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/DiscordProviderEndpoints.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/DiscordProviderEndpoints.java new file mode 100644 index 0000000..8e114f8 --- /dev/null +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/DiscordProviderEndpoints.java @@ -0,0 +1,19 @@ +package dev.cloudeko.zenei.extension.external.endpoint; + +public class DiscordProviderEndpoints implements DefaultProviderEndpoints { + + @Override + public String getAuthorizationEndpoint() { + return "https://discord.com/api/oauth2/authorize"; + } + + @Override + public String getTokenEndpoint() { + return "https://discord.com/api/oauth2/token"; + } + + @Override + public String getBaseEndpoint() { + return "https://discord.com/api"; + } +} diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/ProviderEndpoints.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/ProviderEndpoints.java index 876a719..a18d7bc 100644 --- a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/ProviderEndpoints.java +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/endpoint/ProviderEndpoints.java @@ -2,4 +2,5 @@ public class ProviderEndpoints { public static final DefaultProviderEndpoints GITHUB = new GithubProviderEndpoints(); + public static final DefaultProviderEndpoints DISCORD = new DiscordProviderEndpoints(); } diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/ConfigurationExternalAuthResolver.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/ConfigurationExternalAuthResolver.java index a98fc05..021d80f 100644 --- a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/ConfigurationExternalAuthResolver.java +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/ConfigurationExternalAuthResolver.java @@ -30,6 +30,10 @@ private ExternalAuthProvider createAuthProvider(String providerName, ExternalAut yield new GithubExternalAuthProvider(providerConfig); } + case "discord": { + yield new DiscordExternalAuthProvider(providerConfig); + } + default: { yield null; } diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/DiscordExternalAuthProvider.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/DiscordExternalAuthProvider.java new file mode 100644 index 0000000..abfdbcc --- /dev/null +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/providers/DiscordExternalAuthProvider.java @@ -0,0 +1,49 @@ +package dev.cloudeko.zenei.extension.external.providers; + +import dev.cloudeko.zenei.extension.external.ExternalAuthProvider; +import dev.cloudeko.zenei.extension.external.ExternalUserProfile; +import dev.cloudeko.zenei.extension.external.config.ExternalAuthProviderConfig; +import dev.cloudeko.zenei.extension.external.endpoint.ProviderEndpoints; +import dev.cloudeko.zenei.extension.external.web.client.ExternalAccessToken; +import dev.cloudeko.zenei.extension.external.web.external.discord.DiscordApiClient; +import dev.cloudeko.zenei.extension.external.web.external.github.GithubApiClient; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; + +import java.net.URI; +import java.util.List; + +public record DiscordExternalAuthProvider(ExternalAuthProviderConfig config) implements ExternalAuthProvider { + + @Override + public ExternalUserProfile getExternalUserProfile(ExternalAccessToken accessToken) { + final var client = QuarkusRestClientBuilder.newBuilder() + .baseUri(URI.create(getBaseEndpoint())) + .build(DiscordApiClient.class); + + final var externalUserBuilder = ExternalUserProfile.builder(); + final var user = client.getCurrentlyLoggedInUser("Bearer " + accessToken.getAccessToken()); + + externalUserBuilder.id(user.getId()); + externalUserBuilder.username(user.getUsername()); + externalUserBuilder.avatarUrl(user.getAvatar()); + externalUserBuilder.emails( + List.of(new ExternalUserProfile.ExternalUserEmail(user.getEmail(), true, user.isVerified()))); + + return externalUserBuilder.build(); + } + + @Override + public String getAuthorizationEndpoint() { + return config.authorizationUri().orElse(ProviderEndpoints.DISCORD.getAuthorizationEndpoint()); + } + + @Override + public String getTokenEndpoint() { + return config.tokenUri().orElse(ProviderEndpoints.DISCORD.getTokenEndpoint()); + } + + @Override + public String getBaseEndpoint() { + return config.baseUri().orElse(ProviderEndpoints.DISCORD.getBaseEndpoint()); + } +} diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordApiClient.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordApiClient.java new file mode 100644 index 0000000..4760264 --- /dev/null +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordApiClient.java @@ -0,0 +1,13 @@ +package dev.cloudeko.zenei.extension.external.web.external.discord; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; + +@Path("/") +public interface DiscordApiClient { + + @GET + @Path("/users/@me") + DiscordUser getCurrentlyLoggedInUser(@HeaderParam("Authorization") String token); +} diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordUser.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordUser.java new file mode 100644 index 0000000..09ecc3e --- /dev/null +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/discord/DiscordUser.java @@ -0,0 +1,32 @@ +package dev.cloudeko.zenei.extension.external.web.external.discord; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiscordUser { + @JsonProperty("id") + private String id; + + @JsonProperty("username") + private String username; + + @JsonProperty("global_name") + private String globalName; + + @JsonProperty("discriminator") + private String discriminator; + + @JsonProperty("avatar") + private String avatar; + + @JsonProperty("email") + private String email; + + @JsonProperty("verified") + private boolean verified; +} diff --git a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/github/GithubApiClient.java b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/github/GithubApiClient.java index 0649a94..1bb8452 100644 --- a/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/github/GithubApiClient.java +++ b/extensions/external-authentication/runtime/src/main/java/dev/cloudeko/zenei/extension/external/web/external/github/GithubApiClient.java @@ -9,7 +9,7 @@ @Path("/") public interface GithubApiClient { - @GET() + @GET @Path("/user") GithubUser getCurrentlyLoggedInUser(@HeaderParam("Authorization") String token);