Skip to content

Commit

Permalink
Add OIDC Slack known provider
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Oct 29, 2024
1 parent 49870be commit 60f305c
Show file tree
Hide file tree
Showing 16 changed files with 306 additions and 3 deletions.
Binary file added docs/src/main/asciidoc/images/oidc-slack-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/src/main/asciidoc/images/oidc-slack-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/src/main/asciidoc/images/oidc-slack-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/src/main/asciidoc/images/oidc-slack-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/src/main/asciidoc/images/oidc-slack-5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions docs/src/main/asciidoc/security-openid-connect-providers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Client ID>
quarkus.oidc.credentials.secret=<Client 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://<your ngrok instance>.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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2009,6 +2009,7 @@ public static enum Provider {
LINKEDIN,
MASTODON,
MICROSOFT,
SLACK,
SPOTIFY,
STRAVA,
TWITCH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,23 @@ 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();
case TWITTER, X -> twitter();
};
}

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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void filter(OidcRequestContext rc) {
private boolean isJwksRequest(HttpRequest<Buffer> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading

0 comments on commit 60f305c

Please sign in to comment.