diff --git a/.github/native-tests.json b/.github/native-tests.json index 631bc2fa21730..c9ae722bb2d1e 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -75,7 +75,7 @@ { "category": "Security2", "timeout": 75, - "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock", + "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock, oidc-wiremock-providers", "os-name": "ubuntu-latest" }, { diff --git a/docs/src/main/asciidoc/images/oidc-slack-1.png b/docs/src/main/asciidoc/images/oidc-slack-1.png new file mode 100644 index 0000000000000..2ce473f2587a2 Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-1.png differ diff --git a/docs/src/main/asciidoc/images/oidc-slack-2.png b/docs/src/main/asciidoc/images/oidc-slack-2.png new file mode 100644 index 0000000000000..37dd65e367dbd Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-2.png differ diff --git a/docs/src/main/asciidoc/images/oidc-slack-3.png b/docs/src/main/asciidoc/images/oidc-slack-3.png new file mode 100644 index 0000000000000..d53c81f4f523a Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-3.png differ diff --git a/docs/src/main/asciidoc/images/oidc-slack-4.png b/docs/src/main/asciidoc/images/oidc-slack-4.png new file mode 100644 index 0000000000000..6425b138c333d Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-4.png differ diff --git a/docs/src/main/asciidoc/images/oidc-slack-5.png b/docs/src/main/asciidoc/images/oidc-slack-5.png new file mode 100644 index 0000000000000..9ca2ab413a695 Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-5.png differ diff --git a/docs/src/main/asciidoc/security-openid-connect-providers.adoc b/docs/src/main/asciidoc/security-openid-connect-providers.adoc index f24cbd8555521..898cc468bad84 100644 --- a/docs/src/main/asciidoc/security-openid-connect-providers.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-providers.adoc @@ -406,6 +406,42 @@ quarkus.oidc.token.customizer-name=azure-access-token-customizer ==== +[[slack]] +=== Slack + +Create a https://api.slack.com/authentication/sign-in-with-slack#setup[Slack application]: + +image::oidc-slack-1.png[role="thumb"] + +Select application name, workspace, and remember it, you will need it later: + +image::oidc-slack-2.png[role="thumb"] + +Please save client id and secret displayed on the next page, you will need them later: + +image::oidc-slack-3.png[role="thumb"] + +Configure redirect URLs. +Slack provider requires HTTPS protocol, for development purposes, you can use ngrok: + +image::oidc-slack-4.png[role="thumb"] + +You can now configure your `application.properties`: + +[source,properties] +---- +quarkus.oidc.provider=slack +quarkus.oidc.client-id= +quarkus.oidc.credentials.secret= +quarkus.oidc.authentication.extra-params.team=quarkus-slack <1> +---- +<1> Use the `team` parameter to refer to the workspace you chose during the Slack OIDC application registration. + +Open your browser and navigate to your application `https://.ngrok-free.app/`. +Quarkus will redirect you to Slack provider on the first request where you can grant required permissions: + +image::oidc-slack-5.png[role="thumb"] + [[spotify]] === Spotify diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index a5cf9a739d820..abc3b0a0627e9 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -2009,6 +2009,7 @@ public static enum Provider { LINKEDIN, MASTODON, MICROSOFT, + SLACK, SPOTIFY, STRAVA, TWITCH, diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java index 9a1e95130b5e0..4c47995c1e257 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java @@ -19,6 +19,7 @@ public static OidcTenantConfig provider(OidcTenantConfig.Provider provider) { case LINKEDIN -> linkedIn(); case MASTODON -> mastodon(); case MICROSOFT -> microsoft(); + case SLACK -> slack(); case SPOTIFY -> spotify(); case STRAVA -> strava(); case TWITCH -> twitch(); @@ -26,6 +27,15 @@ public static OidcTenantConfig provider(OidcTenantConfig.Provider provider) { }; } + private static OidcTenantConfig slack() { + OidcTenantConfig ret = new OidcTenantConfig(); + ret.setAuthServerUrl("https://slack.com"); + ret.setApplicationType(OidcTenantConfig.ApplicationType.WEB_APP); + ret.getToken().setPrincipalClaim("name"); + ret.getAuthentication().setForceRedirectHttpsScheme(true); + return ret; + } + private static OidcTenantConfig linkedIn() { OidcTenantConfig ret = new OidcTenantConfig(); ret.setAuthServerUrl("https://www.linkedin.com/oauth"); diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java index 93571c0a96487..29a09e5c427ec 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java @@ -588,4 +588,39 @@ public void testOverrideLinkedInProperties() throws Exception { assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); } + + @Test + public void testAcceptSlackProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SLACK)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().orElse(true)); + assertEquals("https://slack.com", config.getAuthServerUrl().get()); + + assertEquals("name", config.token.principalClaim.get()); + assertTrue(config.authentication.forceRedirectHttpsScheme.orElse(false)); + } + + @Test + public void testOverrideSlackProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId("PattiSmith"); + tenant.setApplicationType(ApplicationType.SERVICE); + tenant.setDiscoveryEnabled(false); + tenant.setAuthServerUrl("https://private-slack.com"); + tenant.getToken().setPrincipalClaim("I you my own principal"); + tenant.getAuthentication().setForceRedirectHttpsScheme(false); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SLACK)); + + assertEquals("PattiSmith", config.getTenantId().get()); + assertEquals(ApplicationType.SERVICE, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().orElse(true)); + assertEquals("https://private-slack.com", config.getAuthServerUrl().get()); + + assertEquals("I you my own principal", config.token.principalClaim.get()); + assertFalse(config.authentication.forceRedirectHttpsScheme.orElse(false)); + } } diff --git a/integration-tests/oidc-wiremock-providers/pom.xml b/integration-tests/oidc-wiremock-providers/pom.xml new file mode 100644 index 0000000000000..25f98f412045c --- /dev/null +++ b/integration-tests/oidc-wiremock-providers/pom.xml @@ -0,0 +1,106 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-integration-test-oidc-wiremock-providers + Quarkus - Integration Tests - OpenID Connect Adapter WireMock - Well-known Providers + Module that contains OpenID Connect Well-known Providers related tests using WireMock + + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-test-oidc-server + test + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.htmlunit + htmlunit + test + + + org.eclipse.jetty + * + + + + + + io.quarkus + quarkus-oidc-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + maven-surefire-plugin + + + maven-failsafe-plugin + + + io.quarkus + quarkus-maven-plugin + + + + generate-code + build + + + + + + + + + diff --git a/integration-tests/oidc-wiremock-providers/src/main/java/io/quarkus/it/oidc/providers/ProvidersResource.java b/integration-tests/oidc-wiremock-providers/src/main/java/io/quarkus/it/oidc/providers/ProvidersResource.java new file mode 100644 index 0000000000000..2db3613c924dc --- /dev/null +++ b/integration-tests/oidc-wiremock-providers/src/main/java/io/quarkus/it/oidc/providers/ProvidersResource.java @@ -0,0 +1,27 @@ +package io.quarkus.it.oidc.providers; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.oidc.AuthorizationCodeFlow; +import io.quarkus.oidc.UserInfo; + +@Path("providers") +public class ProvidersResource { + + public record ProvidersResponseDto(String userPrincipalName, String userInfoEmail) { + } + + @Inject + UserInfo userInfo; + + @Path("slack") + @AuthorizationCodeFlow + @GET + public ProvidersResponseDto getPrincipalAndEmailFromSlackProvider(SecurityContext securityContext) { + return new ProvidersResponseDto(securityContext.getUserPrincipal().getName(), userInfo.getEmail()); + } + +} diff --git a/integration-tests/oidc-wiremock-providers/src/main/resources/application.properties b/integration-tests/oidc-wiremock-providers/src/main/resources/application.properties new file mode 100644 index 0000000000000..bc7a1845fcb5c --- /dev/null +++ b/integration-tests/oidc-wiremock-providers/src/main/resources/application.properties @@ -0,0 +1,16 @@ +quarkus.http.auth.proactive=false +quarkus.log.category."org.htmlunit".level=ERROR +quarkus.log.category."com.github".level=ERROR +quarkus.keycloak.devservices.enabled=false + +quarkus.oidc.slack.provider=slack +quarkus.oidc.slack.auth-server-url=${keycloak.url}/slack +quarkus.oidc.slack.client-id=7925551513107.7922794171477 +quarkus.oidc.slack.credentials.secret=2b82d6039bc97946460fdec75fadd9b2 +quarkus.oidc.slack.authentication.extra-params.team=quarkus-oidc-slack-demo +quarkus.oidc.slack.tenant-paths=/providers/slack* +quarkus.oidc.slack.token.lifespan-grace=2147483647 +quarkus.oidc.slack.authentication.force-redirect-https-scheme=false +quarkus.oidc.slack.authentication.verify-access-token=false +quarkus.oidc.slack.authentication.remove-redirect-parameters=false +quarkus.oidc.slack.authentication.scopes=oidc,profile,email diff --git a/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersIT.java b/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersIT.java new file mode 100644 index 0000000000000..271d0fb3ae39f --- /dev/null +++ b/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.oidc.providers; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class OidcWellKnownProvidersIT extends OidcWellKnownProvidersTest { +} diff --git a/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersTest.java b/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersTest.java new file mode 100644 index 0000000000000..0222fd5075187 --- /dev/null +++ b/integration-tests/oidc-wiremock-providers/src/test/java/io/quarkus/it/oidc/providers/OidcWellKnownProvidersTest.java @@ -0,0 +1,169 @@ +package io.quarkus.it.oidc.providers; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Map; + +import org.htmlunit.SilentCssErrorHandler; +import org.htmlunit.WebClient; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.matching.AnythingPattern; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.server.OidcWireMock; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; + +@QuarkusTest +@QuarkusTestResource(OidcWiremockTestResource.class) +public class OidcWellKnownProvidersTest { + + @OidcWireMock + WireMockServer wireMockServer; + + @Test + public void testSlackWellKnownProvider() throws IOException { + configSlackProviderStubs(); + try (var webClient = createWebClient()) { + var page = webClient.getPage("http://localhost:8081/providers/slack"); + var responseContent = page.getWebResponse().getContentAsString(); + assertTrue(responseContent.contains("\"userPrincipalName\":\"Rosetta\""), responseContent); + assertTrue(responseContent.contains("\"userInfoEmail\":\"example@example.com\""), responseContent); + webClient.getCookieManager().clearCookies(); + } + } + + private void configSlackProviderStubs() { + wireMockServer.stubFor( + get(urlMatching("/auth/slack/.well-known/openid-configuration.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "issuer": "https://server.example.com", + "authorization_endpoint": "%1$s/auth/slack/openid/connect/authorize", + "token_endpoint": "%1$s/auth/slack/api/openid.connect.token", + "userinfo_endpoint": "%1$s/auth/slack/api/openid.connect.userInfo", + "jwks_uri": "%1$s/auth/slack/openid/connect/keys", + "scopes_supported": ["openid","profile","email"], + "response_types_supported": ["code"], + "response_modes_supported": ["form_post"], + "grant_types_supported": ["authorization_code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub","auth_time","iss"], + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": true, + "token_endpoint_auth_methods_supported": ["client_secret_post","client_secret_basic"] + } + """ + .formatted(wireMockServer.baseUrl())))); + + wireMockServer.stubFor( + get(urlMatching("/auth/slack/openid/connect/authorize.*")) + .withQueryParam("response_type", equalTo("code")) + .withQueryParam("client_id", equalTo("7925551513107.7922794171477")) + .withQueryParam("scope", containing("openid")) + .withQueryParam("scope", containing("email")) + .withQueryParam("scope", containing("profile")) + .withQueryParam("scope", containing("profile")) + .withQueryParam("redirect_uri", equalTo("http://localhost:8081/providers/slack")) + .withQueryParam("state", new AnythingPattern()) + .withQueryParam("team", equalTo("quarkus-oidc-slack-demo")) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Set-Cookie", "{{request.headers.Set-Cookie}}") + .withHeader("Content-Type", "text/html") + .withHeader("Location", "http://localhost:8081/providers/slack?code=7917304849541.79239831" + + "24323.1f4c41812b286422cbce183a9f083fa58f7c2761c281c2be483a376694f56274&state" + + "={{request.query.state}}") + .withBody("") + .withTransformers("response-template"))); + + wireMockServer.stubFor( + get(urlMatching("/auth/slack/openid/connect/keys.*")) + .willReturn(aResponse() + .withHeader("Set-Cookie", "{{request.headers.Set-Cookie}}") + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "keys": [ + { + "kid": "1", + "kty":"RSA", + "n":"iJw33l1eVAsGoRlSyo-FCimeOc-AaZbzQ2iESA3Nkuo3TFb1zIkmt0kzlnWVGt48dkaIl13Vdefh9hqw_r9yNF8xZqX1fp0PnCWc5M_TX_ht5fm9y0TpbiVmsjeRMWZn4jr3DsFouxQ9aBXUJiu26V0vd2vrECeeAreFT4mtoHY13D2WVeJvboc5mEJcp50JNhxRCJ5UkY8jR_wfUk2Tzz4-fAj5xQaBccXnqJMu_1C6MjoCEiB7G1d13bVPReIeAGRKVJIF6ogoCN8JbrOhc_48lT4uyjbgnd24beatuKWodmWYhactFobRGYo5551cgMe8BoxpVQ4to30cGA0qjQ", + "e":"AQAB" + } + ] + } + """))); + + wireMockServer.stubFor( + post(urlMatching("/auth/slack/api/openid\\.connect\\.token.*")) + .willReturn(aResponse() + .withHeader("Set-Cookie", "{{request.headers.Set-Cookie}}") + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "ok": true, + "access_token": "xoxp-7925551513107-7925645662178-7911177365927-reduced", + "token_type": "Bearer", + "id_token": "%s", + "state": "" + } + """.formatted(OidcWiremockTestResource.getIdToken("Rosetta", + "7925551513107.7922794171477", Map.of("name", "Rosetta")))) + .withTransformers("response-template"))); + + wireMockServer.stubFor( + get(urlMatching("/auth/slack/api/openid\\.connect\\.userInfo.*")) + .willReturn(aResponse() + .withHeader("Set-Cookie", "{{request.headers.Set-Cookie}}") + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "ok": true, + "sub": "U07TA484GLU", + "https:\\/\\/slack.com\\/user_id": "U0reducedGLU", + "https:\\/\\/slack.com\\/team_id": "T0reduced35", + "email": "example@example.com", + "email_verified": true, + "date_email_verified": 1729712670, + "name": "Michal No", + "picture": "https:\\/\\/avatars.slack-edge.com\\/2024-10-23\\/7948436985680_reduced.png", + "given_name": "Michal", + "family_name": "No", + "locale": "en-US", + "https:\\/\\/slack.com\\/team_name": "quarkus-oidc-slack-demo-workspace", + "https:\\/\\/slack.com\\/team_domain": "reduced-ipa4978", + "https:\\/\\/slack.com\\/user_image_24": "https:\\/\\/avatars.slack-edge.com\\/2024-10-23\\/7948436985680_reduced_a64ea0fba9db9b46c773_24.png", + "https:\\/\\/slack.com\\/team_image_34": "https:\\/\\/a.slack-edge.com\\/80588\\/img\\/avatars-teams\\/ava_7948436985680_reduced.png", + "https:\\/\\/slack.com\\/team_image_default": true + } + """) + .withTransformers("response-template"))); + } + + private static WebClient createWebClient() { + var webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + webClient.getOptions().setCssEnabled(false); + webClient.getOptions().setJavaScriptEnabled(false); + webClient.getOptions().setRedirectEnabled(true); + return webClient; + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index cf618d0ab6e3c..624ee3141e8db 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -266,6 +266,7 @@ oidc-tenancy oidc-wiremock oidc-wiremock-logout + oidc-wiremock-providers keycloak-authorization rest-csrf reactive-db2-client diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index 8207f534ceda2..e240611539a38 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -398,6 +398,10 @@ public static String getIdToken(String userName, Set groups) { return generateJwtToken(userName, groups, TOKEN_SUBJECT, ID_TOKEN_TYPE); } + public static String getIdToken(String userName, String clientId, Map claims) { + return generateJwtToken(userName, Set.of(), TOKEN_SUBJECT, ID_TOKEN_TYPE, Set.of(clientId, ID_TOKEN_AUDIENCE), claims); + } + public static String getIdToken(String userName, Set groups, String clientId) { return generateJwtToken(userName, groups, TOKEN_SUBJECT, ID_TOKEN_TYPE, Set.of(clientId, ID_TOKEN_AUDIENCE)); } @@ -415,6 +419,11 @@ public static String generateJwtToken(String userName, Set groups, Strin } public static String generateJwtToken(String userName, Set groups, String sub, String type, Set aud) { + return generateJwtToken(userName, groups, sub, type, aud, Map.of()); + } + + public static String generateJwtToken(String userName, Set groups, String sub, String type, Set aud, + Map claims) { JwtClaimsBuilder builder = Jwt.preferredUserName(userName) .groups(groups) .issuer(TOKEN_ISSUER) @@ -425,6 +434,10 @@ public static String generateJwtToken(String userName, Set groups, Strin builder.claim("typ", type); } + if (claims != null) { + claims.forEach(builder::claim); + } + return builder .jws() .keyId("1")