From 61f9bd10ea2d4f1b04841c7c248a09857bbf3b02 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 16 Oct 2026 11:56:48 -0400 Subject: [PATCH 1/8] feat(standardized-auth): add an acme sample for standardized app auth Connects user accounts with the Slack-owned Connect and Manage connection controls. Bolt's installation service holds each connection: the OAuth success hook reports it with apps.user.connection.requestAuth, the disconnect action forgets and revokes the user token, tokens_revoked forgets users who remove the app, and the user_connection_status_requested event is answered with apps.user.connection.update. App Home renders the auth_provider block. The user_connection request listener is written and tested but not yet registered, since no Bolt routes that payload to action listeners yet. This depends on java-slack-sdk 1.52.1-SNAPSHOT (AuthProviderBlock, the new methods, and the status-requested event model) until it is released. --- .github/dependabot.yml | 12 ++ .github/workflows/test.yml | 1 + README.md | 1 + standardized-auth/.env.example | 5 + standardized-auth/.gitignore | 7 + standardized-auth/.slack/.gitignore | 2 + standardized-auth/.slack/config.json | 6 + standardized-auth/.slack/hooks.json | 8 + standardized-auth/README.md | 31 +++ standardized-auth/manifest.json | 45 +++++ standardized-auth/pom.xml | 90 +++++++++ .../src/main/java/acme/AcmeAccounts.java | 33 +++ .../src/main/java/acme/AcmeApp.java | 58 ++++++ .../src/main/java/acme/AcmeListeners.java | 116 +++++++++++ .../src/main/java/acme/AcmeProvider.java | 84 ++++++++ .../src/main/java/acme/Main.java | 32 +++ .../src/test/java/acme/AcmeAccountsTest.java | 80 ++++++++ .../src/test/java/acme/AcmeAppTest.java | 146 +++++++++++++ .../src/test/java/acme/AcmeListenersTest.java | 191 ++++++++++++++++++ .../src/test/java/acme/AcmeProviderTest.java | 90 +++++++++ .../src/test/java/acme/FakeSlackApi.java | 87 ++++++++ .../src/test/java/acme/Fixtures.java | 40 ++++ 22 files changed, 1165 insertions(+) create mode 100644 standardized-auth/.env.example create mode 100644 standardized-auth/.gitignore create mode 100644 standardized-auth/.slack/.gitignore create mode 100644 standardized-auth/.slack/config.json create mode 100644 standardized-auth/.slack/hooks.json create mode 100644 standardized-auth/README.md create mode 100644 standardized-auth/manifest.json create mode 100644 standardized-auth/pom.xml create mode 100644 standardized-auth/src/main/java/acme/AcmeAccounts.java create mode 100644 standardized-auth/src/main/java/acme/AcmeApp.java create mode 100644 standardized-auth/src/main/java/acme/AcmeListeners.java create mode 100644 standardized-auth/src/main/java/acme/AcmeProvider.java create mode 100644 standardized-auth/src/main/java/acme/Main.java create mode 100644 standardized-auth/src/test/java/acme/AcmeAccountsTest.java create mode 100644 standardized-auth/src/test/java/acme/AcmeAppTest.java create mode 100644 standardized-auth/src/test/java/acme/AcmeListenersTest.java create mode 100644 standardized-auth/src/test/java/acme/AcmeProviderTest.java create mode 100644 standardized-auth/src/test/java/acme/FakeSlackApi.java create mode 100644 standardized-auth/src/test/java/acme/Fixtures.java 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; + } +} From 6be43dfbfe30cd11916ef470e30b266cef13b7c0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 16:16:33 -0400 Subject: [PATCH 2/8] refactor(standardized-auth): mirror the python sample over socket mode Match slack-samples/bolt-python-examples#305: run over Socket Mode, keep connections in a local properties file, handle the user_connection event and the fixed auth_provider_connect and auth_provider_disconnect action IDs, and answer user_connection_status_requested. The OAuth flow, installation service, tokens_revoked, and token revocation are gone. The SDK has no user_connection event model yet, so the sample defines UserConnectionEvent with a null default subtype to match Bolt's routing key. Co-Authored-By: Claude --- standardized-auth/.env.example | 5 - standardized-auth/.gitignore | 3 +- standardized-auth/README.md | 20 +- standardized-auth/manifest.json | 20 +- standardized-auth/pom.xml | 23 +- .../src/main/java/acme/Acme.java | 48 ++++ .../src/main/java/acme/AcmeAccounts.java | 33 --- .../src/main/java/acme/AcmeApp.java | 58 ----- .../src/main/java/acme/AcmeListeners.java | 116 --------- .../src/main/java/acme/AcmeProvider.java | 84 ------- .../src/main/java/acme/Connections.java | 52 ++++ .../src/main/java/acme/Main.java | 104 ++++++-- .../main/java/acme/UserConnectionEvent.java | 30 +++ .../src/test/java/acme/AcmeAccountsTest.java | 80 ------- .../src/test/java/acme/AcmeAppTest.java | 146 ------------ .../src/test/java/acme/AcmeListenersTest.java | 191 --------------- .../src/test/java/acme/AcmeProviderTest.java | 90 ------- .../src/test/java/acme/AcmeTest.java | 76 ++++++ .../src/test/java/acme/ConnectionsTest.java | 55 +++++ .../src/test/java/acme/FakeSlackApi.java | 87 ------- .../src/test/java/acme/Fixtures.java | 40 ---- .../src/test/java/acme/MainTest.java | 225 ++++++++++++++++++ .../src/test/java/acme/TempConnections.java | 21 ++ 23 files changed, 625 insertions(+), 982 deletions(-) delete mode 100644 standardized-auth/.env.example create mode 100644 standardized-auth/src/main/java/acme/Acme.java delete mode 100644 standardized-auth/src/main/java/acme/AcmeAccounts.java delete mode 100644 standardized-auth/src/main/java/acme/AcmeApp.java delete mode 100644 standardized-auth/src/main/java/acme/AcmeListeners.java delete mode 100644 standardized-auth/src/main/java/acme/AcmeProvider.java create mode 100644 standardized-auth/src/main/java/acme/Connections.java create mode 100644 standardized-auth/src/main/java/acme/UserConnectionEvent.java delete mode 100644 standardized-auth/src/test/java/acme/AcmeAccountsTest.java delete mode 100644 standardized-auth/src/test/java/acme/AcmeAppTest.java delete mode 100644 standardized-auth/src/test/java/acme/AcmeListenersTest.java delete mode 100644 standardized-auth/src/test/java/acme/AcmeProviderTest.java create mode 100644 standardized-auth/src/test/java/acme/AcmeTest.java create mode 100644 standardized-auth/src/test/java/acme/ConnectionsTest.java delete mode 100644 standardized-auth/src/test/java/acme/FakeSlackApi.java delete mode 100644 standardized-auth/src/test/java/acme/Fixtures.java create mode 100644 standardized-auth/src/test/java/acme/MainTest.java create mode 100644 standardized-auth/src/test/java/acme/TempConnections.java diff --git a/standardized-auth/.env.example b/standardized-auth/.env.example deleted file mode 100644 index 1b64f2a..0000000 --- a/standardized-auth/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -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 index 2a4ffd0..de7ac38 100644 --- a/standardized-auth/.gitignore +++ b/standardized-auth/.gitignore @@ -2,6 +2,5 @@ .project .settings target -data/ .env* -!.env.example +acme.properties diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 7146f60..d096e5c 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,26 +6,22 @@ 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. +Acme runs over Socket Mode and keeps every connection in a local properties file ([`Connections.java`](./src/main/java/acme/Connections.java)): a user is connected when their Slack user ID is in it. The provider object and the App Home view live in [`Acme.java`](./src/main/java/acme/Acme.java), and every listener and Slack API call lives in [`Main.java`](./src/main/java/acme/Main.java). -- **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. +- **Connect** connects the Slack user who clicked it, with their user ID as the connected identity. It reports the connection with `apps.user.connection.requestAuth` and refreshes App Home. +- **Connect** or **Manage** on the app's profile sends the `user_connection` event, and Acme answers within 3 seconds by opening the connection modal with `apps.user.connection.requestAuth` and the event's `trigger_id`. +- **Disconnect** forgets the connection and reports the user as disconnected. - **`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. +Connecting in one click stands in for your service's real login. Slack requires at least one host in the manifest's `features.user_connections.auth_hosts`, so `example.com` holds the place until you have one. To sign users in to your own service, set `auth_url` on the provider to your OAuth or SSO page, replace `example.com` with its host, and carry the Slack user through the browser in a signed `state` so a connection always lands on the user who clicked **Connect**. + ## Setup +Everything arrives over Socket Mode, so the app needs no public URL. The manifest subscribes to the `user_connection` and `user_connection_status_requested` events, which every app with `features.user_connections` needs. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest. + ```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 index 89b07bf..6b30ae7 100644 --- a/standardized-auth/manifest.json +++ b/standardized-auth/manifest.json @@ -1,13 +1,11 @@ { "display_information": { "name": "Acme", - "description": "Rocket skates, anvils, and giant magnets for every plan", - "background_color": "#4A154B" + "description": "Rocket skates, anvils, and giant magnets for every plan" }, "features": { "app_home": { - "home_tab_enabled": true, - "messages_tab_enabled": false + "home_tab_enabled": true }, "bot_user": { "display_name": "Acme", @@ -15,31 +13,27 @@ }, "user_connections": { "is_enabled": true, - "auth_hosts": ["1234-56-78-90-0.ngrok-free.app"] + "auth_hosts": ["example.com"] } }, "oauth_config": { - "redirect_urls": ["https://1234-56-78-90-0.ngrok-free.app/slack/oauth_redirect"], "scopes": { - "bot": ["users:read"], - "user": ["users.profile:read"] + "bot": ["users: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", "user_connection_status_requested" ] }, "interactivity": { - "is_enabled": true, - "request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events" + "is_enabled": true }, "org_deploy_enabled": true, - "socket_mode_enabled": false, + "socket_mode_enabled": true, "token_rotation_enabled": false } } diff --git a/standardized-auth/pom.xml b/standardized-auth/pom.xml index e0b1530..ddf81d3 100644 --- a/standardized-auth/pom.xml +++ b/standardized-auth/pom.xml @@ -71,9 +71,24 @@ com.slack.api - bolt-jetty + bolt 1.52.1-SNAPSHOT + + com.slack.api + bolt-socket-mode + 1.52.1-SNAPSHOT + + + javax.websocket + javax.websocket-api + 1.1 + + + org.glassfish.tyrus.bundles + tyrus-standalone-client + 1.21 + org.slf4j slf4j-simple @@ -86,5 +101,11 @@ 6.1.3 test + + org.mockito + mockito-junit-jupiter + 5.23.0 + test + diff --git a/standardized-auth/src/main/java/acme/Acme.java b/standardized-auth/src/main/java/acme/Acme.java new file mode 100644 index 0000000..ef1849c --- /dev/null +++ b/standardized-auth/src/main/java/acme/Acme.java @@ -0,0 +1,48 @@ +package acme; + +import static com.slack.api.model.block.Blocks.asBlocks; +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 static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.view.Views.view; + +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; +import com.slack.api.model.view.View; + +public final class Acme { + public static final String PROVIDER_VALUE = "acme"; + + private Acme() {} + + public static PrimaryProvider provider(String userId) { + PrimaryProvider acme = PrimaryProvider.builder() + .value(PROVIDER_VALUE) + .name(plainText("Acme account")) + .description(plainText("Order rocket skates, anvils, and giant magnets without leaving Slack.")) + .isConnected(Connections.isConnected(userId)) + .build(); + if (acme.getIsConnected()) { + acme.setConnectedIdentity(plainText(userId)); + } + return acme; + } + + public static View homeView(PrimaryProvider acme) { + return view(view -> view.type("home") + .blocks(asBlocks( + section(section -> section.text(markdownText("*Welcome to Acme!* :rocket:\n" + + "Anvils ship same day, gravity permitting. " + + "Please keep giant magnets away from your laptop."))), + authProvider(block -> block.value(acme.getValue()) + .name(acme.getName()) + .description(acme.getDescription()) + .iconUrl(acme.getIconUrl()) + .authUrl(acme.getAuthUrl()) + .disconnectUrl(acme.getDisconnectUrl()) + .isConnected(acme.getIsConnected()) + .connectedIdentity(acme.getConnectedIdentity()) + .connectedContext(acme.getConnectedContext()) + .accessory(acme.getAccessory()))))); + } +} diff --git a/standardized-auth/src/main/java/acme/AcmeAccounts.java b/standardized-auth/src/main/java/acme/AcmeAccounts.java deleted file mode 100644 index 01acc1d..0000000 --- a/standardized-auth/src/main/java/acme/AcmeAccounts.java +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index df49f0c..0000000 --- a/standardized-auth/src/main/java/acme/AcmeApp.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 6a72bec..0000000 --- a/standardized-auth/src/main/java/acme/AcmeListeners.java +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 58fe022..0000000 --- a/standardized-auth/src/main/java/acme/AcmeProvider.java +++ /dev/null @@ -1,84 +0,0 @@ -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/Connections.java b/standardized-auth/src/main/java/acme/Connections.java new file mode 100644 index 0000000..8469172 --- /dev/null +++ b/standardized-auth/src/main/java/acme/Connections.java @@ -0,0 +1,52 @@ +package acme; + +import java.io.IOException; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public final class Connections { + static Path filename = Path.of("acme.properties"); + + private Connections() {} + + public static void connect(String userId) { + Properties db = read(); + db.setProperty(userId, "true"); + write(db); + } + + public static void disconnect(String userId) { + Properties db = read(); + db.remove(userId); + write(db); + } + + public static boolean isConnected(String userId) { + return read().containsKey(userId); + } + + private static Properties read() { + Properties db = new Properties(); + if (Files.notExists(filename)) { + return db; + } + try (Reader reader = Files.newBufferedReader(filename)) { + db.load(reader); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + return db; + } + + private static void write(Properties db) { + try (Writer writer = Files.newBufferedWriter(filename)) { + db.store(writer, null); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } +} diff --git a/standardized-auth/src/main/java/acme/Main.java b/standardized-auth/src/main/java/acme/Main.java index a98c7cb..904d532 100644 --- a/standardized-auth/src/main/java/acme/Main.java +++ b/standardized-auth/src/main/java/acme/Main.java @@ -1,32 +1,88 @@ package acme; -import com.slack.api.Slack; +import static acme.Acme.PROVIDER_VALUE; +import static acme.Acme.homeView; +import static acme.Acme.provider; + +import com.slack.api.app_backend.events.payload.EventsApiPayload; +import com.slack.api.app_backend.interactive_components.payload.BlockActionPayload; 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; +import com.slack.api.bolt.context.builtin.ActionContext; +import com.slack.api.bolt.context.builtin.EventContext; +import com.slack.api.bolt.request.builtin.BlockActionRequest; +import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.socket_mode.SocketModeApp; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; +import com.slack.api.model.event.AppHomeOpenedEvent; +import com.slack.api.model.event.UserConnectionStatusRequestedEvent; +import java.io.IOException; 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(); + App app = new App(); + app.event(AppHomeOpenedEvent.class, Main::appHomeOpenedCallback); + app.blockAction("auth_provider_connect", Main::connectCallback); + app.blockAction("auth_provider_disconnect", Main::disconnectCallback); + app.event(UserConnectionEvent.class, Main::userConnectionCallback); + app.event(UserConnectionStatusRequestedEvent.class, Main::userConnectionStatusRequestedCallback); + new SocketModeApp(app).start(); + } + + public static Response appHomeOpenedCallback(EventsApiPayload payload, EventContext ctx) + throws IOException, SlackApiException { + String userId = payload.getEvent().getUser(); + ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(provider(userId)))); + return ctx.ack(); + } + + public static Response connectCallback(BlockActionRequest req, ActionContext ctx) + throws IOException, SlackApiException { + BlockActionPayload body = req.getPayload(); + if (!PROVIDER_VALUE.equals(body.getActions().get(0).getValue())) { + return ctx.ack(); + } + String userId = body.getUser().getId(); + Connections.connect(userId); + PrimaryProvider acme = provider(userId); + ctx.client() + .appsUserConnectionRequestAuth(request -> request.userId(userId).primaryProvider(acme)); + ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(acme))); + return ctx.ack(); + } + + public static Response disconnectCallback(BlockActionRequest req, ActionContext ctx) + throws IOException, SlackApiException { + BlockActionPayload body = req.getPayload(); + if (!PROVIDER_VALUE.equals(body.getActions().get(0).getValue())) { + return ctx.ack(); + } + String userId = body.getUser().getId(); + Connections.disconnect(userId); + PrimaryProvider acme = provider(userId); + ctx.client() + .appsUserConnectionRequestAuth(request -> request.userId(userId).primaryProvider(acme)); + ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(acme))); + return ctx.ack(); + } + + public static Response userConnectionCallback(EventsApiPayload payload, EventContext ctx) + throws IOException, SlackApiException { + UserConnectionEvent event = payload.getEvent(); + if ("connect".equals(event.getSubtype())) { + ctx.client() + .appsUserConnectionRequestAuth(request -> + request.triggerId(event.getTriggerId()).primaryProvider(provider(event.getUser()))); + } + return ctx.ack(); + } + + public static Response userConnectionStatusRequestedCallback( + EventsApiPayload payload, EventContext ctx) + throws IOException, SlackApiException { + String userId = payload.getEvent().getUser(); + String status = Connections.isConnected(userId) ? "connected" : "disconnected"; + ctx.client().appsUserConnectionUpdate(request -> request.userId(userId).status(status)); + return ctx.ack(); } } diff --git a/standardized-auth/src/main/java/acme/UserConnectionEvent.java b/standardized-auth/src/main/java/acme/UserConnectionEvent.java new file mode 100644 index 0000000..ca1d3f7 --- /dev/null +++ b/standardized-auth/src/main/java/acme/UserConnectionEvent.java @@ -0,0 +1,30 @@ +package acme; + +import com.slack.api.model.event.Event; + +public class UserConnectionEvent implements Event { + public static final String TYPE_NAME = "user_connection"; + + private final String type = TYPE_NAME; + private String subtype; + private String user; + private String triggerId; + + @Override + public String getType() { + return type; + } + + @Override + public String getSubtype() { + return subtype; + } + + public String getUser() { + return user; + } + + public String getTriggerId() { + return triggerId; + } +} diff --git a/standardized-auth/src/test/java/acme/AcmeAccountsTest.java b/standardized-auth/src/test/java/acme/AcmeAccountsTest.java deleted file mode 100644 index 0d6d40b..0000000 --- a/standardized-auth/src/test/java/acme/AcmeAccountsTest.java +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 37d28c9..0000000 --- a/standardized-auth/src/test/java/acme/AcmeAppTest.java +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index a449d8f..0000000 --- a/standardized-auth/src/test/java/acme/AcmeListenersTest.java +++ /dev/null @@ -1,191 +0,0 @@ -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 deleted file mode 100644 index 9bef281..0000000 --- a/standardized-auth/src/test/java/acme/AcmeProviderTest.java +++ /dev/null @@ -1,90 +0,0 @@ -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/AcmeTest.java b/standardized-auth/src/test/java/acme/AcmeTest.java new file mode 100644 index 0000000..9b6547f --- /dev/null +++ b/standardized-auth/src/test/java/acme/AcmeTest.java @@ -0,0 +1,76 @@ +package acme; + +import static acme.Acme.homeView; +import static acme.Acme.provider; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; +import com.slack.api.model.view.View; +import com.slack.api.util.json.GsonFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AcmeTest { + private TempConnections connections; + + @BeforeEach + public void setUp() throws Exception { + connections = new TempConnections(); + } + + @AfterEach + public void tearDown() throws Exception { + connections.close(); + } + + @Test + public void testDisconnectedProviderOffersToConnect() { + 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." + }, + "is_connected": false + } + """; + + assertEquals(JsonParser.parseString(expected), json(provider("U0ADA"))); + } + + @Test + public void testConnectedProviderNamesTheSlackUser() { + Connections.connect("U0ADA"); + + PrimaryProvider acme = provider("U0ADA"); + + assertTrue(acme.getIsConnected()); + assertEquals( + JsonParser.parseString("{\"type\":\"plain_text\",\"text\":\"U0ADA\"}"), + json(acme.getConnectedIdentity())); + } + + @Test + public void testHomeViewRendersTheProviderAsAnAuthProviderBlock() { + PrimaryProvider acme = provider("U0ADA"); + + View view = homeView(acme); + + JsonObject block = + json(view.getBlocks().get(view.getBlocks().size() - 1)).getAsJsonObject(); + assertEquals("home", view.getType()); + assertEquals("auth_provider", block.remove("type").getAsString()); + assertEquals(json(acme), block); + } + + private static JsonElement json(Object value) { + return JsonParser.parseString(GsonFactory.createSnakeCase().toJson(value)); + } +} diff --git a/standardized-auth/src/test/java/acme/ConnectionsTest.java b/standardized-auth/src/test/java/acme/ConnectionsTest.java new file mode 100644 index 0000000..7a8caf7 --- /dev/null +++ b/standardized-auth/src/test/java/acme/ConnectionsTest.java @@ -0,0 +1,55 @@ +package acme; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class ConnectionsTest { + private TempConnections connections; + + @BeforeEach + public void setUp() throws Exception { + connections = new TempConnections(); + } + + @AfterEach + public void tearDown() throws Exception { + connections.close(); + } + + @Test + public void testRemembersAConnectedUser() { + Connections.connect("U0ADA"); + + assertTrue(Connections.isConnected("U0ADA")); + } + + @Test + public void testDisconnectForgetsTheUser() { + Connections.connect("U0ADA"); + + Connections.disconnect("U0ADA"); + + assertFalse(Connections.isConnected("U0ADA")); + } + + @Test + public void testDisconnectKeepsOtherUsersConnected() { + Connections.connect("U0ADA"); + Connections.connect("U0BOB"); + + Connections.disconnect("U0ADA"); + + assertTrue(Connections.isConnected("U0BOB")); + } + + @Test + public void testDisconnectingAnUnknownUserChangesNothing() { + Connections.disconnect("U0ADA"); + + assertFalse(Connections.isConnected("U0ADA")); + } +} diff --git a/standardized-auth/src/test/java/acme/FakeSlackApi.java b/standardized-auth/src/test/java/acme/FakeSlackApi.java deleted file mode 100644 index ab6dac1..0000000 --- a/standardized-auth/src/test/java/acme/FakeSlackApi.java +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index a46cf73..0000000 --- a/standardized-auth/src/test/java/acme/Fixtures.java +++ /dev/null @@ -1,40 +0,0 @@ -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; - } -} diff --git a/standardized-auth/src/test/java/acme/MainTest.java b/standardized-auth/src/test/java/acme/MainTest.java new file mode 100644 index 0000000..8ff87b6 --- /dev/null +++ b/standardized-auth/src/test/java/acme/MainTest.java @@ -0,0 +1,225 @@ +package acme; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.slack.api.RequestConfigurator; +import com.slack.api.app_backend.events.payload.EventsApiPayload; +import com.slack.api.bolt.context.builtin.ActionContext; +import com.slack.api.bolt.context.builtin.EventContext; +import com.slack.api.bolt.request.RequestHeaders; +import com.slack.api.bolt.request.builtin.BlockActionRequest; +import com.slack.api.bolt.response.Response; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest; +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; +import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionUpdateRequest; +import com.slack.api.methods.request.views.ViewsPublishRequest; +import com.slack.api.model.block.AuthProviderBlock; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.event.AppHomeOpenedEvent; +import com.slack.api.model.event.Event; +import com.slack.api.model.event.UserConnectionStatusRequestedEvent; +import com.slack.api.util.json.GsonFactory; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class MainTest { + private TempConnections connections; + + @Mock + private MethodsClient client; + + @Captor + private ArgumentCaptor< + RequestConfigurator< + AppsUserConnectionRequestAuthRequest.AppsUserConnectionRequestAuthRequestBuilder>> + requestAuth; + + @Captor + private ArgumentCaptor> + update; + + @Captor + private ArgumentCaptor> publish; + + private ActionContext actionContext; + private EventContext eventContext; + + @BeforeEach + public void setUp() throws Exception { + connections = new TempConnections(); + actionContext = spy(new ActionContext()); + eventContext = spy(new EventContext()); + lenient().doReturn(client).when(actionContext).client(); + lenient().doReturn(client).when(eventContext).client(); + } + + @AfterEach + public void tearDown() throws Exception { + connections.close(); + } + + @Test + public void testAppHomeOpenedShowsTheConnection() throws Exception { + Connections.connect("U0ADA"); + AppHomeOpenedEvent event = new AppHomeOpenedEvent(); + event.setUser("U0ADA"); + + Main.appHomeOpenedCallback(payload(event), eventContext); + + assertEquals("U0ADA", published().getUserId()); + assertTrue(homeBlock().getIsConnected()); + } + + @Test + public void testConnectSavesAndShowsTheConnection() throws Exception { + Response response = Main.connectCallback(buttonClick("acme"), actionContext); + + assertEquals(200, response.getStatusCode()); + assertTrue(Connections.isConnected("U0ADA")); + assertTrue(sentProvider().getIsConnected()); + assertTrue(homeBlock().getIsConnected()); + } + + @Test + public void testDisconnectForgetsAndShowsTheConnection() throws Exception { + Connections.connect("U0ADA"); + + Response response = Main.disconnectCallback(buttonClick("acme"), actionContext); + + assertEquals(200, response.getStatusCode()); + assertFalse(Connections.isConnected("U0ADA")); + assertFalse(sentProvider().getIsConnected()); + assertFalse(homeBlock().getIsConnected()); + } + + @Test + public void testConnectIgnoresAnotherProvider() throws Exception { + Response response = Main.connectCallback(buttonClick("wile-e-coyote"), actionContext); + + assertEquals(200, response.getStatusCode()); + assertFalse(Connections.isConnected("U0ADA")); + verifyNoInteractions(client); + } + + @Test + public void testDisconnectIgnoresAnotherProvider() throws Exception { + Connections.connect("U0ADA"); + + Response response = Main.disconnectCallback(buttonClick("wile-e-coyote"), actionContext); + + assertEquals(200, response.getStatusCode()); + assertTrue(Connections.isConnected("U0ADA")); + verifyNoInteractions(client); + } + + @Test + public void testUserConnectionConnectOpensTheModal() throws Exception { + UserConnectionEvent event = + userConnection("{\"subtype\":\"connect\",\"user\":\"U0ADA\",\"trigger_id\":\"123.456.abc\"}"); + + Main.userConnectionCallback(payload(event), eventContext); + + AppsUserConnectionRequestAuthRequest request = sentRequestAuth(); + assertEquals("123.456.abc", request.getTriggerId()); + assertFalse(request.getPrimaryProvider().getIsConnected()); + } + + @Test + public void testUserConnectionDisconnectIsIgnored() throws Exception { + UserConnectionEvent event = + userConnection("{\"subtype\":\"disconnect\",\"user\":\"U0ADA\",\"trigger_id\":\"123.456.abc\"}"); + + Main.userConnectionCallback(payload(event), eventContext); + + verifyNoInteractions(client); + } + + @Test + public void testStatusRequestedReportsConnectedUsers() throws Exception { + Connections.connect("U0ADA"); + + Main.userConnectionStatusRequestedCallback(payload(statusRequested("U0ADA")), eventContext); + + AppsUserConnectionUpdateRequest request = sentUpdate(); + assertEquals("U0ADA", request.getUserId()); + assertEquals("connected", request.getStatus()); + } + + @Test + public void testStatusRequestedReportsUnknownUsersAsDisconnected() throws Exception { + Main.userConnectionStatusRequestedCallback(payload(statusRequested("U0ADA")), eventContext); + + AppsUserConnectionUpdateRequest request = sentUpdate(); + assertEquals("U0ADA", request.getUserId()); + assertEquals("disconnected", request.getStatus()); + } + + private AppsUserConnectionRequestAuthRequest sentRequestAuth() throws Exception { + verify(client).appsUserConnectionRequestAuth(requestAuth.capture()); + return requestAuth + .getValue() + .configure(AppsUserConnectionRequestAuthRequest.builder()) + .build(); + } + + private PrimaryProvider sentProvider() throws Exception { + return sentRequestAuth().getPrimaryProvider(); + } + + private AppsUserConnectionUpdateRequest sentUpdate() throws Exception { + verify(client).appsUserConnectionUpdate(update.capture()); + return update.getValue() + .configure(AppsUserConnectionUpdateRequest.builder()) + .build(); + } + + private ViewsPublishRequest published() throws Exception { + verify(client).viewsPublish(publish.capture()); + return publish.getValue().configure(ViewsPublishRequest.builder()).build(); + } + + private AuthProviderBlock homeBlock() throws Exception { + List blocks = published().getView().getBlocks(); + return (AuthProviderBlock) blocks.get(blocks.size() - 1); + } + + private static BlockActionRequest buttonClick(String value) { + String payload = "{\"user\":{\"id\":\"U0ADA\"},\"actions\":[{\"value\":\"%s\"}]}".formatted(value); + return new BlockActionRequest(null, payload, new RequestHeaders(Map.of())); + } + + private static UserConnectionEvent userConnection(String json) { + return GsonFactory.createSnakeCase().fromJson(json, UserConnectionEvent.class); + } + + private static UserConnectionStatusRequestedEvent statusRequested(String userId) { + UserConnectionStatusRequestedEvent event = new UserConnectionStatusRequestedEvent(); + event.setUser(userId); + return event; + } + + @SuppressWarnings("unchecked") + private static EventsApiPayload payload(E event) { + EventsApiPayload payload = mock(EventsApiPayload.class); + when(payload.getEvent()).thenReturn(event); + return payload; + } +} diff --git a/standardized-auth/src/test/java/acme/TempConnections.java b/standardized-auth/src/test/java/acme/TempConnections.java new file mode 100644 index 0000000..df84543 --- /dev/null +++ b/standardized-auth/src/test/java/acme/TempConnections.java @@ -0,0 +1,21 @@ +package acme; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class TempConnections { + private final Path original = Connections.filename; + private final Path dir; + + public TempConnections() throws IOException { + dir = Files.createTempDirectory("acme-"); + Connections.filename = dir.resolve("acme.properties"); + } + + public void close() throws IOException { + Files.deleteIfExists(Connections.filename); + Files.delete(dir); + Connections.filename = original; + } +} From e9e39a29d8e3db8705049fee8e07f12081a06b56 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 16:21:08 -0400 Subject: [PATCH 3/8] test(standardized-auth): pin the user_connection routing key Assert Bolt keys UserConnectionEvent as user_connection, and restore the connections path before deleting the temp directory. Co-Authored-By: Claude --- .../src/test/java/acme/TempConnections.java | 5 +++-- .../src/test/java/acme/UserConnectionEventTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 standardized-auth/src/test/java/acme/UserConnectionEventTest.java diff --git a/standardized-auth/src/test/java/acme/TempConnections.java b/standardized-auth/src/test/java/acme/TempConnections.java index df84543..7777db7 100644 --- a/standardized-auth/src/test/java/acme/TempConnections.java +++ b/standardized-auth/src/test/java/acme/TempConnections.java @@ -14,8 +14,9 @@ public TempConnections() throws IOException { } public void close() throws IOException { - Files.deleteIfExists(Connections.filename); - Files.delete(dir); + Path file = Connections.filename; Connections.filename = original; + Files.deleteIfExists(file); + Files.delete(dir); } } diff --git a/standardized-auth/src/test/java/acme/UserConnectionEventTest.java b/standardized-auth/src/test/java/acme/UserConnectionEventTest.java new file mode 100644 index 0000000..6dbfa63 --- /dev/null +++ b/standardized-auth/src/test/java/acme/UserConnectionEventTest.java @@ -0,0 +1,13 @@ +package acme; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.slack.api.bolt.util.EventsApiPayloadParser; +import org.junit.jupiter.api.Test; + +public class UserConnectionEventTest { + @Test + public void testRoutesByTypeOnly() { + assertEquals("user_connection", EventsApiPayloadParser.getEventTypeAndSubtype(UserConnectionEvent.class)); + } +} From a0deaea186775415a0e462011ad0dbb613c966cf Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 22 Oct 2026 16:20:29 -0400 Subject: [PATCH 4/8] feat(standardized-auth): log slack api calls at debug like the python sample The Java client returns ok:false responses without throwing, and Bolt logs nothing at slf4j-simple's default INFO level, so a failed requestAuth left no trace. Raise com.slack.api to debug so the SDK's DetailedLoggingListener prints each call and its response, with the Authorization header redacted. Co-Authored-By: Claude --- standardized-auth/src/main/resources/simplelogger.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 standardized-auth/src/main/resources/simplelogger.properties diff --git a/standardized-auth/src/main/resources/simplelogger.properties b/standardized-auth/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..e973137 --- /dev/null +++ b/standardized-auth/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.log.com.slack.api=debug From 0cca7241bdd090dc96825b2efbbd5598495729fd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 22 Oct 2026 16:20:29 -0400 Subject: [PATCH 5/8] docs(standardized-auth): name the java and maven versions the sample runs on Note that slack run starts the app with mvn compile exec:java from the start hook in .slack/hooks.json, and point to the debug logging config. Co-Authored-By: Claude --- standardized-auth/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index d096e5c..921ccce 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -20,6 +20,8 @@ Connecting in one click stands in for your service's real login. Slack requires Everything arrives over Socket Mode, so the app needs no public URL. The manifest subscribes to the `user_connection` and `user_connection_status_requested` events, which every app with `features.user_connections` needs. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest. +Acme runs on Java 17 with Maven. `slack run` starts it with `mvn compile exec:java`, the `start` hook in [`.slack/hooks.json`](./.slack/hooks.json), and every Slack API call and its response is logged at debug ([`simplelogger.properties`](./src/main/resources/simplelogger.properties)). + ```sh $ slack run ``` From 59e95f5c99f72c4b7fab501d1a240544732b4cb8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 22 Oct 2026 16:47:07 -0400 Subject: [PATCH 6/8] refactor(standardized-auth): use the sdk's user_connection event model java-slack-sdk#1660 now adds UserConnectionEvent, so drop the sample's stand-in and its test. MainTest keeps the routing check that one listener receives both the connect and disconnect subtypes. Co-Authored-By: Claude --- .../src/main/java/acme/Main.java | 1 + .../main/java/acme/UserConnectionEvent.java | 30 ------------------- .../src/test/java/acme/MainTest.java | 7 +++++ .../java/acme/UserConnectionEventTest.java | 13 -------- 4 files changed, 8 insertions(+), 43 deletions(-) delete mode 100644 standardized-auth/src/main/java/acme/UserConnectionEvent.java delete mode 100644 standardized-auth/src/test/java/acme/UserConnectionEventTest.java diff --git a/standardized-auth/src/main/java/acme/Main.java b/standardized-auth/src/main/java/acme/Main.java index 904d532..31e22b6 100644 --- a/standardized-auth/src/main/java/acme/Main.java +++ b/standardized-auth/src/main/java/acme/Main.java @@ -15,6 +15,7 @@ import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; import com.slack.api.model.event.AppHomeOpenedEvent; +import com.slack.api.model.event.UserConnectionEvent; import com.slack.api.model.event.UserConnectionStatusRequestedEvent; import java.io.IOException; diff --git a/standardized-auth/src/main/java/acme/UserConnectionEvent.java b/standardized-auth/src/main/java/acme/UserConnectionEvent.java deleted file mode 100644 index ca1d3f7..0000000 --- a/standardized-auth/src/main/java/acme/UserConnectionEvent.java +++ /dev/null @@ -1,30 +0,0 @@ -package acme; - -import com.slack.api.model.event.Event; - -public class UserConnectionEvent implements Event { - public static final String TYPE_NAME = "user_connection"; - - private final String type = TYPE_NAME; - private String subtype; - private String user; - private String triggerId; - - @Override - public String getType() { - return type; - } - - @Override - public String getSubtype() { - return subtype; - } - - public String getUser() { - return user; - } - - public String getTriggerId() { - return triggerId; - } -} diff --git a/standardized-auth/src/test/java/acme/MainTest.java b/standardized-auth/src/test/java/acme/MainTest.java index 8ff87b6..1adb75f 100644 --- a/standardized-auth/src/test/java/acme/MainTest.java +++ b/standardized-auth/src/test/java/acme/MainTest.java @@ -17,6 +17,7 @@ import com.slack.api.bolt.request.RequestHeaders; import com.slack.api.bolt.request.builtin.BlockActionRequest; import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.util.EventsApiPayloadParser; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest; import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider; @@ -26,6 +27,7 @@ import com.slack.api.model.block.LayoutBlock; import com.slack.api.model.event.AppHomeOpenedEvent; import com.slack.api.model.event.Event; +import com.slack.api.model.event.UserConnectionEvent; import com.slack.api.model.event.UserConnectionStatusRequestedEvent; import com.slack.api.util.json.GsonFactory; import java.util.List; @@ -152,6 +154,11 @@ public void testUserConnectionDisconnectIsIgnored() throws Exception { verifyNoInteractions(client); } + @Test + public void testUserConnectionRoutesBothSubtypesToOneListener() { + assertEquals("user_connection", EventsApiPayloadParser.getEventTypeAndSubtype(UserConnectionEvent.class)); + } + @Test public void testStatusRequestedReportsConnectedUsers() throws Exception { Connections.connect("U0ADA"); diff --git a/standardized-auth/src/test/java/acme/UserConnectionEventTest.java b/standardized-auth/src/test/java/acme/UserConnectionEventTest.java deleted file mode 100644 index 6dbfa63..0000000 --- a/standardized-auth/src/test/java/acme/UserConnectionEventTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package acme; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.slack.api.bolt.util.EventsApiPayloadParser; -import org.junit.jupiter.api.Test; - -public class UserConnectionEventTest { - @Test - public void testRoutesByTypeOnly() { - assertEquals("user_connection", EventsApiPayloadParser.getEventTypeAndSubtype(UserConnectionEvent.class)); - } -} From b17dfdaa1873d6ea42262fe7ae7bfb496778dd0b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 22 Oct 2026 17:03:39 -0400 Subject: [PATCH 7/8] build(standardized-auth): log with logback like the starter template Swap slf4j-simple for logback-classic 1.6.5 and copy the starter template's logback.xml, which keeps com.slack.api at debug so every Slack API call and its response is logged. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/pom.xml | 7 +++---- standardized-auth/src/main/resources/logback.xml | 16 ++++++++++++++++ .../src/main/resources/simplelogger.properties | 1 - 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 standardized-auth/src/main/resources/logback.xml delete mode 100644 standardized-auth/src/main/resources/simplelogger.properties diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 921ccce..5f6fd87 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -20,7 +20,7 @@ Connecting in one click stands in for your service's real login. Slack requires Everything arrives over Socket Mode, so the app needs no public URL. The manifest subscribes to the `user_connection` and `user_connection_status_requested` events, which every app with `features.user_connections` needs. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest. -Acme runs on Java 17 with Maven. `slack run` starts it with `mvn compile exec:java`, the `start` hook in [`.slack/hooks.json`](./.slack/hooks.json), and every Slack API call and its response is logged at debug ([`simplelogger.properties`](./src/main/resources/simplelogger.properties)). +Acme runs on Java 17 with Maven. `slack run` starts it with `mvn compile exec:java`, the `start` hook in [`.slack/hooks.json`](./.slack/hooks.json), and every Slack API call and its response is logged at debug ([`logback.xml`](./src/main/resources/logback.xml)). ```sh $ slack run diff --git a/standardized-auth/pom.xml b/standardized-auth/pom.xml index ddf81d3..61621dd 100644 --- a/standardized-auth/pom.xml +++ b/standardized-auth/pom.xml @@ -90,10 +90,9 @@ 1.21 - org.slf4j - slf4j-simple - 1.7.36 - runtime + ch.qos.logback + logback-classic + 1.6.5 org.junit.jupiter diff --git a/standardized-auth/src/main/resources/logback.xml b/standardized-auth/src/main/resources/logback.xml new file mode 100644 index 0000000..9b1b639 --- /dev/null +++ b/standardized-auth/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/standardized-auth/src/main/resources/simplelogger.properties b/standardized-auth/src/main/resources/simplelogger.properties deleted file mode 100644 index e973137..0000000 --- a/standardized-auth/src/main/resources/simplelogger.properties +++ /dev/null @@ -1 +0,0 @@ -org.slf4j.simpleLogger.log.com.slack.api=debug From 1fbb032bea047c6998dfe4d22155d963af152cd4 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 23 Oct 2026 11:22:12 -0400 Subject: [PATCH 8/8] build(standardized-auth): pin bolt 1.53.0 ahead of its release The next java-slack-sdk minor ships requestAuth and the user_connection models. CI fails to resolve until 1.53.0 is on Maven Central. Co-Authored-By: Claude --- standardized-auth/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/standardized-auth/pom.xml b/standardized-auth/pom.xml index 61621dd..6643f84 100644 --- a/standardized-auth/pom.xml +++ b/standardized-auth/pom.xml @@ -72,12 +72,12 @@ com.slack.api bolt - 1.52.1-SNAPSHOT + 1.53.0 com.slack.api bolt-socket-mode - 1.52.1-SNAPSHOT + 1.53.0 javax.websocket