diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a8e8d55..c19f7f5 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -30,3 +30,15 @@ updates:
slack:
patterns:
- "com.slack.api:*"
+ - package-ecosystem: "maven"
+ directory: "standardized-auth"
+ schedule:
+ interval: "daily"
+ cooldown:
+ default-days: 7
+ exclude:
+ - "com.slack.api:*"
+ groups:
+ slack:
+ patterns:
+ - "com.slack.api:*"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 551d6d2..a9f2035 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,6 +16,7 @@ jobs:
showcase:
- "block-kit"
- "methods"
+ - "standardized-auth"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/README.md b/README.md
index bc305e3..74b2219 100644
--- a/README.md
+++ b/README.md
@@ -6,3 +6,4 @@ This collections of examples highlights features of a Slack app in the language
- **[Block Kit](./block-kit)**: The framework of visual components arranged to create app layouts.
- **[Methods](./methods)**: An interface for querying information from and enacting change in a Slack workspace.
+- **[Standardized Auth](./standardized-auth)**: Connect user accounts with the Slack-owned Connect and Manage connection controls.
diff --git a/standardized-auth/.env.example b/standardized-auth/.env.example
new file mode 100644
index 0000000..1b64f2a
--- /dev/null
+++ b/standardized-auth/.env.example
@@ -0,0 +1,5 @@
+SLACK_CLIENT_ID=
+SLACK_CLIENT_SECRET=
+SLACK_SIGNING_SECRET=
+BASE_URL=https://1234-56-78-90-0.ngrok-free.app
+PORT=3000
diff --git a/standardized-auth/.gitignore b/standardized-auth/.gitignore
new file mode 100644
index 0000000..2a4ffd0
--- /dev/null
+++ b/standardized-auth/.gitignore
@@ -0,0 +1,7 @@
+.classpath
+.project
+.settings
+target
+data/
+.env*
+!.env.example
diff --git a/standardized-auth/.slack/.gitignore b/standardized-auth/.slack/.gitignore
new file mode 100644
index 0000000..973ba60
--- /dev/null
+++ b/standardized-auth/.slack/.gitignore
@@ -0,0 +1,2 @@
+apps.dev.json
+cache/
diff --git a/standardized-auth/.slack/config.json b/standardized-auth/.slack/config.json
new file mode 100644
index 0000000..909afbe
--- /dev/null
+++ b/standardized-auth/.slack/config.json
@@ -0,0 +1,6 @@
+{
+ "manifest": {
+ "source": "local"
+ },
+ "project_id": "00000000-0000-0000-0000-000000000000"
+}
diff --git a/standardized-auth/.slack/hooks.json b/standardized-auth/.slack/hooks.json
new file mode 100644
index 0000000..9146667
--- /dev/null
+++ b/standardized-auth/.slack/hooks.json
@@ -0,0 +1,8 @@
+{
+ "hooks": {
+ "start": "mvn --quiet compile exec:java"
+ },
+ "config": {
+ "sdk-managed-connection-enabled": true
+ }
+}
diff --git a/standardized-auth/README.md b/standardized-auth/README.md
new file mode 100644
index 0000000..7146f60
--- /dev/null
+++ b/standardized-auth/README.md
@@ -0,0 +1,31 @@
+# Standardized Auth
+
+Connect your Acme account to Slack with the Slack-owned **Connect** and **Manage connection** controls. Rocket skates, anvils, and giant magnets not included.
+
+Read the docs: https://docs.slack.dev/authentication/standardized-auth
+
+## How it works
+
+Acme keeps every connection in Bolt's installation service: a user is connected when it holds their user token.
+
+- **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, the app reports the connection with `apps.user.connection.requestAuth` and refreshes App Home.
+- **Disconnect** forgets the user's token, reports the user as disconnected, then revokes the token.
+- **`user_connection_status_requested`** answers with `apps.user.connection.update` whenever Slack asks about a user it doesn't know yet.
+- **`tokens_revoked`** forgets users who remove Acme from their Slack settings and reports them as disconnected.
+- **App Home** shows an `auth_provider` block with the user's current connection.
+
+## Setup
+
+```sh
+$ ngrok http 3000 # Replace 1234-56-78-90-0.ngrok-free.app in manifest.json
+$ slack install --environment local # Create a new app
+$ slack app settings # Gather the client ID, client secret, and signing secret
+$ slack env init # Update defaults with them and your ngrok URL
+$ slack manifest # Validate fields
+$ slack run
+$ open https://1234-56-78-90-0.ngrok-free.app/slack/install # Install the app
+```
+
+Then open the app's **Home** tab and click **Connect**.
+
+Swap the `users:read` and `users.profile:read` scopes for the ones your connection needs.
diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json
new file mode 100644
index 0000000..89b07bf
--- /dev/null
+++ b/standardized-auth/manifest.json
@@ -0,0 +1,45 @@
+{
+ "display_information": {
+ "name": "Acme",
+ "description": "Rocket skates, anvils, and giant magnets for every plan",
+ "background_color": "#4A154B"
+ },
+ "features": {
+ "app_home": {
+ "home_tab_enabled": true,
+ "messages_tab_enabled": false
+ },
+ "bot_user": {
+ "display_name": "Acme",
+ "always_online": true
+ },
+ "user_connections": {
+ "is_enabled": true,
+ "auth_hosts": ["1234-56-78-90-0.ngrok-free.app"]
+ }
+ },
+ "oauth_config": {
+ "redirect_urls": ["https://1234-56-78-90-0.ngrok-free.app/slack/oauth_redirect"],
+ "scopes": {
+ "bot": ["users:read"],
+ "user": ["users.profile:read"]
+ }
+ },
+ "settings": {
+ "event_subscriptions": {
+ "request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events",
+ "bot_events": [
+ "app_home_opened",
+ "tokens_revoked",
+ "user_connection_status_requested"
+ ]
+ },
+ "interactivity": {
+ "is_enabled": true,
+ "request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events"
+ },
+ "org_deploy_enabled": true,
+ "socket_mode_enabled": false,
+ "token_rotation_enabled": false
+ }
+}
diff --git a/standardized-auth/pom.xml b/standardized-auth/pom.xml
new file mode 100644
index 0000000..e0b1530
--- /dev/null
+++ b/standardized-auth/pom.xml
@@ -0,0 +1,90 @@
+
+ 4.0.0
+ com.slack.api
+ standardized-auth-example
+ 0.1-SNAPSHOT
+ jar
+
+ UTF-8
+ acme.Main
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.16.0
+
+ 17
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.6.4
+
+ false
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 3.10.4
+
+
+
+
+ .gitignore
+
+
+
+
+ true
+ 4
+
+
+
+
+
+
+
+
+
+ **/*.md
+
+
+
+
+
+
+
+ apply
+
+ compile
+
+
+
+
+
+
+
+ com.slack.api
+ bolt-jetty
+ 1.52.1-SNAPSHOT
+
+
+ org.slf4j
+ slf4j-simple
+ 1.7.36
+ runtime
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 6.1.3
+ test
+
+
+
diff --git a/standardized-auth/src/main/java/acme/AcmeAccounts.java b/standardized-auth/src/main/java/acme/AcmeAccounts.java
new file mode 100644
index 0000000..01acc1d
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/AcmeAccounts.java
@@ -0,0 +1,33 @@
+package acme;
+
+import com.slack.api.bolt.context.Context;
+import com.slack.api.bolt.model.Installer;
+import com.slack.api.bolt.service.InstallationService;
+import java.util.Optional;
+
+public final class AcmeAccounts {
+ private final InstallationService installations;
+
+ public AcmeAccounts(InstallationService installations) {
+ this.installations = installations;
+ }
+
+ public Optional find(Context context, String userId) {
+ return Optional.ofNullable(installations.findInstaller(context.getEnterpriseId(), context.getTeamId(), userId))
+ .filter(installer -> installer.getInstallerUserAccessToken() != null);
+ }
+
+ public Optional forget(Context context, String userId) {
+ Optional account = find(context, userId);
+ account.ifPresent(this::delete);
+ return account.map(Installer::getInstallerUserAccessToken);
+ }
+
+ private void delete(Installer installer) {
+ try {
+ installations.deleteInstaller(installer);
+ } catch (Exception error) {
+ throw new IllegalStateException("Failed to forget the Acme account", error);
+ }
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/AcmeApp.java b/standardized-auth/src/main/java/acme/AcmeApp.java
new file mode 100644
index 0000000..df49f0c
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/AcmeApp.java
@@ -0,0 +1,58 @@
+package acme;
+
+import com.slack.api.bolt.App;
+import com.slack.api.bolt.AppConfig;
+import com.slack.api.bolt.service.InstallationService;
+import com.slack.api.bolt.service.OAuthStateService;
+import com.slack.api.model.event.AppHomeOpenedEvent;
+import com.slack.api.model.event.TokensRevokedEvent;
+import com.slack.api.model.event.UserConnectionStatusRequestedEvent;
+
+public final class AcmeApp {
+ private AcmeApp() {}
+
+ public static AppConfig config() {
+ return AppConfig.builder()
+ .singleTeamBotToken(null)
+ .scope("users:read")
+ .userScope("users.profile:read")
+ .oauthInstallPath("install")
+ .oauthRedirectUriPath("oauth_redirect")
+ .oAuthInstallPathEnabled(true)
+ .oAuthRedirectUriPathEnabled(true)
+ .oAuthInstallPageRenderingEnabled(false)
+ .build();
+ }
+
+ public static App api(AppConfig config, InstallationService installations, AcmeListeners listeners) {
+ App app = new App(config).service(installations);
+ app.event(AppHomeOpenedEvent.class, (payload, ctx) -> {
+ listeners.publishHome(ctx.client(), ctx, payload.getEvent().getUser());
+ return ctx.ack();
+ });
+ app.event(UserConnectionStatusRequestedEvent.class, (payload, ctx) -> {
+ listeners.answerStatusRequest(ctx.client(), ctx, payload.getEvent().getUser());
+ return ctx.ack();
+ });
+ app.event(TokensRevokedEvent.class, (payload, ctx) -> {
+ listeners.forgetRevokedUsers(
+ ctx.client(), ctx, payload.getEvent().getTokens().getOauth());
+ return ctx.ack();
+ });
+ app.blockAction(AcmeProvider.CONNECT_ACTION_ID, (req, ctx) -> ctx.ack());
+ app.blockAction(AcmeProvider.DISCONNECT_ACTION_ID, (req, ctx) -> {
+ listeners.disconnect(ctx.client(), ctx, req.getPayload().getUser().getId());
+ return ctx.ack();
+ });
+ return app;
+ }
+
+ public static App oauth(
+ AppConfig config, InstallationService installations, OAuthStateService states, AcmeListeners listeners) {
+ return new App(config)
+ .asOAuthApp(true)
+ .service(installations)
+ .service(states)
+ .oauthPersistenceCallback(listeners::reportConnected);
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/AcmeListeners.java b/standardized-auth/src/main/java/acme/AcmeListeners.java
new file mode 100644
index 0000000..6a72bec
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/AcmeListeners.java
@@ -0,0 +1,116 @@
+package acme;
+
+import acme.AcmeProvider.Connection;
+import com.slack.api.Slack;
+import com.slack.api.bolt.context.Context;
+import com.slack.api.bolt.model.Installer;
+import com.slack.api.bolt.response.Response;
+import com.slack.api.bolt.service.builtin.oauth.OAuthV2SuccessPersistenceCallback;
+import com.slack.api.methods.MethodsClient;
+import com.slack.api.methods.SlackApiException;
+import com.slack.api.methods.SlackApiTextResponse;
+import com.slack.api.methods.response.auth.AuthTestResponse;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+public final class AcmeListeners {
+ private final Slack slack;
+ private final AcmeAccounts accounts;
+ private final AcmeProvider provider;
+
+ public AcmeListeners(Slack slack, AcmeAccounts accounts, AcmeProvider provider) {
+ this.slack = slack;
+ this.accounts = accounts;
+ this.provider = provider;
+ }
+
+ public void reportConnected(OAuthV2SuccessPersistenceCallback.Arguments args) {
+ Installer installer = args.getInstaller();
+ MethodsClient client = slack.methods(installer.getBotAccessToken(), installer.getTeamId());
+ try {
+ report(client, installer.getInstallerUserId(), connectionOf(client, Optional.of(installer)));
+ } catch (IOException | SlackApiException error) {
+ throw new IllegalStateException("Failed to report the Acme connection", error);
+ }
+ showSuccessPage(args);
+ }
+
+ public void publishHome(MethodsClient client, Context context, String userId)
+ throws IOException, SlackApiException {
+ Optional connection = connectionOf(client, accounts.find(context, userId));
+ requireOk(client.viewsPublish(request -> request.userId(userId).view(provider.homeView(connection))));
+ }
+
+ public void answerStatusRequest(MethodsClient client, Context context, String userId)
+ throws IOException, SlackApiException {
+ String status = accounts.find(context, userId).isPresent() ? "connected" : "disconnected";
+ requireOk(client.appsUserConnectionUpdate(
+ request -> request.userId(userId).status(status)));
+ }
+
+ public void forgetRevokedUsers(MethodsClient client, Context context, List userIds)
+ throws IOException, SlackApiException {
+ for (String userId : Optional.ofNullable(userIds).orElse(List.of())) {
+ accounts.forget(context, userId);
+ report(client, userId, Optional.empty());
+ }
+ }
+
+ public void handleUserConnection(
+ MethodsClient client, Context context, String subtype, String userId, String triggerId)
+ throws IOException, SlackApiException {
+ if (subtype.equals("connect")) {
+ openConnectModal(client, context, userId, triggerId);
+ } else if (subtype.equals("disconnect")) {
+ disconnect(client, context, userId);
+ }
+ }
+
+ public void disconnect(MethodsClient client, Context context, String userId) throws IOException, SlackApiException {
+ Optional forgottenToken = accounts.forget(context, userId);
+ report(client, userId, Optional.empty());
+ if (forgottenToken.isPresent()) {
+ requireOk(client.authRevoke(request -> request.token(forgottenToken.get())));
+ }
+ }
+
+ private void openConnectModal(MethodsClient client, Context context, String userId, String triggerId)
+ throws IOException, SlackApiException {
+ Optional connection = connectionOf(client, accounts.find(context, userId));
+ requireOk(client.appsUserConnectionRequestAuth(
+ request -> request.triggerId(triggerId).primaryProvider(provider.primaryProvider(connection))));
+ }
+
+ private void report(MethodsClient client, String userId, Optional connection)
+ throws IOException, SlackApiException {
+ requireOk(client.appsUserConnectionRequestAuth(
+ request -> request.userId(userId).primaryProvider(provider.primaryProvider(connection))));
+ requireOk(client.viewsPublish(request -> request.userId(userId).view(provider.homeView(connection))));
+ }
+
+ private static Optional connectionOf(MethodsClient client, Optional account)
+ throws IOException, SlackApiException {
+ if (account.isEmpty()) {
+ return Optional.empty();
+ }
+ AuthTestResponse identity =
+ requireOk(client.authTest(request -> request.token(account.get().getInstallerUserAccessToken())));
+ return Optional.of(new Connection("@" + identity.getUser(), identity.getTeam()));
+ }
+
+ private static void showSuccessPage(OAuthV2SuccessPersistenceCallback.Arguments args) {
+ Response response = args.getResponse();
+ response.setStatusCode(200);
+ response.setContentType("text/html; charset=utf-8");
+ response.setBody(args.getPageRenderer()
+ .renderSuccessPage(args.getInstaller(), args.getAppConfig().getOauthCompletionUrl()));
+ }
+
+ private static T requireOk(T response) {
+ if (!response.isOk()) {
+ throw new IllegalStateException(response.getError());
+ }
+ return response;
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/AcmeProvider.java b/standardized-auth/src/main/java/acme/AcmeProvider.java
new file mode 100644
index 0000000..58fe022
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/AcmeProvider.java
@@ -0,0 +1,84 @@
+package acme;
+
+import static com.slack.api.model.block.Blocks.authProvider;
+import static com.slack.api.model.block.Blocks.section;
+import static com.slack.api.model.block.composition.BlockCompositions.markdownText;
+
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider;
+import com.slack.api.model.block.AuthProviderBlock;
+import com.slack.api.model.block.composition.BlockCompositions;
+import com.slack.api.model.block.composition.PlainTextObject;
+import com.slack.api.model.view.View;
+import java.util.List;
+import java.util.Optional;
+
+public final class AcmeProvider {
+ public static final String VALUE = "acme";
+ public static final String CONNECT_ACTION_ID = "auth_provider_connect_" + VALUE;
+ public static final String DISCONNECT_ACTION_ID = "auth_provider_disconnect_" + VALUE;
+
+ public static final String WELCOME_GREETING = """
+ *Welcome to Acme!* :rocket:
+ Connect your Acme account to start ordering. Anvils ship same day, gravity permitting.""";
+ public static final String CONNECTED_GREETING = """
+ *You're all set!* :tada:
+ Your Acme account is connected. Please keep giant magnets away from your laptop.""";
+
+ public record Connection(String identity, String context) {}
+
+ private final String authUrl;
+
+ public AcmeProvider(String authUrl) {
+ this.authUrl = authUrl;
+ }
+
+ public PrimaryProvider primaryProvider(Optional connection) {
+ return PrimaryProvider.builder()
+ .value(VALUE)
+ .name(name())
+ .description(description())
+ .authUrl(authUrl)
+ .isConnected(connection.isPresent())
+ .connectedIdentity(connection
+ .map(Connection::identity)
+ .map(BlockCompositions::plainText)
+ .orElse(null))
+ .connectedContext(connection
+ .map(Connection::context)
+ .map(BlockCompositions::plainText)
+ .orElse(null))
+ .build();
+ }
+
+ public AuthProviderBlock block(Optional connection) {
+ return authProvider(block -> block.value(VALUE)
+ .name(name())
+ .description(description())
+ .authUrl(authUrl)
+ .isConnected(connection.isPresent())
+ .connectedIdentity(connection
+ .map(Connection::identity)
+ .map(BlockCompositions::plainText)
+ .orElse(null))
+ .connectedContext(connection
+ .map(Connection::context)
+ .map(BlockCompositions::plainText)
+ .orElse(null)));
+ }
+
+ public View homeView(Optional connection) {
+ String greeting = connection.isPresent() ? CONNECTED_GREETING : WELCOME_GREETING;
+ return View.builder()
+ .type("home")
+ .blocks(List.of(section(section -> section.text(markdownText(greeting))), block(connection)))
+ .build();
+ }
+
+ private static PlainTextObject name() {
+ return BlockCompositions.plainText("Acme account");
+ }
+
+ private static PlainTextObject description() {
+ return BlockCompositions.plainText("Order rocket skates, anvils, and giant magnets without leaving Slack.");
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/Main.java b/standardized-auth/src/main/java/acme/Main.java
new file mode 100644
index 0000000..a98c7cb
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/Main.java
@@ -0,0 +1,32 @@
+package acme;
+
+import com.slack.api.Slack;
+import com.slack.api.bolt.App;
+import com.slack.api.bolt.jetty.SlackAppServer;
+import com.slack.api.bolt.service.InstallationService;
+import com.slack.api.bolt.service.builtin.FileInstallationService;
+import com.slack.api.bolt.service.builtin.FileOAuthStateService;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+public class Main {
+ public static void main(String[] args) throws Exception {
+ String baseUrl =
+ Objects.requireNonNull(System.getenv("BASE_URL"), "Set BASE_URL to the public URL of this app");
+ InstallationService installations = new FileInstallationService(AcmeApp.config(), "./data/installations");
+ AcmeListeners listeners = new AcmeListeners(
+ Slack.getInstance(), new AcmeAccounts(installations), new AcmeProvider(baseUrl + "/slack/install"));
+
+ App api = AcmeApp.api(AcmeApp.config(), installations, listeners);
+ App oauth = AcmeApp.oauth(
+ AcmeApp.config(),
+ installations,
+ new FileOAuthStateService(AcmeApp.config(), "./data/states"),
+ listeners);
+
+ int port = Integer.parseInt(Optional.ofNullable(System.getenv("PORT")).orElse("3000"));
+ new SlackAppServer(new HashMap<>(Map.of("/slack/events", api, "/slack/", oauth)), port).start();
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/AcmeAccountsTest.java b/standardized-auth/src/test/java/acme/AcmeAccountsTest.java
new file mode 100644
index 0000000..0d6d40b
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/AcmeAccountsTest.java
@@ -0,0 +1,80 @@
+package acme;
+
+import static acme.Fixtures.context;
+import static acme.Fixtures.installer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.slack.api.bolt.AppConfig;
+import com.slack.api.bolt.service.InstallationService;
+import com.slack.api.bolt.service.builtin.FileInstallationService;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class AcmeAccountsTest {
+ @TempDir
+ Path directory;
+
+ private InstallationService installations;
+ private AcmeAccounts accounts;
+
+ @BeforeEach
+ public void setUp() {
+ AppConfig config = AppConfig.builder().clientId("111.222").build();
+ installations = new FileInstallationService(config, directory.toString());
+ accounts = new AcmeAccounts(installations);
+ }
+
+ @Test
+ public void testUsersWhoNeverInstalledHaveNoAccount() {
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ }
+
+ @Test
+ public void testInstallationsWithoutAUserTokenAreNotAccounts() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", null));
+
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ }
+
+ @Test
+ public void testFindsTheAccountOfAUserWhoConnected() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ assertEquals(
+ "xoxp-test-ada", accounts.find(context(), "U0ADA").orElseThrow().getInstallerUserAccessToken());
+ }
+
+ @Test
+ public void testFindsOrgWideAccountsFromAnyWorkspaceInTheOrg() throws Exception {
+ installations.saveInstallerAndBot(installer("E0001", null, "U0ADA", "xoxp-test-ada"));
+
+ assertTrue(accounts.find(context("E0001", "T0002"), "U0ADA").isPresent());
+ }
+
+ @Test
+ public void testForgetReturnsTheTokenAndKeepsOtherUsersConnected() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+ installations.saveInstallerAndBot(installer("U0BOB", "xoxp-test-bob"));
+
+ assertEquals(Optional.of("xoxp-test-ada"), accounts.forget(context(), "U0ADA"));
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ assertTrue(accounts.find(context(), "U0BOB").isPresent());
+ }
+
+ @Test
+ public void testForgetsOrgWideAccounts() throws Exception {
+ installations.saveInstallerAndBot(installer("E0001", null, "U0ADA", "xoxp-test-ada"));
+
+ assertEquals(Optional.of("xoxp-test-ada"), accounts.forget(context("E0001", "T0002"), "U0ADA"));
+ assertEquals(Optional.empty(), accounts.find(context("E0001", "T0002"), "U0ADA"));
+ }
+
+ @Test
+ public void testForgettingAUserWithoutAnAccountReturnsNothing() {
+ assertEquals(Optional.empty(), accounts.forget(context(), "U0ADA"));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/AcmeAppTest.java b/standardized-auth/src/test/java/acme/AcmeAppTest.java
new file mode 100644
index 0000000..37d28c9
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/AcmeAppTest.java
@@ -0,0 +1,146 @@
+package acme;
+
+import static acme.Fixtures.context;
+import static acme.Fixtures.installer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.slack.api.app_backend.SlackSignature;
+import com.slack.api.bolt.App;
+import com.slack.api.bolt.AppConfig;
+import com.slack.api.bolt.request.RequestHeaders;
+import com.slack.api.bolt.request.builtin.BlockActionRequest;
+import com.slack.api.bolt.request.builtin.EventRequest;
+import com.slack.api.bolt.response.Response;
+import com.slack.api.bolt.service.InstallationService;
+import com.slack.api.bolt.service.builtin.FileInstallationService;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class AcmeAppTest {
+ private static final String SIGNING_SECRET = "test_signing_secret";
+ private static final String REQUEST_AUTH = "apps.user.connection.requestAuth";
+
+ @TempDir
+ Path directory;
+
+ private FakeSlackApi slackApi;
+ private AcmeAccounts accounts;
+ private App app;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ slackApi = new FakeSlackApi();
+ AppConfig config = AppConfig.builder()
+ .clientId("111.222")
+ .signingSecret(SIGNING_SECRET)
+ .singleTeamBotToken(null)
+ .slack(slackApi.slack())
+ .build();
+ InstallationService installations = new FileInstallationService(config, directory.toString());
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+ accounts = new AcmeAccounts(installations);
+ AcmeProvider provider = new AcmeProvider("https://acme.example/slack/install");
+ app = AcmeApp.api(config, installations, new AcmeListeners(slackApi.slack(), accounts, provider));
+ }
+
+ @AfterEach
+ public void tearDown() {
+ slackApi.close();
+ }
+
+ @Test
+ public void testConnectClicksAreAcknowledged() throws Exception {
+ Response response = app.run(blockAction(AcmeProvider.CONNECT_ACTION_ID));
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(List.of(), apiCalls());
+ }
+
+ @Test
+ public void testDisconnectClicksForgetTheAccount() throws Exception {
+ Response response = app.run(blockAction(AcmeProvider.DISCONNECT_ACTION_ID));
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ assertEquals(List.of(REQUEST_AUTH, "views.publish", "auth.revoke"), apiCalls());
+ }
+
+ @Test
+ public void testAppHomeOpenedPublishesTheHomeView() throws Exception {
+ Response response = app.run(event("{\"type\":\"app_home_opened\",\"user\":\"U0ADA\"}"));
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(List.of("views.publish"), apiCalls());
+ }
+
+ @Test
+ public void testStatusRequestsAreAnswered() throws Exception {
+ Response response = app.run(event("{\"type\":\"user_connection_status_requested\",\"user\":\"U0ADA\"}"));
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(
+ "connected",
+ slackApi.call("apps.user.connection.update").params().get("status"));
+ }
+
+ @Test
+ public void testRevokedUserTokensAreForgotten() throws Exception {
+ Response response = app.run(event("{\"type\":\"tokens_revoked\",\"tokens\":{\"oauth\":[\"U0ADA\"]}}"));
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ }
+
+ private List apiCalls() {
+ return slackApi.methods().stream()
+ .filter(method -> !method.equals("auth.test"))
+ .toList();
+ }
+
+ private static BlockActionRequest blockAction(String actionId) {
+ String payload = """
+ {
+ "type": "block_actions",
+ "api_app_id": "A0001",
+ "team": { "id": "T0001" },
+ "user": { "id": "U0ADA", "team_id": "T0001" },
+ "trigger_id": "123.456.abc",
+ "actions": [
+ { "type": "button", "action_id": "%s", "block_id": "acme", "action_ts": "1700000000.000001" }
+ ]
+ }
+ """.formatted(actionId);
+ String body = "payload=" + URLEncoder.encode(payload, StandardCharsets.UTF_8);
+ return new BlockActionRequest(body, payload, signedHeaders(body));
+ }
+
+ private static EventRequest event(String event) {
+ String body = """
+ {
+ "type": "event_callback",
+ "api_app_id": "A0001",
+ "team_id": "T0001",
+ "event": %s,
+ "event_id": "Ev0001",
+ "event_time": 1700000000
+ }
+ """.formatted(event);
+ return new EventRequest(body, signedHeaders(body));
+ }
+
+ private static RequestHeaders signedHeaders(String body) {
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+ String signature = new SlackSignature.Generator(SIGNING_SECRET).generate(timestamp, body);
+ return new RequestHeaders(Map.of(
+ "X-Slack-Request-Timestamp", List.of(timestamp),
+ "X-Slack-Signature", List.of(signature)));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/AcmeListenersTest.java b/standardized-auth/src/test/java/acme/AcmeListenersTest.java
new file mode 100644
index 0000000..a449d8f
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/AcmeListenersTest.java
@@ -0,0 +1,191 @@
+package acme;
+
+import static acme.Fixtures.context;
+import static acme.Fixtures.installer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import acme.AcmeProvider.Connection;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.slack.api.bolt.AppConfig;
+import com.slack.api.bolt.response.Response;
+import com.slack.api.bolt.service.InstallationService;
+import com.slack.api.bolt.service.builtin.FileInstallationService;
+import com.slack.api.bolt.service.builtin.oauth.OAuthV2SuccessPersistenceCallback;
+import com.slack.api.methods.MethodsClient;
+import com.slack.api.util.json.GsonFactory;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class AcmeListenersTest {
+ private static final Optional ADA = Optional.of(new Connection("@ada", "Acme Corp"));
+ private static final String REQUEST_AUTH = "apps.user.connection.requestAuth";
+
+ @TempDir
+ Path directory;
+
+ private final AcmeProvider provider = new AcmeProvider("https://acme.example/slack/install");
+ private final AppConfig config = AppConfig.builder().clientId("111.222").build();
+ private FakeSlackApi slackApi;
+ private InstallationService installations;
+ private AcmeAccounts accounts;
+ private AcmeListeners listeners;
+ private MethodsClient client;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ slackApi = new FakeSlackApi();
+ installations = new FileInstallationService(config, directory.toString());
+ accounts = new AcmeAccounts(installations);
+ listeners = new AcmeListeners(slackApi.slack(), accounts, provider);
+ client = slackApi.slack().methods("xoxb-test-bot");
+ }
+
+ @AfterEach
+ public void tearDown() {
+ slackApi.close();
+ }
+
+ @Test
+ public void testReportConnectedRecordsTheConnectionThenShowsTheSuccessPage() {
+ Response response = new Response();
+
+ listeners.reportConnected(OAuthV2SuccessPersistenceCallback.Arguments.builder()
+ .appConfig(config)
+ .pageRenderer(config.getOAuthRedirectUriPageRenderer())
+ .installer(installer("U0ADA", "xoxp-test-ada"))
+ .response(response)
+ .build());
+
+ assertEquals(List.of("auth.test", REQUEST_AUTH, "views.publish"), slackApi.methods());
+ assertEquals("xoxp-test-ada", slackApi.call("auth.test").token());
+ assertEquals("xoxb-test-bot", slackApi.call(REQUEST_AUTH).token());
+ assertEquals("U0ADA", slackApi.call(REQUEST_AUTH).params().get("user_id"));
+ assertEquals(json(provider.primaryProvider(ADA)), param(REQUEST_AUTH, "primary_provider"));
+ assertEquals(200, response.getStatusCode());
+ }
+
+ @Test
+ public void testPublishHomeShowsAConnectedAccount() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.publishHome(client, context(), "U0ADA");
+
+ assertEquals(List.of("auth.test", "views.publish"), slackApi.methods());
+ assertEquals("U0ADA", slackApi.call("views.publish").params().get("user_id"));
+ assertEquals(json(provider.homeView(ADA)), param("views.publish", "view"));
+ }
+
+ @Test
+ public void testPublishHomeOffersToConnectUsersWithoutAnAccount() throws Exception {
+ listeners.publishHome(client, context(), "U0ADA");
+
+ assertEquals(List.of("views.publish"), slackApi.methods());
+ assertEquals(json(provider.homeView(Optional.empty())), param("views.publish", "view"));
+ }
+
+ @Test
+ public void testDisconnectForgetsReportsThenRevokes() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.disconnect(client, context(), "U0ADA");
+
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ assertEquals(List.of(REQUEST_AUTH, "views.publish", "auth.revoke"), slackApi.methods());
+ assertEquals(json(provider.primaryProvider(Optional.empty())), param(REQUEST_AUTH, "primary_provider"));
+ assertEquals("xoxp-test-ada", slackApi.call("auth.revoke").token());
+ }
+
+ @Test
+ public void testDisconnectingWithoutAnAccountStillReportsDisconnected() throws Exception {
+ listeners.disconnect(client, context(), "U0ADA");
+
+ assertEquals(List.of(REQUEST_AUTH, "views.publish"), slackApi.methods());
+ }
+
+ @Test
+ public void testAnswerStatusRequestReportsConnectedUsers() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.answerStatusRequest(client, context(), "U0ADA");
+
+ assertEquals(
+ "connected",
+ slackApi.call("apps.user.connection.update").params().get("status"));
+ }
+
+ @Test
+ public void testAnswerStatusRequestReportsUsersWithoutAnAccountAsDisconnected() throws Exception {
+ listeners.answerStatusRequest(client, context(), "U0ADA");
+
+ assertEquals(
+ "disconnected",
+ slackApi.call("apps.user.connection.update").params().get("status"));
+ }
+
+ @Test
+ public void testForgetRevokedUsersReportsThemDisconnected() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.forgetRevokedUsers(client, context(), List.of("U0ADA"));
+
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ assertEquals(List.of(REQUEST_AUTH, "views.publish"), slackApi.methods());
+ }
+
+ @Test
+ public void testForgetRevokedUsersIgnoresRevokedBotTokens() throws Exception {
+ listeners.forgetRevokedUsers(client, context(), null);
+
+ assertEquals(List.of(), slackApi.methods());
+ }
+
+ @Test
+ public void testUserConnectionConnectOpensTheAuthModal() throws Exception {
+ listeners.handleUserConnection(client, context(), "connect", "U0ADA", "123.456.abc");
+
+ assertEquals(List.of(REQUEST_AUTH), slackApi.methods());
+ assertEquals("123.456.abc", slackApi.call(REQUEST_AUTH).params().get("trigger_id"));
+ assertEquals(json(provider.primaryProvider(Optional.empty())), param(REQUEST_AUTH, "primary_provider"));
+ }
+
+ @Test
+ public void testUserConnectionConnectShowsAnExistingConnection() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.handleUserConnection(client, context(), "connect", "U0ADA", "123.456.abc");
+
+ assertEquals(json(provider.primaryProvider(ADA)), param(REQUEST_AUTH, "primary_provider"));
+ }
+
+ @Test
+ public void testUserConnectionDisconnectForgetsTheAccount() throws Exception {
+ installations.saveInstallerAndBot(installer("U0ADA", "xoxp-test-ada"));
+
+ listeners.handleUserConnection(client, context(), "disconnect", "U0ADA", null);
+
+ assertEquals(Optional.empty(), accounts.find(context(), "U0ADA"));
+ assertTrue(slackApi.methods().contains("auth.revoke"));
+ }
+
+ @Test
+ public void testUserConnectionWithAnUnknownSubtypeCallsNothing() throws Exception {
+ listeners.handleUserConnection(client, context(), "reconnect", "U0ADA", null);
+
+ assertEquals(List.of(), slackApi.methods());
+ }
+
+ private JsonElement param(String method, String name) {
+ return JsonParser.parseString(slackApi.call(method).params().get(name));
+ }
+
+ private static JsonElement json(Object value) {
+ return JsonParser.parseString(GsonFactory.createSnakeCase().toJson(value));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/AcmeProviderTest.java b/standardized-auth/src/test/java/acme/AcmeProviderTest.java
new file mode 100644
index 0000000..9bef281
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/AcmeProviderTest.java
@@ -0,0 +1,90 @@
+package acme;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import acme.AcmeProvider.Connection;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.slack.api.model.block.SectionBlock;
+import com.slack.api.model.view.View;
+import com.slack.api.util.json.GsonFactory;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+public class AcmeProviderTest {
+ private static final String AUTH_URL = "https://acme.example/slack/install";
+ private static final Optional ADA = Optional.of(new Connection("@ada", "Acme Corp"));
+
+ private final AcmeProvider provider = new AcmeProvider(AUTH_URL);
+
+ @Test
+ public void testActionIdsFollowTheProviderValue() {
+ assertEquals("auth_provider_connect_acme", AcmeProvider.CONNECT_ACTION_ID);
+ assertEquals("auth_provider_disconnect_acme", AcmeProvider.DISCONNECT_ACTION_ID);
+ }
+
+ @Test
+ public void testDisconnectedProviderOffersTheAcmeSignIn() {
+ String expected = """
+ {
+ "value": "acme",
+ "name": { "type": "plain_text", "text": "Acme account" },
+ "description": {
+ "type": "plain_text",
+ "text": "Order rocket skates, anvils, and giant magnets without leaving Slack."
+ },
+ "auth_url": "https://acme.example/slack/install",
+ "is_connected": false
+ }
+ """;
+ assertEquals(JsonParser.parseString(expected), json(provider.primaryProvider(Optional.empty())));
+ }
+
+ @Test
+ public void testConnectedProviderNamesTheConnectedAccount() {
+ JsonObject connected = json(provider.primaryProvider(ADA)).getAsJsonObject();
+
+ assertEquals(true, connected.get("is_connected").getAsBoolean());
+ assertEquals(
+ JsonParser.parseString("{\"type\":\"plain_text\",\"text\":\"@ada\"}"),
+ connected.get("connected_identity"));
+ assertEquals(
+ JsonParser.parseString("{\"type\":\"plain_text\",\"text\":\"Acme Corp\"}"),
+ connected.get("connected_context"));
+ }
+
+ @Test
+ public void testBlockMatchesThePrimaryProvider() {
+ JsonObject block = json(provider.block(ADA)).getAsJsonObject();
+
+ assertEquals("auth_provider", block.remove("type").getAsString());
+ assertEquals(json(provider.primaryProvider(ADA)), block);
+ }
+
+ @Test
+ public void testHomeViewRendersTheProviderAsAnAuthProviderBlock() {
+ View view = provider.homeView(Optional.empty());
+
+ assertEquals("home", view.getType());
+ assertEquals(
+ json(provider.block(Optional.empty())), json(view.getBlocks().get(1)));
+ }
+
+ @Test
+ public void testHomeViewWelcomesUsersWhoAreNotConnected() {
+ assertEquals(AcmeProvider.WELCOME_GREETING, greeting(provider.homeView(Optional.empty())));
+ }
+
+ @Test
+ public void testHomeViewCelebratesUsersWhoAreConnected() {
+ assertEquals(AcmeProvider.CONNECTED_GREETING, greeting(provider.homeView(ADA)));
+ }
+
+ private static String greeting(View view) {
+ return ((SectionBlock) view.getBlocks().get(0)).getText().getText();
+ }
+
+ private static com.google.gson.JsonElement json(Object value) {
+ return JsonParser.parseString(GsonFactory.createSnakeCase().toJson(value));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/FakeSlackApi.java b/standardized-auth/src/test/java/acme/FakeSlackApi.java
new file mode 100644
index 0000000..ab6dac1
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/FakeSlackApi.java
@@ -0,0 +1,87 @@
+package acme;
+
+import com.slack.api.Slack;
+import com.slack.api.SlackConfig;
+import com.slack.api.methods.MethodsConfig;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Collectors;
+
+public class FakeSlackApi implements AutoCloseable {
+ public record Call(String method, String token, Map params) {}
+
+ private static final byte[] REPLY =
+ "{\"ok\":true,\"user\":\"ada\",\"team\":\"Acme Corp\"}".getBytes(StandardCharsets.UTF_8);
+
+ private final HttpServer server;
+ private final List calls = new CopyOnWriteArrayList<>();
+
+ public FakeSlackApi() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext("/api/", this::record);
+ server.start();
+ }
+
+ public Slack slack() {
+ MethodsConfig methodsConfig = new MethodsConfig();
+ methodsConfig.setStatsEnabled(false);
+ SlackConfig config = new SlackConfig();
+ config.setMethodsConfig(methodsConfig);
+ config.setMethodsEndpointUrlPrefix(
+ "http://localhost:" + server.getAddress().getPort() + "/api/");
+ return Slack.getInstance(config);
+ }
+
+ public List calls() {
+ return calls;
+ }
+
+ public List methods() {
+ return calls.stream().map(Call::method).toList();
+ }
+
+ public Call call(String method) {
+ return calls.stream()
+ .filter(call -> call.method().equals(method))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private void record(HttpExchange exchange) throws IOException {
+ String method = exchange.getRequestURI().getPath().substring("/api/".length());
+ String authorization = exchange.getRequestHeaders().getFirst("Authorization");
+ String token = authorization == null ? null : authorization.substring("Bearer ".length());
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ calls.add(new Call(method, token, formParams(body)));
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, REPLY.length);
+ exchange.getResponseBody().write(REPLY);
+ exchange.close();
+ }
+
+ private static Map formParams(String body) {
+ if (body.isEmpty()) {
+ return Map.of();
+ }
+ return Arrays.stream(body.split("&"))
+ .map(pair -> pair.split("=", 2))
+ .collect(Collectors.toMap(pair -> decode(pair[0]), pair -> decode(pair[1])));
+ }
+
+ private static String decode(String value) {
+ return URLDecoder.decode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/Fixtures.java b/standardized-auth/src/test/java/acme/Fixtures.java
new file mode 100644
index 0000000..a46cf73
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/Fixtures.java
@@ -0,0 +1,40 @@
+package acme;
+
+import com.slack.api.bolt.context.Context;
+import com.slack.api.bolt.context.builtin.ActionContext;
+import com.slack.api.bolt.model.Installer;
+import com.slack.api.bolt.model.builtin.DefaultInstaller;
+
+public final class Fixtures {
+ private Fixtures() {}
+
+ public static Installer installer(String userId, String userToken) {
+ return installer(null, "T0001", userId, userToken);
+ }
+
+ public static Installer installer(String enterpriseId, String teamId, String userId, String userToken) {
+ return DefaultInstaller.builder()
+ .appId("A0001")
+ .enterpriseId(enterpriseId)
+ .teamId(teamId)
+ .isEnterpriseInstall(teamId == null)
+ .installerUserId(userId)
+ .installerUserAccessToken(userToken)
+ .botUserId("U0BOT")
+ .botId("B0001")
+ .botAccessToken("xoxb-test-bot")
+ .installedAt(System.currentTimeMillis())
+ .build();
+ }
+
+ public static Context context() {
+ return context(null, "T0001");
+ }
+
+ public static Context context(String enterpriseId, String teamId) {
+ ActionContext context = new ActionContext();
+ context.setEnterpriseId(enterpriseId);
+ context.setTeamId(teamId);
+ return context;
+ }
+}