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/.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..d58fa68 --- /dev/null +++ b/standardized-auth/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.venv +.env* +!.env.example +data/ 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..869d571 --- /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 store: a user is connected when the store holds their user token. Shared helpers live in [`src/acme.py`](./src/acme.py), and each listener has its own file under [`src/listeners`](./src/listeners) that registers itself with `@app.action` or `@app.event`. [`src/bolt_app.py`](./src/bolt_app.py) imports every file in that directory when it builds the app, so a new listener only needs a new file. + +- **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, [`bolt_app.py`](./src/bolt_app.py) reports the connection with `apps.user.connection.requestAuth` and refreshes App Home. +- **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the user's token, reports the user as disconnected, then revokes the token. +- **`user_connection_status_requested`** ([`events/user_connection_status_requested.py`](./src/listeners/events/user_connection_status_requested.py)) answers with `apps.user.connection.update` whenever Slack asks about a user it doesn't know yet. +- **`tokens_revoked`** ([`events/tokens_revoked.py`](./src/listeners/events/tokens_revoked.py)) forgets users who remove Acme from their Slack settings and reports them as disconnected. +- **App Home** ([`events/app_home_opened.py`](./src/listeners/events/app_home_opened.py)) 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/app.py b/standardized-auth/app.py new file mode 100644 index 0000000..c689b01 --- /dev/null +++ b/standardized-auth/app.py @@ -0,0 +1,6 @@ +import os + +from src.bolt_app import app + +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", "3000"))) 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/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..bb294a6 --- /dev/null +++ b/standardized-auth/src/acme.py @@ -0,0 +1,89 @@ +import os +from typing import Any + +from slack_bolt import BoltContext +from slack_sdk import WebClient +from slack_sdk.models.blocks import AuthProviderBlock, MarkdownTextObject, SectionBlock +from slack_sdk.models.views import View +from slack_sdk.oauth.installation_store import FileInstallationStore, Installation + +installation_store = FileInstallationStore(base_dir="./data/installations") + +GREETING = ( + "*Welcome to Acme!* :rocket:\n" + "Anvils ship same day, gravity permitting. " + "Please keep giant magnets away from your laptop." +) + + +def find_account(context: BoltContext, user_id: str) -> Installation | None: + installation = installation_store.find_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if installation is None or installation.user_token is None: + return None + return installation + + +def find_user_token(context: BoltContext, user_id: str) -> str | None: + account = find_account(context, user_id) + return None if account is None else account.user_token + + +def forget(context: BoltContext, user_id: str) -> str | None: + account = find_account(context, user_id) + if account is None: + return None + installation_store.delete_installation( + enterprise_id=account.enterprise_id, + team_id=account.team_id, + user_id=user_id, + ) + return account.user_token + + +def plain_text(text: str) -> dict[str, str]: + return {"type": "plain_text", "text": text} + + +def provider(client: WebClient, user_token: str | None) -> dict[str, Any]: + acme: dict[str, Any] = { + "value": "acme", + "name": plain_text("Acme account"), + "description": plain_text( + "Order rocket skates, anvils, and giant magnets without leaving Slack." + ), + "auth_url": f"{os.environ['BASE_URL']}/slack/install", + "is_connected": user_token is not None, + } + if user_token is not None: + identity = client.auth_test(token=user_token) + acme["connected_identity"] = plain_text(f"@{identity['user']}") + acme["connected_context"] = plain_text(identity["team"]) + return acme + + +def home_view(acme: dict[str, Any]) -> View: + return View( + type="home", + blocks=[ + SectionBlock(text=MarkdownTextObject(text=GREETING)), + AuthProviderBlock(**acme), + ], + ) + + +def report(client: WebClient, user_id: str, user_token: str | None = None): + acme = provider(client, user_token) + client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme) + client.views_publish(user_id=user_id, view=home_view(acme)) + + +def disconnect(client: WebClient, context: BoltContext, user_id: str): + forgotten_token = forget(context, user_id) + report(client, user_id) + if forgotten_token is not None: + client.auth_revoke(token=forgotten_token) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py new file mode 100644 index 0000000..611d0c8 --- /dev/null +++ b/standardized-auth/src/bolt_app.py @@ -0,0 +1,47 @@ +import os +from importlib import import_module +from pkgutil import walk_packages + +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk import WebClient +from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore + +from src import acme, listeners + + +def success_callback(args: SuccessArgs) -> BoltResponse: + installation = args.installation + client = WebClient(token=installation.bot_token) + acme.report(client, installation.user_id, installation.user_token) + return args.default.success(args) + + +def failure_callback(args: FailureArgs) -> BoltResponse: + return args.default.failure(args) + + +app = App( + oauth_settings=OAuthSettings( + scopes=["users:read"], + user_scopes=["users.profile:read"], + install_page_rendering_enabled=False, + installation_store=acme.installation_store, + state_store=StatelessOAuthStateStore( + expiration_seconds=300, + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + ), + callback_options=CallbackOptions( + success=success_callback, failure=failure_callback + ), + ), +) + + +def load_listeners() -> None: + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): + import_module(module_info.name) + + +load_listeners() diff --git a/standardized-auth/src/listeners/__init__.py b/standardized-auth/src/listeners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/src/listeners/actions/__init__.py b/standardized-auth/src/listeners/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py new file mode 100644 index 0000000..d7ed2a7 --- /dev/null +++ b/standardized-auth/src/listeners/actions/connect.py @@ -0,0 +1,8 @@ +from slack_bolt import Ack + +from src.bolt_app import app + + +@app.action("auth_provider_connect_acme") +def connect_callback(ack: Ack): + ack() diff --git a/standardized-auth/src/listeners/actions/disconnect.py b/standardized-auth/src/listeners/actions/disconnect.py new file mode 100644 index 0000000..0000d5b --- /dev/null +++ b/standardized-auth/src/listeners/actions/disconnect.py @@ -0,0 +1,11 @@ +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + +from src.acme import disconnect +from src.bolt_app import app + + +@app.action("auth_provider_disconnect_acme") +def disconnect_callback(ack: Ack, body: dict, client: WebClient, context: BoltContext): + ack() + disconnect(client, context, body["user"]["id"]) diff --git a/standardized-auth/src/listeners/actions/user_connection.py b/standardized-auth/src/listeners/actions/user_connection.py new file mode 100644 index 0000000..2ff7dd6 --- /dev/null +++ b/standardized-auth/src/listeners/actions/user_connection.py @@ -0,0 +1,21 @@ +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + +from src.acme import disconnect, find_user_token, provider +from src.bolt_app import app + + +@app.action("user_connection") +def user_connection_callback( + ack: Ack, body: dict, client: WebClient, context: BoltContext +): + ack() + user_id = body["user"]["id"] + if body["subtype"] == "connect": + user_token = find_user_token(context, user_id) + client.apps_user_connection_requestAuth( + trigger_id=body["trigger_id"], + primary_provider=provider(client, user_token), + ) + elif body["subtype"] == "disconnect": + disconnect(client, context, user_id) diff --git a/standardized-auth/src/listeners/events/__init__.py b/standardized-auth/src/listeners/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/src/listeners/events/app_home_opened.py b/standardized-auth/src/listeners/events/app_home_opened.py new file mode 100644 index 0000000..7fac7e2 --- /dev/null +++ b/standardized-auth/src/listeners/events/app_home_opened.py @@ -0,0 +1,13 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import find_user_token, home_view, provider +from src.bolt_app import app + + +@app.event("app_home_opened") +def app_home_opened_callback(client: WebClient, context: BoltContext, event: dict): + user_token = find_user_token(context, event["user"]) + client.views_publish( + user_id=event["user"], view=home_view(provider(client, user_token)) + ) diff --git a/standardized-auth/src/listeners/events/tokens_revoked.py b/standardized-auth/src/listeners/events/tokens_revoked.py new file mode 100644 index 0000000..1ba5749 --- /dev/null +++ b/standardized-auth/src/listeners/events/tokens_revoked.py @@ -0,0 +1,12 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import forget, report +from src.bolt_app import app + + +@app.event("tokens_revoked") +def tokens_revoked_callback(client: WebClient, context: BoltContext, event: dict): + for user_id in event["tokens"].get("oauth", []): + forget(context, user_id) + report(client, user_id) diff --git a/standardized-auth/src/listeners/events/user_connection_status_requested.py b/standardized-auth/src/listeners/events/user_connection_status_requested.py new file mode 100644 index 0000000..033c144 --- /dev/null +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -0,0 +1,14 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import find_user_token +from src.bolt_app import app + + +@app.event("user_connection_status_requested") +def user_connection_status_requested_callback( + client: WebClient, context: BoltContext, event: dict +): + user_token = find_user_token(context, event["user"]) + status = "disconnected" if user_token is None else "connected" + client.apps_user_connection_update(user_id=event["user"], status=status) 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..282689e --- /dev/null +++ b/standardized-auth/tests/conftest.py @@ -0,0 +1,26 @@ +import os +from unittest.mock import MagicMock + +import pytest +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import FileInstallationStore + +from src.acme import installation_store + +os.environ["SLACK_CLIENT_ID"] = "111.222" +os.environ["SLACK_CLIENT_SECRET"] = "test_client_secret" +os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" +os.environ["BASE_URL"] = "https://acme.example" + + +@pytest.fixture +def store(tmp_path, monkeypatch) -> FileInstallationStore: + monkeypatch.setattr(installation_store, "base_dir", str(tmp_path / "installations")) + return installation_store + + +@pytest.fixture +def client() -> MagicMock: + client = MagicMock(spec=WebClient) + client.auth_test.return_value = {"ok": True, "user": "ada", "team": "Acme Corp"} + return client diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py new file mode 100644 index 0000000..bd0ddee --- /dev/null +++ b/standardized-auth/tests/helpers.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +from slack_bolt import BoltContext +from slack_sdk.oauth.installation_store import Installation + +from src.acme import home_view, provider + + +def installation( + user_id: str, + user_token: str | None = None, + team_id: str | None = "T0001", + enterprise_id: str | None = None, + is_enterprise_install: bool = False, +) -> Installation: + return Installation( + app_id="A0001", + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + user_token=user_token, + bot_token="xoxb-test-bot", + bot_id="B0001", + bot_user_id="U0BOT", + is_enterprise_install=is_enterprise_install, + ) + + +def context( + team_id: str | None = "T0001", + enterprise_id: str | None = None, + is_enterprise_install: bool = False, +) -> BoltContext: + return BoltContext( + { + "team_id": team_id, + "enterprise_id": enterprise_id, + "is_enterprise_install": is_enterprise_install, + } + ) + + +def published_view(client: MagicMock) -> dict: + return client.views_publish.call_args.kwargs["view"].to_dict() + + +def called_methods(client: MagicMock) -> list: + return [name for name, _, _ in client.mock_calls] + + +def expected_provider(user_token: str | None = None) -> dict: + client = MagicMock() + client.auth_test.return_value = {"user": "ada", "team": "Acme Corp"} + return provider(client, user_token) + + +def expected_home(user_token: str | None = None) -> dict: + return home_view(expected_provider(user_token)).to_dict() diff --git a/standardized-auth/tests/listeners/__init__.py b/standardized-auth/tests/listeners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/tests/listeners/actions/__init__.py b/standardized-auth/tests/listeners/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/tests/listeners/actions/test_connect.py b/standardized-auth/tests/listeners/actions/test_connect.py new file mode 100644 index 0000000..3924b2d --- /dev/null +++ b/standardized-auth/tests/listeners/actions/test_connect.py @@ -0,0 +1,11 @@ +from unittest.mock import MagicMock + +from src.listeners.actions.connect import connect_callback + + +def test_leaves_the_sign_in_to_the_auth_url(): + ack = MagicMock() + + connect_callback(ack=ack) + + ack.assert_called_once_with() diff --git a/standardized-auth/tests/listeners/actions/test_disconnect.py b/standardized-auth/tests/listeners/actions/test_disconnect.py new file mode 100644 index 0000000..754ef05 --- /dev/null +++ b/standardized-auth/tests/listeners/actions/test_disconnect.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock + +from src.acme import find_user_token +from src.listeners.actions.disconnect import disconnect_callback +from tests.helpers import called_methods, context, expected_provider, installation + + +def test_forgets_reports_then_revokes(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + ack = MagicMock() + + disconnect_callback( + ack=ack, body={"user": {"id": "U0ADA"}}, client=client, context=context() + ) + + ack.assert_called_once_with() + assert find_user_token(context(), "U0ADA") is None + assert called_methods(client) == [ + "apps_user_connection_requestAuth", + "views_publish", + "auth_revoke", + ] + client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=expected_provider() + ) + client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") + + +def test_disconnecting_without_an_account_still_reports_disconnected(store, client): + disconnect_callback( + ack=MagicMock(), + body={"user": {"id": "U0ADA"}}, + client=client, + context=context(), + ) + + client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=expected_provider() + ) + client.auth_revoke.assert_not_called() diff --git a/standardized-auth/tests/listeners/actions/test_user_connection.py b/standardized-auth/tests/listeners/actions/test_user_connection.py new file mode 100644 index 0000000..e85d4cf --- /dev/null +++ b/standardized-auth/tests/listeners/actions/test_user_connection.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock + +from src.acme import find_user_token +from src.listeners.actions.user_connection import user_connection_callback +from tests.helpers import called_methods, context, expected_provider, installation + + +def user_connection(subtype: str) -> dict: + return { + "type": "user_connection", + "subtype": subtype, + "user": {"id": "U0ADA", "team_id": "T0001"}, + "trigger_id": "123.456.abc", + } + + +def test_connect_opens_the_auth_modal(store, client): + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("connect"), client=client, context=context() + ) + + ack.assert_called_once_with() + client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=expected_provider() + ) + + +def test_connect_shows_an_existing_connection(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + user_connection_callback( + ack=MagicMock(), + body=user_connection("connect"), + client=client, + context=context(), + ) + + client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=expected_provider("xoxp-test-ada") + ) + + +def test_disconnect_forgets_the_account(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("disconnect"), client=client, context=context() + ) + + ack.assert_called_once_with() + assert find_user_token(context(), "U0ADA") is None + client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") + + +def test_an_unknown_subtype_is_only_acknowledged(store, client): + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("reconnect"), client=client, context=context() + ) + + ack.assert_called_once_with() + assert called_methods(client) == [] diff --git a/standardized-auth/tests/listeners/events/__init__.py b/standardized-auth/tests/listeners/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/tests/listeners/events/test_app_home_opened.py b/standardized-auth/tests/listeners/events/test_app_home_opened.py new file mode 100644 index 0000000..5a41331 --- /dev/null +++ b/standardized-auth/tests/listeners/events/test_app_home_opened.py @@ -0,0 +1,18 @@ +from src.listeners.events.app_home_opened import app_home_opened_callback +from tests.helpers import context, expected_home, installation, published_view + + +def test_shows_a_connected_account(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + app_home_opened_callback(client=client, context=context(), event={"user": "U0ADA"}) + + assert client.views_publish.call_args.kwargs["user_id"] == "U0ADA" + assert published_view(client) == expected_home("xoxp-test-ada") + + +def test_offers_to_connect_users_without_an_account(store, client): + app_home_opened_callback(client=client, context=context(), event={"user": "U0ADA"}) + + client.auth_test.assert_not_called() + assert published_view(client) == expected_home() diff --git a/standardized-auth/tests/listeners/events/test_tokens_revoked.py b/standardized-auth/tests/listeners/events/test_tokens_revoked.py new file mode 100644 index 0000000..b898895 --- /dev/null +++ b/standardized-auth/tests/listeners/events/test_tokens_revoked.py @@ -0,0 +1,25 @@ +from src.acme import find_user_token +from src.listeners.events.tokens_revoked import tokens_revoked_callback +from tests.helpers import called_methods, context, installation + + +def test_forgets_revoked_users_and_reports_them_disconnected(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + tokens_revoked_callback( + client=client, context=context(), event={"tokens": {"oauth": ["U0ADA"]}} + ) + + assert find_user_token(context(), "U0ADA") is None + assert called_methods(client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + + +def test_ignores_revoked_bot_tokens(store, client): + tokens_revoked_callback( + client=client, context=context(), event={"tokens": {"bot": ["U0BOT"]}} + ) + + assert called_methods(client) == [] diff --git a/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py b/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py new file mode 100644 index 0000000..0bacf57 --- /dev/null +++ b/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py @@ -0,0 +1,26 @@ +from src.listeners.events.user_connection_status_requested import ( + user_connection_status_requested_callback, +) +from tests.helpers import context, installation + + +def test_reports_connected_users(store, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + user_connection_status_requested_callback( + client=client, context=context(), event={"user": "U0ADA"} + ) + + client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="connected" + ) + + +def test_reports_users_without_an_account_as_disconnected(store, client): + user_connection_status_requested_callback( + client=client, context=context(), event={"user": "U0ADA"} + ) + + client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="disconnected" + ) diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py new file mode 100644 index 0000000..db62b83 --- /dev/null +++ b/standardized-auth/tests/test_acme.py @@ -0,0 +1,90 @@ +from src.acme import find_user_token, forget, home_view, provider +from tests.helpers import context, installation + + +def org_wide_installation(): + return installation( + "U0ADA", + user_token="xoxp-test-ada", + team_id=None, + enterprise_id="E0001", + is_enterprise_install=True, + ) + + +def org_context(): + return context(team_id="T0002", enterprise_id="E0001", is_enterprise_install=True) + + +def test_users_who_never_installed_have_no_token(store): + assert find_user_token(context(), "U0ADA") is None + + +def test_installations_without_a_user_token_are_not_accounts(store): + store.save(installation("U0ADA")) + + assert find_user_token(context(), "U0ADA") is None + + +def test_finds_the_token_of_a_user_who_connected(store): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + assert find_user_token(context(), "U0ADA") == "xoxp-test-ada" + + +def test_finds_org_wide_accounts_from_any_workspace_in_the_org(store): + store.save(org_wide_installation()) + + assert find_user_token(org_context(), "U0ADA") == "xoxp-test-ada" + + +def test_forget_returns_the_token_and_keeps_other_users_connected(store): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + store.save(installation("U0BOB", user_token="xoxp-test-bob")) + + assert forget(context(), "U0ADA") == "xoxp-test-ada" + assert find_user_token(context(), "U0ADA") is None + assert find_user_token(context(), "U0BOB") == "xoxp-test-bob" + + +def test_forgets_org_wide_accounts(store): + store.save(org_wide_installation()) + + assert forget(org_context(), "U0ADA") == "xoxp-test-ada" + assert find_user_token(org_context(), "U0ADA") is None + + +def test_forgetting_a_user_without_an_account_returns_nothing(store): + assert forget(context(), "U0ADA") is None + + +def test_disconnected_provider_offers_the_acme_sign_in(client): + assert provider(client, None) == { + "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, + } + client.auth_test.assert_not_called() + + +def test_connected_provider_names_the_account_behind_the_token(client): + acme = provider(client, "xoxp-test-ada") + + client.auth_test.assert_called_once_with(token="xoxp-test-ada") + assert acme["is_connected"] is True + assert acme["connected_identity"] == {"type": "plain_text", "text": "@ada"} + assert acme["connected_context"] == {"type": "plain_text", "text": "Acme Corp"} + + +def test_home_view_renders_the_provider_as_an_auth_provider_block(client): + acme = provider(client, None) + + 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..78e8f13 --- /dev/null +++ b/standardized-auth/tests/test_app.py @@ -0,0 +1,177 @@ +import hashlib +import hmac +import json +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlencode, urlparse + +import pytest +from slack_bolt import App, BoltContext, BoltRequest +from slack_sdk import WebClient + +import app as entrypoint +from src.acme import find_user_token +from tests.helpers import installation + +SIGNING_SECRET = "test_signing_secret" +BOT_AUTH_TEST = {"ok": True, "team_id": "T0001", "bot_id": "B0001", "user_id": "U0BOT"} +USER_AUTH_TEST = { + "ok": True, + "team_id": "T0001", + "team": "Acme Corp", + "user_id": "U0ADA", + "user": "ada", +} + + +def auth_test(token: str) -> dict: + return BOT_AUTH_TEST if token.startswith("xoxb-") else USER_AUTH_TEST + + +@pytest.fixture +def app(store) -> App: + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + return entrypoint.app + + +def test_connect_clicks_are_acknowledged(app): + with patch.object(WebClient, "auth_test", side_effect=auth_test): + response = app.dispatch(block_action("auth_provider_connect_acme")) + + assert response.status == 200 + + +def test_clicks_for_other_providers_are_not_handled(app): + with patch.object(WebClient, "auth_test", side_effect=auth_test): + response = app.dispatch(block_action("auth_provider_connect_other")) + + assert response.status == 404 + + +def test_disconnect_clicks_forget_the_account(app, store): + with slack_api() as api: + response = app.dispatch(block_action("auth_provider_disconnect_acme")) + wait_until(lambda: api.auth_revoke.called) + + assert response.status == 200 + assert find_user_token(workspace(), "U0ADA") is None + + +def test_app_home_opened_publishes_the_home_view(app): + with slack_api() as api: + response = app.dispatch(event({"type": "app_home_opened", "user": "U0ADA"})) + wait_until(lambda: api.views_publish.called) + + assert response.status == 200 + assert api.views_publish.call_args.kwargs["user_id"] == "U0ADA" + + +def test_status_requests_are_answered(app): + status_requested = {"type": "user_connection_status_requested", "user": "U0ADA"} + with slack_api() as api: + response = app.dispatch(event(status_requested)) + wait_until(lambda: api.apps_user_connection_update.called) + + assert response.status == 200 + api.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="connected" + ) + + +def test_revoked_user_tokens_are_forgotten(app, store): + tokens_revoked = {"type": "tokens_revoked", "tokens": {"oauth": ["U0ADA"]}} + with slack_api() as api: + response = app.dispatch(event(tokens_revoked)) + wait_until(lambda: api.views_publish.called) + + assert response.status == 200 + assert find_user_token(workspace(), "U0ADA") is None + + +def test_install_redirects_straight_to_slack_with_the_user_scope(app): + response = app.oauth_flow.handle_installation(BoltRequest(body="", query={})) + + location = urlparse(response.headers["location"][0]) + assert response.status == 302 + assert parse_qs(location.query)["user_scope"] == ["users.profile:read"] + + +@contextmanager +def slack_api() -> Iterator[MagicMock]: + methods = [ + "apps_user_connection_requestAuth", + "apps_user_connection_update", + "auth_revoke", + "views_publish", + ] + api = MagicMock() + with ( + patch.object(WebClient, "auth_test", side_effect=auth_test), + patch.multiple( + WebClient, **{method: getattr(api, method) for method in methods} + ), + ): + yield api + + +def wait_until(condition: Callable[[], bool]) -> None: + deadline = time.time() + 2 + while not condition(): + assert time.time() < deadline, "timed out waiting for the listener" + time.sleep(0.01) + + +def workspace() -> BoltContext: + return BoltContext({"team_id": "T0001", "is_enterprise_install": False}) + + +def event(body: dict) -> BoltRequest: + envelope = json.dumps( + { + "type": "event_callback", + "api_app_id": "A0001", + "team_id": "T0001", + "event": body, + "event_id": "Ev0001", + "event_time": 1700000000, + } + ) + headers = signed_headers(envelope) + headers["content-type"] = ["application/json"] + return BoltRequest(body=envelope, headers=headers) + + +def block_action(action_id: str) -> BoltRequest: + 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": action_id, + "block_id": "acme", + "action_ts": "1700000000.000001", + } + ], + } + body = urlencode({"payload": json.dumps(payload)}) + return BoltRequest(body=body, headers=signed_headers(body)) + + +def signed_headers(body: str, secret: str = SIGNING_SECRET) -> dict: + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-request-timestamp": [timestamp], + "x-slack-signature": [signature], + } diff --git a/standardized-auth/tests/test_bolt_app.py b/standardized-auth/tests/test_bolt_app.py new file mode 100644 index 0000000..bf33481 --- /dev/null +++ b/standardized-auth/tests/test_bolt_app.py @@ -0,0 +1,27 @@ +from unittest.mock import MagicMock, patch + +from slack_bolt.oauth.callback_options import SuccessArgs + +from src.bolt_app import success_callback +from tests.helpers import expected_home, expected_provider, installation, published_view + + +def test_reports_the_connection_then_shows_the_success_page(client): + args = SuccessArgs( + request=MagicMock(), + installation=installation("U0ADA", user_token="xoxp-test-ada"), + settings=MagicMock(), + default=MagicMock(), + ) + + with patch("src.bolt_app.WebClient", return_value=client) as client_class: + response = success_callback(args) + + client_class.assert_called_once_with(token="xoxb-test-bot") + client.auth_test.assert_called_once_with(token="xoxp-test-ada") + client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=expected_provider("xoxp-test-ada") + ) + assert published_view(client) == expected_home("xoxp-test-ada") + args.default.success.assert_called_once_with(args) + assert response == args.default.success.return_value