diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c72ebfa..3491b05 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -46,3 +46,11 @@ updates: default-days: 7 exclude: - "slack*" + - package-ecosystem: "pip" + directory: "standardized-auth" + schedule: + interval: "daily" + cooldown: + default-days: 7 + exclude: + - "slack*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c24ba7d..078f724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: - "ai/slackbot-mcp-client/slack-identity" - "block-kit" - "methods" + - "standardized-auth" defaults: run: working-directory: ${{ matrix.showcase }} @@ -53,6 +54,7 @@ jobs: - "ai/slackbot-mcp-client/slack-identity" - "block-kit" - "methods" + - "standardized-auth" defaults: run: working-directory: ${{ matrix.showcase }} @@ -82,6 +84,7 @@ jobs: - "ai/slackbot-mcp-client/rich-responses/mcp-apps" - "ai/slackbot-mcp-client/slack-identity" - "block-kit" + - "standardized-auth" defaults: run: working-directory: ${{ matrix.showcase }} diff --git a/README.md b/README.md index 01c9a84..b710f7c 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,4 @@ This collections of examples highlights features of a Slack app in the language - **[AI in Slack](./ai)**: Agent experiences and MCP features in an interactive conversation interface. - **[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/.gitignore b/standardized-auth/.gitignore new file mode 100644 index 0000000..c7b3a74 --- /dev/null +++ b/standardized-auth/.gitignore @@ -0,0 +1,7 @@ +__pycache__ +.mypy_cache +.pytest_cache +.ruff_cache +.venv +.env* +acme.db* 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..ce474c9 --- /dev/null +++ b/standardized-auth/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/standardized-auth/README.md b/standardized-auth/README.md new file mode 100644 index 0000000..ad87614 --- /dev/null +++ b/standardized-auth/README.md @@ -0,0 +1,27 @@ +# 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 runs over Socket Mode and keeps every connection in a local `shelve` file ([`src/connections.py`](./src/connections.py)): a user is connected when their Slack user ID is in it. The provider object and the App Home view live in [`src/acme.py`](./src/acme.py), and every listener and Slack API call lives in [`app.py`](./app.py). + +- **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. +- **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` (the SDK's `StatelessOAuthStateStore` works for this) 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 +$ slack run +``` + +Then open the app's **Home** tab and click **Connect**. diff --git a/standardized-auth/app.py b/standardized-auth/app.py new file mode 100644 index 0000000..444cdf5 --- /dev/null +++ b/standardized-auth/app.py @@ -0,0 +1,57 @@ +import os + +from slack_bolt import Ack, App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +from src import connections +from src.acme import PROVIDER_VALUE, home_view, provider + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.event("app_home_opened") +def app_home_opened_callback(client: WebClient, event: dict): + client.views_publish(user_id=event["user"], view=home_view(provider(event["user"]))) + + +@app.action("auth_provider_connect") +def connect_callback(ack: Ack, body: dict, client: WebClient): + ack() + if body["actions"][0]["value"] != PROVIDER_VALUE: + return + user_id = body["user"]["id"] + connections.connect(user_id) + acme = provider(user_id) + client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme) + client.views_publish(user_id=user_id, view=home_view(acme)) + + +@app.action("auth_provider_disconnect") +def disconnect_callback(ack: Ack, body: dict, client: WebClient): + ack() + if body["actions"][0]["value"] != PROVIDER_VALUE: + return + user_id = body["user"]["id"] + connections.disconnect(user_id) + acme = provider(user_id) + client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme) + client.views_publish(user_id=user_id, view=home_view(acme)) + + +@app.event("user_connection") +def user_connection_callback(client: WebClient, event: dict): + if event["subtype"] == "connect": + client.apps_user_connection_requestAuth( + trigger_id=event["trigger_id"], primary_provider=provider(event["user"]) + ) + + +@app.event("user_connection_status_requested") +def user_connection_status_requested_callback(client: WebClient, event: dict): + status = "connected" if connections.is_connected(event["user"]) else "disconnected" + client.apps_user_connection_update(user_id=event["user"], status=status) + + +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json new file mode 100644 index 0000000..6b30ae7 --- /dev/null +++ b/standardized-auth/manifest.json @@ -0,0 +1,39 @@ +{ + "display_information": { + "name": "Acme", + "description": "Rocket skates, anvils, and giant magnets for every plan" + }, + "features": { + "app_home": { + "home_tab_enabled": true + }, + "bot_user": { + "display_name": "Acme", + "always_online": true + }, + "user_connections": { + "is_enabled": true, + "auth_hosts": ["example.com"] + } + }, + "oauth_config": { + "scopes": { + "bot": ["users:read"] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_home_opened", + "user_connection", + "user_connection_status_requested" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} diff --git a/standardized-auth/requirements.txt b/standardized-auth/requirements.txt new file mode 100644 index 0000000..078a5cd --- /dev/null +++ b/standardized-auth/requirements.txt @@ -0,0 +1,6 @@ +mypy==2.4.0 +pytest==9.1.1 +ruff==0.16.10 +slack_bolt==1.30.0 +slack_cli_hooks==0.3.0 +slack_sdk @ git+https://github.com/slackapi/python-slack-sdk.git@699e1d1fe91effa4cb03bd0e3e8e5d88f43258b4 diff --git a/standardized-auth/src/__init__.py b/standardized-auth/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py new file mode 100644 index 0000000..3ebae54 --- /dev/null +++ b/standardized-auth/src/acme.py @@ -0,0 +1,37 @@ +from typing import Any + +from slack_sdk.models.blocks import AuthProviderBlock, SectionBlock +from slack_sdk.models.views import View + +from src import connections + +PROVIDER_VALUE = "acme" + + +def provider(user_id: str) -> dict[str, Any]: + acme: dict[str, Any] = { + "value": PROVIDER_VALUE, + "name": {"type": "plain_text", "text": "Acme account"}, + "description": { + "type": "plain_text", + "text": "Order rocket skates, anvils, and giant magnets without leaving Slack.", + }, + "is_connected": connections.is_connected(user_id), + } + if acme["is_connected"]: + acme["connected_identity"] = {"type": "plain_text", "text": user_id} + return acme + + +def home_view(acme: dict[str, Any]) -> View: + return View( + type="home", + blocks=[ + SectionBlock( + text="*Welcome to Acme!* :rocket:\n" + "Anvils ship same day, gravity permitting. " + "Please keep giant magnets away from your laptop." + ), + AuthProviderBlock(**acme), + ], + ) diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py new file mode 100644 index 0000000..906b379 --- /dev/null +++ b/standardized-auth/src/connections.py @@ -0,0 +1,18 @@ +import shelve + +FILENAME = "acme.db" + + +def connect(user_id: str) -> None: + with shelve.open(FILENAME) as db: + db[user_id] = True + + +def disconnect(user_id: str) -> None: + with shelve.open(FILENAME) as db: + db.pop(user_id, None) + + +def is_connected(user_id: str) -> bool: + with shelve.open(FILENAME) as db: + return user_id in db diff --git a/standardized-auth/tests/__init__.py b/standardized-auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py new file mode 100644 index 0000000..545e193 --- /dev/null +++ b/standardized-auth/tests/conftest.py @@ -0,0 +1,6 @@ +import os +from unittest.mock import patch + +os.environ["SLACK_BOT_TOKEN"] = "xoxb-test-bot" + +patch("slack_sdk.WebClient.auth_test").start() diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py new file mode 100644 index 0000000..6d964da --- /dev/null +++ b/standardized-auth/tests/helpers.py @@ -0,0 +1,13 @@ +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +class TempConnections: + def __init__(self): + self.dir = TemporaryDirectory() + self.filename = patch("src.connections.FILENAME", f"{self.dir.name}/acme.db") + self.filename.start() + + def close(self): + self.filename.stop() + self.dir.cleanup() diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py new file mode 100644 index 0000000..6eeff7a --- /dev/null +++ b/standardized-auth/tests/test_acme.py @@ -0,0 +1,38 @@ +from src import connections +from src.acme import home_view, provider +from tests.helpers import TempConnections + + +class TestAcme: + def setup_method(self): + self.connections = TempConnections() + + def teardown_method(self): + self.connections.close() + + def test_disconnected_provider_offers_to_connect(self): + assert provider("U0ADA") == { + "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, + } + + def test_connected_provider_names_the_slack_user(self): + connections.connect("U0ADA") + + acme = provider("U0ADA") + + assert acme["is_connected"] is True + assert acme["connected_identity"] == {"type": "plain_text", "text": "U0ADA"} + + def test_home_view_renders_the_provider_as_an_auth_provider_block(self): + acme = provider("U0ADA") + + view = home_view(acme).to_dict() + + assert view["type"] == "home" + assert view["blocks"][-1] == {"type": "auth_provider", **acme} diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py new file mode 100644 index 0000000..6f77466 --- /dev/null +++ b/standardized-auth/tests/test_app.py @@ -0,0 +1,118 @@ +from unittest.mock import MagicMock + +from slack_sdk import WebClient + +from app import ( + app_home_opened_callback, + connect_callback, + disconnect_callback, + user_connection_callback, + user_connection_status_requested_callback, +) +from src import connections +from tests.helpers import TempConnections + + +def button_click(value: str) -> dict: + return {"user": {"id": "U0ADA"}, "actions": [{"value": value}]} + + +class TestApp: + def setup_method(self): + self.connections = TempConnections() + self.ack = MagicMock() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.connections.close() + + def sent_provider(self) -> dict: + return self.client.apps_user_connection_requestAuth.call_args.kwargs[ + "primary_provider" + ] + + def home_block(self) -> dict: + view = self.client.views_publish.call_args.kwargs["view"].to_dict() + return view["blocks"][-1] + + def test_app_home_opened_shows_the_connection(self): + connections.connect("U0ADA") + + app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) + + assert self.client.views_publish.call_args.kwargs["user_id"] == "U0ADA" + assert self.home_block()["is_connected"] is True + + def test_connect_saves_and_shows_the_connection(self): + connect_callback(ack=self.ack, body=button_click("acme"), client=self.client) + + self.ack.assert_called_once_with() + assert connections.is_connected("U0ADA") + assert self.sent_provider()["is_connected"] is True + assert self.home_block()["is_connected"] is True + + def test_disconnect_forgets_and_shows_the_connection(self): + connections.connect("U0ADA") + + disconnect_callback(ack=self.ack, body=button_click("acme"), client=self.client) + + self.ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") + assert self.sent_provider()["is_connected"] is False + assert self.home_block()["is_connected"] is False + + def test_connect_ignores_another_provider(self): + connect_callback( + ack=self.ack, body=button_click("wile-e-coyote"), client=self.client + ) + + self.ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") + assert self.client.mock_calls == [] + + def test_disconnect_ignores_another_provider(self): + connections.connect("U0ADA") + + disconnect_callback( + ack=self.ack, body=button_click("wile-e-coyote"), client=self.client + ) + + self.ack.assert_called_once_with() + assert connections.is_connected("U0ADA") + assert self.client.mock_calls == [] + + def test_user_connection_connect_opens_the_modal(self): + event = {"subtype": "connect", "user": "U0ADA", "trigger_id": "123.456.abc"} + + user_connection_callback(client=self.client, event=event) + + kwargs = self.client.apps_user_connection_requestAuth.call_args.kwargs + assert kwargs["trigger_id"] == "123.456.abc" + assert kwargs["primary_provider"]["is_connected"] is False + + def test_user_connection_disconnect_is_ignored(self): + event = {"subtype": "disconnect", "user": "U0ADA", "trigger_id": "123.456.abc"} + + user_connection_callback(client=self.client, event=event) + + assert self.client.mock_calls == [] + + def test_status_requested_reports_connected_users(self): + connections.connect("U0ADA") + + user_connection_status_requested_callback( + client=self.client, event={"user": "U0ADA"} + ) + + self.client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="connected" + ) + + def test_status_requested_reports_unknown_users_as_disconnected(self): + user_connection_status_requested_callback( + client=self.client, event={"user": "U0ADA"} + ) + + self.client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="disconnected" + ) diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py new file mode 100644 index 0000000..df625b3 --- /dev/null +++ b/standardized-auth/tests/test_connections.py @@ -0,0 +1,35 @@ +from src import connections +from tests.helpers import TempConnections + + +class TestConnections: + def setup_method(self): + self.connections = TempConnections() + + def teardown_method(self): + self.connections.close() + + def test_remembers_a_connected_user(self): + connections.connect("U0ADA") + + assert connections.is_connected("U0ADA") + + def test_disconnect_forgets_the_user(self): + connections.connect("U0ADA") + + connections.disconnect("U0ADA") + + assert not connections.is_connected("U0ADA") + + def test_disconnect_keeps_other_users_connected(self): + connections.connect("U0ADA") + connections.connect("U0BOB") + + connections.disconnect("U0ADA") + + assert connections.is_connected("U0BOB") + + def test_disconnecting_an_unknown_user_changes_nothing(self): + connections.disconnect("U0ADA") + + assert not connections.is_connected("U0ADA")