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; + } +}