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/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OidcDiscoveryJwksRequestCustomizer.java index ac10fcb482248..2587eaf55515d 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 0000000000000..f2f7f473341ca --- /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("/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 edeeaceebf842..5c85055ef0061 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -270,3 +270,17 @@ 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.slack.provider=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 +# test properties required because Slack mock is not identical to Slack +quarkus.oidc.slack.tenant-paths=/slack +quarkus.oidc.slack.auth-server-url=http://localhost:8188 +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/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenOidcRecoveredTest.java index 4343bc1eebf82..50546df57be6b 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,8 +86,8 @@ 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)); + .body(Matchers.containsString(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 3c0f6461154c6..7b7574d1b64b4 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/slack"); + var responseContent = page.getWebResponse().getContentAsString(); + assertTrue(responseContent.contains("\"userPrincipalName\":\"Rosetta\""), responseContent); + assertTrue(responseContent.contains("\"userInfoEmail\":\"example@example.com\""), responseContent); + 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 0000000000000..64dfc8dcb66a0 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/SlackWiremockTestResource.java @@ -0,0 +1,153 @@ +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 java.io.IOException; +import java.util.Map; + +import org.jboss.logging.Logger; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.matching.AnythingPattern; + +import io.quarkus.test.oidc.server.OidcWiremockTestResource; + +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() throws IOException { + 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://server.example.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/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/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": [ + { + "kid": "1", + "kty":"RSA", + "n":"iJw33l1eVAsGoRlSyo-FCimeOc-AaZbzQ2iESA3Nkuo3TFb1zIkmt0kzlnWVGt48dkaIl13Vdefh9hqw_r9yNF8xZqX1fp0PnCWc5M_TX_ht5fm9y0TpbiVmsjeRMWZn4jr3DsFouxQ9aBXUJiu26V0vd2vrECeeAreFT4mtoHY13D2WVeJvboc5mEJcp50JNhxRCJ5UkY8jR_wfUk2Tzz4-fAj5xQaBccXnqJMu_1C6MjoCEiB7G1d13bVPReIeAGRKVJIF6ogoCN8JbrOhc_48lT4uyjbgnd24beatuKWodmWYhactFobRGYo5551cgMe8BoxpVQ4to30cGA0qjQ", + "e":"AQAB" + } + ] + } + """))); + + 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": "%s", + "state": "{{request.query.state}}" + } + """.formatted(OidcWiremockTestResource.getIdToken("Rosetta", + "7925551513107.7922794171477", Map.of("name", "Rosetta")))))); + + 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"); + } +} 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")