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 00000000000000..fb44e11d323a2e 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 00000000000000..d9807f94ca9e06 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 00000000000000..3890a226998c4f 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 00000000000000..54d8a029d87c46 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 00000000000000..d5fe1e044e7722 Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-5.png differ diff --git a/docs/src/main/asciidoc/images/oidc-slack-6.png b/docs/src/main/asciidoc/images/oidc-slack-6.png new file mode 100644 index 00000000000000..d1179f82dd31d4 Binary files /dev/null and b/docs/src/main/asciidoc/images/oidc-slack-6.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 f24cbd85555211..b88b1bca59cc1c 100644 --- a/docs/src/main/asciidoc/security-openid-connect-providers.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-providers.adoc @@ -406,6 +406,60 @@ 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 workspace and remember it, you will need it later: + +image::oidc-slack-2.png[role="thumb"] + +Your Slack application must have configured name, redirect URI where Slack sends standard OpenID response and OpenID scopes: + +image::oidc-slack-3.png[role="thumb"] + +Make sure user OpenID scopes are set to `openid`, `email`, `profile`: + +image::oidc-slack-4.png[role="thumb"] + +When you create your application, you are redirected to page with basic information. +Please save client id and secret, you will need them in the next step: + +image::oidc-slack-5.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-oidc-slack-demo <1> +---- +<1> Replace team name `quarkus-oidc-slack-demo` with a name you specified earlier. + +Slack provider enforces the `HTTPS` protocol, which means your redirect URLs must use `HTTPS` protocol. +The easiest way to configure TLS context is a xref:./tls-registry-reference.adoc[TLS centralized configuration]. +If you are creating a new application, here is what you need to do: + +[source,bash] +---- +quarkus create quarkus-oidc-slack-demo slack --extensions=oidc,quarkus-rest +cd quarkus-oidc-slack-demo +quarkus tls generate-certificate --name my-cert +# now add application properties mentioned earlier +quarkus dev +---- + +And you are ready to go! +Open your browser and navigate to `https://localhost:8443/hello`. +Quarkus OIDC extension will redirect you to the Slack provider where you can grant permissions to your Slack workspace: + +image::oidc-slack-6.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 a5cf9a739d8205..abc3b0a0627e93 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 9a1e95130b5e01..29af05a25d5b31 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,26 @@ 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.getCredentials().getClientSecret().setMethod(Method.POST); + + var token = ret.getToken(); + token.setPrincipalClaim("name"); + token.setSubjectRequired(true); + + var authentication = ret.getAuthentication(); + authentication.setScopes(List.of("email", "profile")); + authentication.setForceRedirectHttpsScheme(true); + authentication.setAddOpenidScope(true); + authentication.setNonceRequired(true); + authentication.failOnMissingStateParam = true; + + return ret; + } + private static OidcTenantConfig linkedIn() { OidcTenantConfig ret = new OidcTenantConfig(); ret.setAuthServerUrl("https://www.linkedin.com/oauth"); diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java index ac10fcb4822484..2587eaf55515d4 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java @@ -29,6 +29,7 @@ public void filter(OidcRequestContext rc) { private boolean isJwksRequest(HttpRequest request) { return request.uri().endsWith("/protocol/openid-connect/certs") || request.uri().endsWith("/auth/azure/jwk") - || request.uri().endsWith("/single-key-without-kid-thumbprint"); + || request.uri().endsWith("/single-key-without-kid-thumbprint") + || request.uri().endsWith("/openid/connect/keys"); } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/SlackCodeFlowResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/SlackCodeFlowResource.java new file mode 100644 index 00000000000000..f4a28c399b4fda --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/SlackCodeFlowResource.java @@ -0,0 +1,26 @@ +package io.quarkus.it.keycloak; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.Authenticated; + +@Path("/code-flow-slack") +public class SlackCodeFlowResource { + + public record SlackResponseDto(String userPrincipalName, String userInfoEmail) { + } + + @Inject + UserInfo userInfo; + + @Authenticated + @GET + public SlackResponseDto get(SecurityContext securityContext) { + return new SlackResponseDto(securityContext.getUserPrincipal().getName(), userInfo.getEmail()); + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index edeeaceebf8423..5a5091a4e2fa9f 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -270,3 +270,16 @@ quarkus.grpc.server.use-separate-server=false %issuer-based-resolver.quarkus.oidc.bearer-issuer-resolver.token.audience=https://correct-issuer.edu %issuer-based-resolver.quarkus.oidc.bearer-issuer-resolver.token.allow-jwt-introspection=false %issuer-based-resolver.quarkus.oidc.resolve-tenants-with-issuer=true + +# properties required to configure Slack provider +quarkus.oidc.code-slack.provider=slack +quarkus.oidc.code-slack.client-id=7925551513107.7922794171477 +quarkus.oidc.code-slack.credentials.secret=2b82d6039bc97946460fdec75fadd9b2 +quarkus.oidc.code-slack.authentication.extra-params.team=quarkus-oidc-slack-demo +# test properties required because Slack mock is not identical to Slack +quarkus.oidc.code-slack.tenant-paths=/code-flow-slack +quarkus.oidc.code-slack.auth-server-url=http://localhost:8188 +quarkus.oidc.code-slack.token.lifespan-grace=2147483647 +quarkus.oidc.code-slack.authentication.force-redirect-https-scheme=false +quarkus.oidc.code-slack.authentication.verify-access-token=false +quarkus.oidc.code-slack.authentication.remove-redirect-parameters=false diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java index 4343bc1eebf823..6168618643d278 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java @@ -86,7 +86,7 @@ public void assertOidcServerAvailabilityReported() { String expectAuthServerUrl = RestAssured.get("/oidc-event/expected-auth-server-url").then().statusCode(200).extract() .asString(); RestAssured.given().get("/oidc-event/unavailable-auth-server-urls").then().statusCode(200) - .body(Matchers.is(expectAuthServerUrl)); + .body(Matchers.containsString(expectAuthServerUrl)); RestAssured.given().get("/oidc-event/available-auth-server-urls").then().statusCode(200) .body(Matchers.is(expectAuthServerUrl)); } diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 3c0f6461154c6b..f8cca4b8744e06 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -42,6 +42,7 @@ import org.htmlunit.FailingHttpStatusCodeException; import org.htmlunit.SilentCssErrorHandler; import org.htmlunit.TextPage; +import org.htmlunit.UnexpectedPage; import org.htmlunit.WebClient; import org.htmlunit.WebRequest; import org.htmlunit.WebResponse; @@ -515,6 +516,19 @@ public void testCodeFlowTokenIntrospection() throws Exception { clearCache(); } + @Test + public void testSlackKnownProvider() throws IOException { + try (var ignored = new SlackWiremockTestResource(); var webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + UnexpectedPage page = webClient.getPage("http://localhost:8081/code-flow-slack"); + var responseContent = page.getWebResponse().getContentAsString(); + assertTrue(responseContent.contains("\"userPrincipalName\":\"vavra\"")); + assertTrue(responseContent.contains("\"userInfoEmail\":\"example@example.com\"")); + webClient.getCookieManager().clearCookies(); + } + clearCache(); + } + private void doTestCodeFlowUserInfo(String tenantId, long internalIdTokenLifetime, boolean cacheUserInfoInIdToken, boolean tenantConfigResolver, int inMemoryCacheSize, int userInfoRequests) throws Exception { try (final WebClient webClient = createWebClient()) { diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/SlackWiremockTestResource.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/SlackWiremockTestResource.java new file mode 100644 index 00000000000000..e1535dd2c260dd --- /dev/null +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/SlackWiremockTestResource.java @@ -0,0 +1,150 @@ +package io.quarkus.it.keycloak; + +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 com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +import java.io.Closeable; + +import org.jboss.logging.Logger; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.matching.AnythingPattern; + +public class SlackWiremockTestResource implements Closeable { + + private static final Logger LOG = Logger.getLogger(SlackWiremockTestResource.class); + private static final int PORT = 8188; + private final WireMockServer server; + + SlackWiremockTestResource() { + var config = wireMockConfig().port(PORT).globalTemplating(true); + this.server = new WireMockServer(config); + LOG.info("Starting Slack mock on port " + PORT); + this.server.start(); + configureStubs(); + } + + private void configureStubs() { + server.stubFor( + get(urlMatching("/.well-known/openid-configuration.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "issuer": "https://slack.com", + "authorization_endpoint": "http://localhost:8188/openid/connect/authorize", + "token_endpoint": "http://localhost:8188/api/openid.connect.token", + "userinfo_endpoint": "http://localhost:8188/api/openid.connect.userInfo", + "jwks_uri": "http://localhost:8188/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"] + } + """))); + + server.stubFor( + get(urlMatching("/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/code-flow-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/code-flow-slack?code=7917304849541.79239831" + + "24323.1f4c41812b286422cbce183a9f083fa58f7c2761c281c2be483a376694f56274&state" + + "={{request.query.state}}") + .withBody(""))); + + server.stubFor( + get(urlMatching("/openid/connect/keys.*")) + .willReturn(aResponse() + .withHeader("Set-Cookie", "{{request.headers.Set-Cookie}}") + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "keys": [ + { + "e": "AQAB", + "n": "zQqzXfb677bpMKw0idKC5WkVLyqk04PWMsWYJDKqMUUuu_PmzdsvXBfHU7tcZiNoHDuVvGDqjqnkLPEzjXnaZY0DDDHvJKS0JI8fkxIfV1kNy3DkpQMMhgAwnftUiSXgb5clypOmotAEm59gHPYjK9JHBWoHS14NYEYZv9NVy0EkjauyYDSTz589aiKU5lA-cePG93JnqLw8A82kfTlrJ1IIJo2isyBGANr0YzR-d3b_5EvP7ivU7Ph2v5JcEUHeiLSRzIzP3PuyVFrPH659Deh-UAsDFOyJbIcimg9ITnk5_45sb_Xcd_UN6h5I7TGOAFaJN4oi4aaGD4elNi_K1Q", + "kty": "RSA", + "kid": "mB2MAyKSn555isd0EbdhKx6nkyAi9xLq8rvCEb_nOyY", + "alg": "RS256" + } + ] + } + """))); + + server.stubFor( + post(urlMatching("/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": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1CMk1BeUtTbjU1NWlzZDBFYmRoS3g2bmt5QWk5eExxOHJ2Q0ViX25PeVkifQ.eyJpc3MiOiJodHRwczpcL1wvc2xhY2suY29tIiwic3ViIjoiVTA3VDdKWktHNTgiLCJhdWQiOiI3OTI1NTUxNTEzMTA3Ljc5MjI3OTQxNzE0NzciLCJleHAiOjE3Mjk3MTEwNjEsImlhdCI6MTcyOTcxMDc2MSwiYXV0aF90aW1lIjoxNzI5NzEwNzYxLCJub25jZSI6IiIsImF0X2hhc2giOiJRZTRtQkhIUFl2ZUVzd0NFSUZ1Q3VnIiwiaHR0cHM6XC9cL3NsYWNrLmNvbVwvdGVhbV9pZCI6IlQwN1Q3RzdGMzM1IiwiaHR0cHM6XC9cL3NsYWNrLmNvbVwvdXNlcl9pZCI6IlUwN1Q3SlpLRzU4IiwiZW1haWwiOiJ2YXZyYS56YWxvaGFAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImRhdGVfZW1haWxfdmVyaWZpZWQiOjE3Mjk3MDg5MzAsImxvY2FsZSI6ImVuLVVTIiwibmFtZSI6InZhdnJhIiwicGljdHVyZSI6Imh0dHBzOlwvXC9zZWN1cmUuZ3JhdmF0YXIuY29tXC9hdmF0YXJcL2E4NjE0NWY1M2E4MjcyZDU5NmVkYjgyMDkyOGM2Y2E1LmpwZz9zPTUxMiZkPWh0dHBzJTNBJTJGJTJGYS5zbGFjay1lZGdlLmNvbSUyRmRmMTBkJTJGaW1nJTJGYXZhdGFycyUyRmF2YV8wMDAzLTUxMi5wbmciLCJnaXZlbl9uYW1lIjoidmF2cmEiLCJmYW1pbHlfbmFtZSI6IiIsImh0dHBzOlwvXC9zbGFjay5jb21cL3RlYW1fbmFtZSI6InF1YXJrdXMtb2lkYy1zbGFjay1kZW1vLXdvcmtzcGFjZSIsImh0dHBzOlwvXC9zbGFjay5jb21cL3RlYW1fZG9tYWluIjoicXVhcmt1c29pZGNzbC1pcGE0OTc4IiwiaHR0cHM6XC9cL3NsYWNrLmNvbVwvdGVhbV9pbWFnZV8yMzAiOiJodHRwczpcL1wvYS5zbGFjay1lZGdlLmNvbVwvODA1ODhcL2ltZ1wvYXZhdGFycy10ZWFtc1wvYXZhXzAwMjYtMjMwLnBuZyIsImh0dHBzOlwvXC9zbGFjay5jb21cL3RlYW1faW1hZ2VfZGVmYXVsdCI6dHJ1ZX0.APy1FtKGxzgk65RhxB1lLO9cUt6MZgQVOTicm6o8sVUUy15W7oor2nBJcnFvhYs0W7i4GQFEBFYEQji8iQWYf14Vq5xuKFAcVi5cHqPxMGNiDLy0cLkEtUmkHImQhAl2aV6W-FZAJosJ3BdYd_Xs3GPwvl01763izwTKe2sWSyyN-eyXHgg48OxLn8pex4l4nvBzRqp3iB_UvW7iujgSppjRteE0UiUnL6hiD269v_O-KCul_HAdaUn5iKUoKsnbjeSE9GE_vXzFFSUl0Nu0N78NS2ENvpodfpSK4fQo4Buh3E2VlehFe_te-bNw1aYIPusDLefyw2lCxy4kqtmgzw", + "state": "{{request.query.state}}" + } + """))); + + server.stubFor( + get(urlMatching("/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 + } + """))); + } + + @Override + public void close() { + server.stop(); + LOG.info("Slack mock was shut down"); + } + +}