From adce849c78083f057bc1e90b93b1f0df2203af0d Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 16 Oct 2026 11:28:02 -0400 Subject: [PATCH 01/24] feat(standardized-auth): add an acme sample for standardized app auth Connects user accounts with the Slack-owned Connect and Manage connection controls. Bolt's installation store holds each connection: the OAuth success hook reports it with apps.user.connection.requestAuth, the disconnect action forgets and revokes the user token, tokens_revoked forgets users who remove the app, and the user_connection_status_requested event is answered with apps.user.connection.update. App Home renders the auth_provider block. The user_connection request listener is written and tested but not yet registered, since no Bolt routes that payload to action listeners yet. --- .github/dependabot.yml | 8 + .github/workflows/test.yml | 1 + README.md | 1 + standardized-auth/.env.example | 5 + standardized-auth/.gitignore | 5 + standardized-auth/.slack/.gitignore | 2 + standardized-auth/.slack/config.json | 6 + standardized-auth/.slack/hooks.json | 5 + standardized-auth/README.md | 31 +++ standardized-auth/app.py | 16 ++ standardized-auth/manifest.json | 45 +++++ standardized-auth/requirements.txt | 6 + standardized-auth/src/__init__.py | 0 standardized-auth/src/accounts.py | 29 +++ standardized-auth/src/acme.py | 57 ++++++ standardized-auth/src/app.py | 41 ++++ standardized-auth/src/listeners.py | 88 +++++++++ standardized-auth/tests/__init__.py | 0 standardized-auth/tests/conftest.py | 7 + standardized-auth/tests/helpers.py | 36 ++++ standardized-auth/tests/test_accounts.py | 71 +++++++ standardized-auth/tests/test_acme.py | 60 ++++++ standardized-auth/tests/test_app.py | 187 ++++++++++++++++++ standardized-auth/tests/test_listeners.py | 224 ++++++++++++++++++++++ 24 files changed, 931 insertions(+) create mode 100644 standardized-auth/.env.example create mode 100644 standardized-auth/.gitignore create mode 100644 standardized-auth/.slack/.gitignore create mode 100644 standardized-auth/.slack/config.json create mode 100644 standardized-auth/.slack/hooks.json create mode 100644 standardized-auth/README.md create mode 100644 standardized-auth/app.py create mode 100644 standardized-auth/manifest.json create mode 100644 standardized-auth/requirements.txt create mode 100644 standardized-auth/src/__init__.py create mode 100644 standardized-auth/src/accounts.py create mode 100644 standardized-auth/src/acme.py create mode 100644 standardized-auth/src/app.py create mode 100644 standardized-auth/src/listeners.py create mode 100644 standardized-auth/tests/__init__.py create mode 100644 standardized-auth/tests/conftest.py create mode 100644 standardized-auth/tests/helpers.py create mode 100644 standardized-auth/tests/test_accounts.py create mode 100644 standardized-auth/tests/test_acme.py create mode 100644 standardized-auth/tests/test_app.py create mode 100644 standardized-auth/tests/test_listeners.py 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/test.yml b/.github/workflows/test.yml index 3503800..48dc3e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,7 @@ jobs: - "ai/slackbot-mcp-client/slack-identity" - "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 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..1542791 --- /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. + +- **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/app.py b/standardized-auth/app.py new file mode 100644 index 0000000..152ec97 --- /dev/null +++ b/standardized-auth/app.py @@ -0,0 +1,16 @@ +import os + +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from src.app import build_app + +if __name__ == "__main__": + app = build_app( + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore( + expiration_seconds=600, base_dir="./data/states" + ), + base_url=os.environ["BASE_URL"], + ) + 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/accounts.py b/standardized-auth/src/accounts.py new file mode 100644 index 0000000..7ca5a9b --- /dev/null +++ b/standardized-auth/src/accounts.py @@ -0,0 +1,29 @@ +from slack_bolt import BoltContext +from slack_sdk.oauth.installation_store import Installation, InstallationStore + + +class AcmeAccounts: + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def find(self, context: BoltContext, user_id: str) -> Installation | None: + installation = self.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 forget(self, context: BoltContext, user_id: str) -> str | None: + account = self.find(context, user_id) + if account is None: + return None + self.installation_store.delete_installation( + enterprise_id=account.enterprise_id, + team_id=account.team_id, + user_id=user_id, + ) + return account.user_token diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py new file mode 100644 index 0000000..2d57698 --- /dev/null +++ b/standardized-auth/src/acme.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Any + +from slack_sdk.models.blocks import AuthProviderBlock, MarkdownTextObject, SectionBlock +from slack_sdk.models.views import View + +ACME_VALUE = "acme" +CONNECT_ACTION_ID = f"auth_provider_connect_{ACME_VALUE}" +DISCONNECT_ACTION_ID = f"auth_provider_disconnect_{ACME_VALUE}" + +WELCOME_GREETING = ( + "*Welcome to Acme!* :rocket:\n" + "Connect your Acme account to start ordering. " + "Anvils ship same day, gravity permitting." +) +CONNECTED_GREETING = ( + "*You're all set!* :tada:\n" + "Your Acme account is connected. " + "Please keep giant magnets away from your laptop." +) + + +@dataclass(frozen=True) +class Connection: + identity: str + context: str + + +def plain_text(text: str) -> dict[str, str]: + return {"type": "plain_text", "text": text} + + +def acme_provider(auth_url: str, connection: Connection | None) -> dict[str, Any]: + provider: dict[str, Any] = { + "value": ACME_VALUE, + "name": plain_text("Acme account"), + "description": plain_text( + "Order rocket skates, anvils, and giant magnets without leaving Slack." + ), + "auth_url": auth_url, + "is_connected": connection is not None, + } + if connection is not None: + provider["connected_identity"] = plain_text(connection.identity) + provider["connected_context"] = plain_text(connection.context) + return provider + + +def home_view(provider: dict[str, Any]) -> View: + greeting = CONNECTED_GREETING if provider["is_connected"] else WELCOME_GREETING + return View( + type="home", + blocks=[ + SectionBlock(text=MarkdownTextObject(text=greeting)), + AuthProviderBlock(**provider), + ], + ) diff --git a/standardized-auth/src/app.py b/standardized-auth/src/app.py new file mode 100644 index 0000000..15a8d26 --- /dev/null +++ b/standardized-auth/src/app.py @@ -0,0 +1,41 @@ +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import InstallationStore +from slack_sdk.oauth.state_store import OAuthStateStore + +from src.accounts import AcmeAccounts +from src.acme import CONNECT_ACTION_ID, DISCONNECT_ACTION_ID +from src.listeners import AcmeListeners + + +def show_failure(args: FailureArgs) -> BoltResponse: + return args.default.failure(args) + + +def build_app( + installation_store: InstallationStore, + state_store: OAuthStateStore, + base_url: str, +) -> App: + listeners = AcmeListeners( + AcmeAccounts(installation_store), auth_url=f"{base_url}/slack/install" + ) + app = App( + oauth_settings=OAuthSettings( + scopes=["users:read"], + user_scopes=["users.profile:read"], + install_page_rendering_enabled=False, + installation_store=installation_store, + state_store=state_store, + callback_options=CallbackOptions( + success=listeners.report_connected, failure=show_failure + ), + ), + ) + app.event("app_home_opened")(listeners.publish_home) + app.event("user_connection_status_requested")(listeners.answer_status_request) + app.event("tokens_revoked")(listeners.forget_revoked_users) + app.action(CONNECT_ACTION_ID)(listeners.acknowledge_connect) + app.action(DISCONNECT_ACTION_ID)(listeners.acknowledge_disconnect) + return app diff --git a/standardized-auth/src/listeners.py b/standardized-auth/src/listeners.py new file mode 100644 index 0000000..206ed46 --- /dev/null +++ b/standardized-auth/src/listeners.py @@ -0,0 +1,88 @@ +from slack_bolt import Ack, BoltContext, BoltResponse +from slack_bolt.oauth.callback_options import SuccessArgs +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import Installation + +from src.accounts import AcmeAccounts +from src.acme import Connection, acme_provider, home_view + + +def connection_of(client: WebClient, account: Installation | None) -> Connection | None: + if account is None: + return None + identity = client.auth_test(token=account.user_token) + return Connection(identity=f"@{identity['user']}", context=identity["team"]) + + +class AcmeListeners: + def __init__(self, accounts: AcmeAccounts, auth_url: str): + self.accounts = accounts + self.auth_url = auth_url + + def report_connected(self, args: SuccessArgs) -> BoltResponse: + account = args.installation + client = WebClient(token=account.bot_token) + self.report(client, account.user_id, connection_of(client, account)) + return args.default.success(args) + + def publish_home(self, client: WebClient, context: BoltContext, event: dict): + account = self.accounts.find(context, event["user"]) + provider = acme_provider(self.auth_url, connection_of(client, account)) + client.views_publish(user_id=event["user"], view=home_view(provider)) + + def acknowledge_connect(self, ack: Ack): + ack() + + def acknowledge_disconnect( + self, ack: Ack, body: dict, client: WebClient, context: BoltContext + ): + ack() + self.disconnect(client, context, body["user"]["id"]) + + def answer_status_request( + self, client: WebClient, context: BoltContext, event: dict + ): + account = self.accounts.find(context, event["user"]) + status = "disconnected" if account is None else "connected" + client.apps_user_connection_update(user_id=event["user"], status=status) + + def forget_revoked_users( + self, client: WebClient, context: BoltContext, event: dict + ): + for user_id in event["tokens"].get("oauth", []): + self.accounts.forget(context, user_id) + self.report(client, user_id, None) + + def handle_user_connection( + self, ack: Ack, body: dict, client: WebClient, context: BoltContext + ): + ack() + user_id = body["user"]["id"] + if body["subtype"] == "connect": + self.open_connect_modal(client, context, user_id, body["trigger_id"]) + elif body["subtype"] == "disconnect": + self.disconnect(client, context, user_id) + + def open_connect_modal( + self, client: WebClient, context: BoltContext, user_id: str, trigger_id: str + ): + account = self.accounts.find(context, user_id) + client.apps_user_connection_requestAuth( + trigger_id=trigger_id, + primary_provider=acme_provider( + self.auth_url, connection_of(client, account) + ), + ) + + def disconnect(self, client: WebClient, context: BoltContext, user_id: str): + forgotten_token = self.accounts.forget(context, user_id) + self.report(client, user_id, None) + if forgotten_token is not None: + client.auth_revoke(token=forgotten_token) + + def report(self, client: WebClient, user_id: str, connection: Connection | None): + provider = acme_provider(self.auth_url, connection) + client.apps_user_connection_requestAuth( + user_id=user_id, primary_provider=provider + ) + client.views_publish(user_id=user_id, view=home_view(provider)) 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..7de5430 --- /dev/null +++ b/standardized-auth/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest +from slack_sdk.oauth.installation_store import FileInstallationStore + + +@pytest.fixture +def store(tmp_path) -> FileInstallationStore: + return FileInstallationStore(base_dir=str(tmp_path / "installations")) diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py new file mode 100644 index 0000000..640ca7f --- /dev/null +++ b/standardized-auth/tests/helpers.py @@ -0,0 +1,36 @@ +from slack_bolt import BoltContext +from slack_sdk.oauth.installation_store import Installation + + +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, + } + ) diff --git a/standardized-auth/tests/test_accounts.py b/standardized-auth/tests/test_accounts.py new file mode 100644 index 0000000..8ddfa57 --- /dev/null +++ b/standardized-auth/tests/test_accounts.py @@ -0,0 +1,71 @@ +from src.accounts import AcmeAccounts +from tests.helpers import context, installation + + +def test_users_who_never_installed_have_no_account(store): + assert AcmeAccounts(store).find(context(), "U0ADA") is None + + +def test_installations_without_a_user_token_are_not_accounts(store): + store.save(installation("U0ADA")) + + assert AcmeAccounts(store).find(context(), "U0ADA") is None + + +def test_finds_the_account_of_a_user_who_connected(store): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + account = AcmeAccounts(store).find(context(), "U0ADA") + + assert account is not None + assert account.user_token == "xoxp-test-ada" + + +def test_finds_org_wide_accounts_from_any_workspace_in_the_org(store): + store.save( + installation( + "U0ADA", + user_token="xoxp-test-ada", + team_id=None, + enterprise_id="E0001", + is_enterprise_install=True, + ) + ) + org_context = context( + team_id="T0002", enterprise_id="E0001", is_enterprise_install=True + ) + + assert AcmeAccounts(store).find(org_context, "U0ADA") is not None + + +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")) + accounts = AcmeAccounts(store) + + assert accounts.forget(context(), "U0ADA") == "xoxp-test-ada" + assert accounts.find(context(), "U0ADA") is None + assert accounts.find(context(), "U0BOB") is not None + + +def test_forgets_org_wide_accounts(store): + store.save( + installation( + "U0ADA", + user_token="xoxp-test-ada", + team_id=None, + enterprise_id="E0001", + is_enterprise_install=True, + ) + ) + org_context = context( + team_id="T0002", enterprise_id="E0001", is_enterprise_install=True + ) + accounts = AcmeAccounts(store) + + assert accounts.forget(org_context, "U0ADA") == "xoxp-test-ada" + assert accounts.find(org_context, "U0ADA") is None + + +def test_forgetting_a_user_without_an_account_returns_nothing(store): + assert AcmeAccounts(store).forget(context(), "U0ADA") is None diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py new file mode 100644 index 0000000..48c2886 --- /dev/null +++ b/standardized-auth/tests/test_acme.py @@ -0,0 +1,60 @@ +from src.acme import ( + CONNECT_ACTION_ID, + CONNECTED_GREETING, + DISCONNECT_ACTION_ID, + WELCOME_GREETING, + Connection, + acme_provider, + home_view, +) + +AUTH_URL = "https://acme.example/slack/install" + + +def test_action_ids_follow_the_provider_value(): + assert CONNECT_ACTION_ID == "auth_provider_connect_acme" + assert DISCONNECT_ACTION_ID == "auth_provider_disconnect_acme" + + +def test_disconnected_provider_offers_the_acme_sign_in(): + assert acme_provider(AUTH_URL, 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": AUTH_URL, + "is_connected": False, + } + + +def test_connected_provider_names_the_connected_account(): + provider = acme_provider(AUTH_URL, Connection(identity="@ada", context="Acme Corp")) + + assert provider["is_connected"] is True + assert provider["connected_identity"] == {"type": "plain_text", "text": "@ada"} + assert provider["connected_context"] == {"type": "plain_text", "text": "Acme Corp"} + + +def test_home_view_renders_the_provider_as_an_auth_provider_block(): + provider = acme_provider(AUTH_URL, None) + + view = home_view(provider).to_dict() + + assert view["type"] == "home" + assert view["blocks"][-1] == {"type": "auth_provider", **provider} + + +def test_home_view_welcomes_users_who_are_not_connected(): + view = home_view(acme_provider(AUTH_URL, None)).to_dict() + + assert view["blocks"][0]["text"]["text"] == WELCOME_GREETING + + +def test_home_view_celebrates_users_who_are_connected(): + connection = Connection(identity="@ada", context="Acme Corp") + + view = home_view(acme_provider(AUTH_URL, connection)).to_dict() + + assert view["blocks"][0]["text"]["text"] == CONNECTED_GREETING diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py new file mode 100644 index 0000000..961aeda --- /dev/null +++ b/standardized-auth/tests/test_app.py @@ -0,0 +1,187 @@ +import hashlib +import hmac +import json +import os +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 +from slack_sdk.oauth.state_store import FileOAuthStateStore + +os.environ["SLACK_CLIENT_ID"] = "111.222" +os.environ["SLACK_CLIENT_SECRET"] = "test_client_secret" +os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" + +from src.accounts import AcmeAccounts +from src.acme import CONNECT_ACTION_ID, DISCONNECT_ACTION_ID +from src.app import build_app +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, tmp_path) -> App: + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + state_store = FileOAuthStateStore( + expiration_seconds=600, base_dir=str(tmp_path / "states") + ) + return build_app(store, state_store, "https://acme.example") + + +def test_connect_clicks_are_acknowledged(app): + with patch.object(WebClient, "auth_test", side_effect=auth_test): + response = app.dispatch(block_action(CONNECT_ACTION_ID)) + + 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(DISCONNECT_ACTION_ID)) + wait_until(lambda: api.auth_revoke.called) + + assert response.status == 200 + assert AcmeAccounts(store).find(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 AcmeAccounts(store).find(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_listeners.py b/standardized-auth/tests/test_listeners.py new file mode 100644 index 0000000..e949a01 --- /dev/null +++ b/standardized-auth/tests/test_listeners.py @@ -0,0 +1,224 @@ +from unittest.mock import MagicMock, patch + +import pytest +from slack_bolt.oauth.callback_options import SuccessArgs +from slack_sdk import WebClient + +from src.accounts import AcmeAccounts +from src.acme import Connection, acme_provider, home_view +from src.listeners import AcmeListeners +from tests.helpers import context, installation + +AUTH_URL = "https://acme.example/slack/install" +ADA = Connection(identity="@ada", context="Acme Corp") + + +@pytest.fixture +def client() -> MagicMock: + client = MagicMock(spec=WebClient) + client.auth_test.return_value = {"ok": True, "user": "ada", "team": "Acme Corp"} + return client + + +@pytest.fixture +def listeners(store) -> AcmeListeners: + return AcmeListeners(AcmeAccounts(store), AUTH_URL) + + +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 user_connection(subtype: str) -> dict: + return { + "type": "user_connection", + "subtype": subtype, + "user": {"id": "U0ADA", "team_id": "T0001"}, + "trigger_id": "123.456.abc", + } + + +def test_report_connected_records_the_connection_then_shows_the_success_page( + listeners, client +): + args = SuccessArgs( + request=MagicMock(), + installation=installation("U0ADA", user_token="xoxp-test-ada"), + settings=MagicMock(), + default=MagicMock(), + ) + + with patch("src.listeners.WebClient", return_value=client) as client_class: + response = listeners.report_connected(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=acme_provider(AUTH_URL, ADA) + ) + assert published_view(client) == home_view(acme_provider(AUTH_URL, ADA)).to_dict() + args.default.success.assert_called_once_with(args) + assert response == args.default.success.return_value + + +def test_publish_home_shows_a_connected_account(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + listeners.publish_home(client=client, context=context(), event={"user": "U0ADA"}) + + assert client.views_publish.call_args.kwargs["user_id"] == "U0ADA" + assert published_view(client) == home_view(acme_provider(AUTH_URL, ADA)).to_dict() + + +def test_publish_home_offers_to_connect_users_without_an_account(listeners, client): + listeners.publish_home(client=client, context=context(), event={"user": "U0ADA"}) + + client.auth_test.assert_not_called() + assert published_view(client) == home_view(acme_provider(AUTH_URL, None)).to_dict() + + +def test_acknowledge_connect_leaves_the_sign_in_to_the_auth_url(listeners): + ack = MagicMock() + + listeners.acknowledge_connect(ack=ack) + + ack.assert_called_once_with() + + +def test_acknowledge_disconnect_forgets_reports_then_revokes(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + ack = MagicMock() + + listeners.acknowledge_disconnect( + ack=ack, body={"user": {"id": "U0ADA"}}, client=client, context=context() + ) + + ack.assert_called_once_with() + assert AcmeAccounts(store).find(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=acme_provider(AUTH_URL, None) + ) + client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") + + +def test_disconnecting_without_an_account_still_reports_disconnected(listeners, client): + listeners.acknowledge_disconnect( + ack=MagicMock(), + body={"user": {"id": "U0ADA"}}, + client=client, + context=context(), + ) + + client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=acme_provider(AUTH_URL, None) + ) + client.auth_revoke.assert_not_called() + + +def test_answer_status_request_reports_connected_users(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + listeners.answer_status_request( + client=client, context=context(), event={"user": "U0ADA"} + ) + + client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="connected" + ) + + +def test_answer_status_request_reports_users_without_an_account_as_disconnected( + listeners, client +): + listeners.answer_status_request( + client=client, context=context(), event={"user": "U0ADA"} + ) + + client.apps_user_connection_update.assert_called_once_with( + user_id="U0ADA", status="disconnected" + ) + + +def test_forget_revoked_users_reports_them_disconnected(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + listeners.forget_revoked_users( + client=client, context=context(), event={"tokens": {"oauth": ["U0ADA"]}} + ) + + assert AcmeAccounts(store).find(context(), "U0ADA") is None + assert called_methods(client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + + +def test_forget_revoked_users_ignores_revoked_bot_tokens(listeners, client): + listeners.forget_revoked_users( + client=client, context=context(), event={"tokens": {"bot": ["U0BOT"]}} + ) + + assert called_methods(client) == [] + + +def test_user_connection_connect_opens_the_auth_modal(listeners, client): + ack = MagicMock() + + listeners.handle_user_connection( + 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=acme_provider(AUTH_URL, None) + ) + + +def test_user_connection_connect_shows_an_existing_connection(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + listeners.handle_user_connection( + 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=acme_provider(AUTH_URL, ADA) + ) + + +def test_user_connection_disconnect_forgets_the_account(store, listeners, client): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + ack = MagicMock() + + listeners.handle_user_connection( + ack=ack, body=user_connection("disconnect"), client=client, context=context() + ) + + ack.assert_called_once_with() + assert AcmeAccounts(store).find(context(), "U0ADA") is None + client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") + + +def test_user_connection_with_an_unknown_subtype_is_only_acknowledged( + listeners, client +): + ack = MagicMock() + + listeners.handle_user_connection( + ack=ack, body=user_connection("reconnect"), client=client, context=context() + ) + + ack.assert_called_once_with() + assert called_methods(client) == [] From e6f9a83dcb896d9ac05567dbd0eb1294bb9e6545 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 16 Oct 2026 16:32:25 -0400 Subject: [PATCH 02/24] refactor(standardized-auth): use the stateless oauth state store OAuth state no longer needs files on disk. The state is now an HMAC-signed token checked with the signing secret, and it expires after 5 minutes because a stateless token can be replayed until then. Installations stay in the file store so connections survive a restart. Co-Authored-By: Claude --- standardized-auth/app.py | 6 +++--- standardized-auth/tests/test_app.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/standardized-auth/app.py b/standardized-auth/app.py index 152ec97..ac282f0 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,15 +1,15 @@ import os from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore from src.app import build_app if __name__ == "__main__": app = build_app( installation_store=FileInstallationStore(base_dir="./data/installations"), - state_store=FileOAuthStateStore( - expiration_seconds=600, base_dir="./data/states" + state_store=StatelessOAuthStateStore( + expiration_seconds=300, signing_secret=os.environ["SLACK_SIGNING_SECRET"] ), base_url=os.environ["BASE_URL"], ) diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index 961aeda..dcb64ce 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -11,7 +11,7 @@ import pytest from slack_bolt import App, BoltContext, BoltRequest from slack_sdk import WebClient -from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore os.environ["SLACK_CLIENT_ID"] = "111.222" os.environ["SLACK_CLIENT_SECRET"] = "test_client_secret" @@ -38,10 +38,10 @@ def auth_test(token: str) -> dict: @pytest.fixture -def app(store, tmp_path) -> App: +def app(store) -> App: store.save(installation("U0ADA", user_token="xoxp-test-ada")) - state_store = FileOAuthStateStore( - expiration_seconds=600, base_dir=str(tmp_path / "states") + state_store = StatelessOAuthStateStore( + expiration_seconds=300, signing_secret=SIGNING_SECRET ) return build_app(store, state_store, "https://acme.example") From 9d42ba6b055e7a23e27c8b16cba1ad21623e649b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 10:28:59 -0400 Subject: [PATCH 03/24] refactor(standardized-auth): one file per listener and plain-function helpers Split the listeners into src/listeners/{events,actions,oauth}/, one file per handler, following the bolt-python-starter-template layout. Replace the AcmeAccounts and AcmeListeners classes, the Connection dataclass, and the action ID constants with plain functions in src/acme.py, and use a single App Home greeting. Tests mirror the new layout. Co-Authored-By: Claude --- standardized-auth/README.md | 12 +- standardized-auth/app.py | 12 +- standardized-auth/src/accounts.py | 29 --- standardized-auth/src/acme.py | 88 ++++--- standardized-auth/src/app.py | 42 ++-- standardized-auth/src/listeners.py | 88 ------- standardized-auth/src/listeners/__init__.py | 8 + .../src/listeners/actions/__init__.py | 9 + .../src/listeners/actions/connect.py | 5 + .../src/listeners/actions/disconnect.py | 9 + .../src/listeners/actions/user_connection.py | 19 ++ .../src/listeners/events/__init__.py | 13 + .../src/listeners/events/app_home_opened.py | 11 + .../src/listeners/events/tokens_revoked.py | 10 + .../user_connection_status_requested.py | 12 + .../src/listeners/oauth/__init__.py | 11 + .../src/listeners/oauth/success.py | 12 + standardized-auth/tests/conftest.py | 22 +- standardized-auth/tests/helpers.py | 22 ++ standardized-auth/tests/listeners/__init__.py | 0 .../tests/listeners/actions/__init__.py | 0 .../tests/listeners/actions/test_connect.py | 11 + .../listeners/actions/test_disconnect.py | 40 ++++ .../listeners/actions/test_user_connection.py | 66 ++++++ .../tests/listeners/events/__init__.py | 0 .../listeners/events/test_app_home_opened.py | 18 ++ .../listeners/events/test_tokens_revoked.py | 25 ++ .../test_user_connection_status_requested.py | 26 ++ .../tests/listeners/oauth/__init__.py | 0 .../tests/listeners/oauth/test_success.py | 29 +++ standardized-auth/tests/test_accounts.py | 71 ------ standardized-auth/tests/test_acme.py | 108 ++++++--- standardized-auth/tests/test_app.py | 22 +- standardized-auth/tests/test_listeners.py | 224 ------------------ 34 files changed, 532 insertions(+), 542 deletions(-) delete mode 100644 standardized-auth/src/accounts.py delete mode 100644 standardized-auth/src/listeners.py create mode 100644 standardized-auth/src/listeners/__init__.py create mode 100644 standardized-auth/src/listeners/actions/__init__.py create mode 100644 standardized-auth/src/listeners/actions/connect.py create mode 100644 standardized-auth/src/listeners/actions/disconnect.py create mode 100644 standardized-auth/src/listeners/actions/user_connection.py create mode 100644 standardized-auth/src/listeners/events/__init__.py create mode 100644 standardized-auth/src/listeners/events/app_home_opened.py create mode 100644 standardized-auth/src/listeners/events/tokens_revoked.py create mode 100644 standardized-auth/src/listeners/events/user_connection_status_requested.py create mode 100644 standardized-auth/src/listeners/oauth/__init__.py create mode 100644 standardized-auth/src/listeners/oauth/success.py create mode 100644 standardized-auth/tests/listeners/__init__.py create mode 100644 standardized-auth/tests/listeners/actions/__init__.py create mode 100644 standardized-auth/tests/listeners/actions/test_connect.py create mode 100644 standardized-auth/tests/listeners/actions/test_disconnect.py create mode 100644 standardized-auth/tests/listeners/actions/test_user_connection.py create mode 100644 standardized-auth/tests/listeners/events/__init__.py create mode 100644 standardized-auth/tests/listeners/events/test_app_home_opened.py create mode 100644 standardized-auth/tests/listeners/events/test_tokens_revoked.py create mode 100644 standardized-auth/tests/listeners/events/test_user_connection_status_requested.py create mode 100644 standardized-auth/tests/listeners/oauth/__init__.py create mode 100644 standardized-auth/tests/listeners/oauth/test_success.py delete mode 100644 standardized-auth/tests/test_accounts.py delete mode 100644 standardized-auth/tests/test_listeners.py diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 1542791..87c11c2 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,13 +6,13 @@ 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. +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). -- **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. +- **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, [`oauth/success.py`](./src/listeners/oauth/success.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 diff --git a/standardized-auth/app.py b/standardized-auth/app.py index ac282f0..51d2f8c 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,16 +1,6 @@ import os -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore - from src.app import build_app if __name__ == "__main__": - app = build_app( - installation_store=FileInstallationStore(base_dir="./data/installations"), - state_store=StatelessOAuthStateStore( - expiration_seconds=300, signing_secret=os.environ["SLACK_SIGNING_SECRET"] - ), - base_url=os.environ["BASE_URL"], - ) - app.start(port=int(os.environ.get("PORT", "3000"))) + build_app().start(port=int(os.environ.get("PORT", "3000"))) diff --git a/standardized-auth/src/accounts.py b/standardized-auth/src/accounts.py deleted file mode 100644 index 7ca5a9b..0000000 --- a/standardized-auth/src/accounts.py +++ /dev/null @@ -1,29 +0,0 @@ -from slack_bolt import BoltContext -from slack_sdk.oauth.installation_store import Installation, InstallationStore - - -class AcmeAccounts: - def __init__(self, installation_store: InstallationStore): - self.installation_store = installation_store - - def find(self, context: BoltContext, user_id: str) -> Installation | None: - installation = self.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 forget(self, context: BoltContext, user_id: str) -> str | None: - account = self.find(context, user_id) - if account is None: - return None - self.installation_store.delete_installation( - enterprise_id=account.enterprise_id, - team_id=account.team_id, - user_id=user_id, - ) - return account.user_token diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index 2d57698..bb294a6 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -1,57 +1,89 @@ -from dataclasses import dataclass +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 -ACME_VALUE = "acme" -CONNECT_ACTION_ID = f"auth_provider_connect_{ACME_VALUE}" -DISCONNECT_ACTION_ID = f"auth_provider_disconnect_{ACME_VALUE}" +installation_store = FileInstallationStore(base_dir="./data/installations") -WELCOME_GREETING = ( +GREETING = ( "*Welcome to Acme!* :rocket:\n" - "Connect your Acme account to start ordering. " - "Anvils ship same day, gravity permitting." -) -CONNECTED_GREETING = ( - "*You're all set!* :tada:\n" - "Your Acme account is connected. " + "Anvils ship same day, gravity permitting. " "Please keep giant magnets away from your laptop." ) -@dataclass(frozen=True) -class Connection: - identity: str - context: str +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 acme_provider(auth_url: str, connection: Connection | None) -> dict[str, Any]: - provider: dict[str, Any] = { - "value": ACME_VALUE, +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": auth_url, - "is_connected": connection is not None, + "auth_url": f"{os.environ['BASE_URL']}/slack/install", + "is_connected": user_token is not None, } - if connection is not None: - provider["connected_identity"] = plain_text(connection.identity) - provider["connected_context"] = plain_text(connection.context) - return provider + 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(provider: dict[str, Any]) -> View: - greeting = CONNECTED_GREETING if provider["is_connected"] else WELCOME_GREETING +def home_view(acme: dict[str, Any]) -> View: return View( type="home", blocks=[ - SectionBlock(text=MarkdownTextObject(text=greeting)), - AuthProviderBlock(**provider), + 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/app.py b/standardized-auth/src/app.py index 15a8d26..d3f70c4 100644 --- a/standardized-auth/src/app.py +++ b/standardized-auth/src/app.py @@ -1,41 +1,27 @@ -from slack_bolt import App, BoltResponse -from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs -from slack_bolt.oauth.oauth_settings import OAuthSettings -from slack_sdk.oauth.installation_store import InstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore - -from src.accounts import AcmeAccounts -from src.acme import CONNECT_ACTION_ID, DISCONNECT_ACTION_ID -from src.listeners import AcmeListeners +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore -def show_failure(args: FailureArgs) -> BoltResponse: - return args.default.failure(args) +from src import acme +from src.listeners import register_listeners +from src.listeners.oauth import callback_options -def build_app( - installation_store: InstallationStore, - state_store: OAuthStateStore, - base_url: str, -) -> App: - listeners = AcmeListeners( - AcmeAccounts(installation_store), auth_url=f"{base_url}/slack/install" - ) +def build_app() -> App: app = App( oauth_settings=OAuthSettings( scopes=["users:read"], user_scopes=["users.profile:read"], install_page_rendering_enabled=False, - installation_store=installation_store, - state_store=state_store, - callback_options=CallbackOptions( - success=listeners.report_connected, failure=show_failure + installation_store=acme.installation_store, + state_store=StatelessOAuthStateStore( + expiration_seconds=300, + signing_secret=os.environ["SLACK_SIGNING_SECRET"], ), + callback_options=callback_options, ), ) - app.event("app_home_opened")(listeners.publish_home) - app.event("user_connection_status_requested")(listeners.answer_status_request) - app.event("tokens_revoked")(listeners.forget_revoked_users) - app.action(CONNECT_ACTION_ID)(listeners.acknowledge_connect) - app.action(DISCONNECT_ACTION_ID)(listeners.acknowledge_disconnect) + register_listeners(app) return app diff --git a/standardized-auth/src/listeners.py b/standardized-auth/src/listeners.py deleted file mode 100644 index 206ed46..0000000 --- a/standardized-auth/src/listeners.py +++ /dev/null @@ -1,88 +0,0 @@ -from slack_bolt import Ack, BoltContext, BoltResponse -from slack_bolt.oauth.callback_options import SuccessArgs -from slack_sdk import WebClient -from slack_sdk.oauth.installation_store import Installation - -from src.accounts import AcmeAccounts -from src.acme import Connection, acme_provider, home_view - - -def connection_of(client: WebClient, account: Installation | None) -> Connection | None: - if account is None: - return None - identity = client.auth_test(token=account.user_token) - return Connection(identity=f"@{identity['user']}", context=identity["team"]) - - -class AcmeListeners: - def __init__(self, accounts: AcmeAccounts, auth_url: str): - self.accounts = accounts - self.auth_url = auth_url - - def report_connected(self, args: SuccessArgs) -> BoltResponse: - account = args.installation - client = WebClient(token=account.bot_token) - self.report(client, account.user_id, connection_of(client, account)) - return args.default.success(args) - - def publish_home(self, client: WebClient, context: BoltContext, event: dict): - account = self.accounts.find(context, event["user"]) - provider = acme_provider(self.auth_url, connection_of(client, account)) - client.views_publish(user_id=event["user"], view=home_view(provider)) - - def acknowledge_connect(self, ack: Ack): - ack() - - def acknowledge_disconnect( - self, ack: Ack, body: dict, client: WebClient, context: BoltContext - ): - ack() - self.disconnect(client, context, body["user"]["id"]) - - def answer_status_request( - self, client: WebClient, context: BoltContext, event: dict - ): - account = self.accounts.find(context, event["user"]) - status = "disconnected" if account is None else "connected" - client.apps_user_connection_update(user_id=event["user"], status=status) - - def forget_revoked_users( - self, client: WebClient, context: BoltContext, event: dict - ): - for user_id in event["tokens"].get("oauth", []): - self.accounts.forget(context, user_id) - self.report(client, user_id, None) - - def handle_user_connection( - self, ack: Ack, body: dict, client: WebClient, context: BoltContext - ): - ack() - user_id = body["user"]["id"] - if body["subtype"] == "connect": - self.open_connect_modal(client, context, user_id, body["trigger_id"]) - elif body["subtype"] == "disconnect": - self.disconnect(client, context, user_id) - - def open_connect_modal( - self, client: WebClient, context: BoltContext, user_id: str, trigger_id: str - ): - account = self.accounts.find(context, user_id) - client.apps_user_connection_requestAuth( - trigger_id=trigger_id, - primary_provider=acme_provider( - self.auth_url, connection_of(client, account) - ), - ) - - def disconnect(self, client: WebClient, context: BoltContext, user_id: str): - forgotten_token = self.accounts.forget(context, user_id) - self.report(client, user_id, None) - if forgotten_token is not None: - client.auth_revoke(token=forgotten_token) - - def report(self, client: WebClient, user_id: str, connection: Connection | None): - provider = acme_provider(self.auth_url, connection) - client.apps_user_connection_requestAuth( - user_id=user_id, primary_provider=provider - ) - client.views_publish(user_id=user_id, view=home_view(provider)) diff --git a/standardized-auth/src/listeners/__init__.py b/standardized-auth/src/listeners/__init__.py new file mode 100644 index 0000000..59eb6f1 --- /dev/null +++ b/standardized-auth/src/listeners/__init__.py @@ -0,0 +1,8 @@ +from slack_bolt import App + +from src.listeners import actions, events + + +def register_listeners(app: App): + actions.register(app) + events.register(app) diff --git a/standardized-auth/src/listeners/actions/__init__.py b/standardized-auth/src/listeners/actions/__init__.py new file mode 100644 index 0000000..024a704 --- /dev/null +++ b/standardized-auth/src/listeners/actions/__init__.py @@ -0,0 +1,9 @@ +from slack_bolt import App + +from .connect import connect_callback +from .disconnect import disconnect_callback + + +def register(app: App): + app.action("auth_provider_connect_acme")(connect_callback) + app.action("auth_provider_disconnect_acme")(disconnect_callback) diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py new file mode 100644 index 0000000..fc53357 --- /dev/null +++ b/standardized-auth/src/listeners/actions/connect.py @@ -0,0 +1,5 @@ +from slack_bolt import Ack + + +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..548cd6b --- /dev/null +++ b/standardized-auth/src/listeners/actions/disconnect.py @@ -0,0 +1,9 @@ +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + +from src.acme import disconnect + + +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..6254c14 --- /dev/null +++ b/standardized-auth/src/listeners/actions/user_connection.py @@ -0,0 +1,19 @@ +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + +from src.acme import disconnect, find_user_token, provider + + +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..aba6fb8 --- /dev/null +++ b/standardized-auth/src/listeners/events/__init__.py @@ -0,0 +1,13 @@ +from slack_bolt import App + +from .app_home_opened import app_home_opened_callback +from .tokens_revoked import tokens_revoked_callback +from .user_connection_status_requested import user_connection_status_requested_callback + + +def register(app: App): + app.event("app_home_opened")(app_home_opened_callback) + app.event("tokens_revoked")(tokens_revoked_callback) + app.event("user_connection_status_requested")( + user_connection_status_requested_callback + ) 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..c3dc84f --- /dev/null +++ b/standardized-auth/src/listeners/events/app_home_opened.py @@ -0,0 +1,11 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import find_user_token, home_view, provider + + +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..dc57876 --- /dev/null +++ b/standardized-auth/src/listeners/events/tokens_revoked.py @@ -0,0 +1,10 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import forget, report + + +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..14628a4 --- /dev/null +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -0,0 +1,12 @@ +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from src.acme import find_user_token + + +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/src/listeners/oauth/__init__.py b/standardized-auth/src/listeners/oauth/__init__.py new file mode 100644 index 0000000..b433366 --- /dev/null +++ b/standardized-auth/src/listeners/oauth/__init__.py @@ -0,0 +1,11 @@ +from slack_bolt import BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs + +from .success import success_callback + + +def failure_callback(args: FailureArgs) -> BoltResponse: + return args.default.failure(args) + + +callback_options = CallbackOptions(success=success_callback, failure=failure_callback) diff --git a/standardized-auth/src/listeners/oauth/success.py b/standardized-auth/src/listeners/oauth/success.py new file mode 100644 index 0000000..1d003aa --- /dev/null +++ b/standardized-auth/src/listeners/oauth/success.py @@ -0,0 +1,12 @@ +from slack_bolt import BoltResponse +from slack_bolt.oauth.callback_options import SuccessArgs +from slack_sdk import WebClient + +from src.acme import report + + +def success_callback(args: SuccessArgs) -> BoltResponse: + installation = args.installation + client = WebClient(token=installation.bot_token) + report(client, installation.user_id, installation.user_token) + return args.default.success(args) diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py index 7de5430..7844818 100644 --- a/standardized-auth/tests/conftest.py +++ b/standardized-auth/tests/conftest.py @@ -1,7 +1,25 @@ +import os +from unittest.mock import MagicMock + import pytest +from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore +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: + store = FileInstallationStore(base_dir=str(tmp_path / "installations")) + monkeypatch.setattr("src.acme.installation_store", store) + return store + @pytest.fixture -def store(tmp_path) -> FileInstallationStore: - return FileInstallationStore(base_dir=str(tmp_path / "installations")) +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 index 640ca7f..bd0ddee 100644 --- a/standardized-auth/tests/helpers.py +++ b/standardized-auth/tests/helpers.py @@ -1,6 +1,10 @@ +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, @@ -34,3 +38,21 @@ def context( "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/listeners/oauth/__init__.py b/standardized-auth/tests/listeners/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/standardized-auth/tests/listeners/oauth/test_success.py b/standardized-auth/tests/listeners/oauth/test_success.py new file mode 100644 index 0000000..9d33828 --- /dev/null +++ b/standardized-auth/tests/listeners/oauth/test_success.py @@ -0,0 +1,29 @@ +from unittest.mock import MagicMock, patch + +from slack_bolt.oauth.callback_options import SuccessArgs + +from src.listeners.oauth.success 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.listeners.oauth.success.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 diff --git a/standardized-auth/tests/test_accounts.py b/standardized-auth/tests/test_accounts.py deleted file mode 100644 index 8ddfa57..0000000 --- a/standardized-auth/tests/test_accounts.py +++ /dev/null @@ -1,71 +0,0 @@ -from src.accounts import AcmeAccounts -from tests.helpers import context, installation - - -def test_users_who_never_installed_have_no_account(store): - assert AcmeAccounts(store).find(context(), "U0ADA") is None - - -def test_installations_without_a_user_token_are_not_accounts(store): - store.save(installation("U0ADA")) - - assert AcmeAccounts(store).find(context(), "U0ADA") is None - - -def test_finds_the_account_of_a_user_who_connected(store): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - account = AcmeAccounts(store).find(context(), "U0ADA") - - assert account is not None - assert account.user_token == "xoxp-test-ada" - - -def test_finds_org_wide_accounts_from_any_workspace_in_the_org(store): - store.save( - installation( - "U0ADA", - user_token="xoxp-test-ada", - team_id=None, - enterprise_id="E0001", - is_enterprise_install=True, - ) - ) - org_context = context( - team_id="T0002", enterprise_id="E0001", is_enterprise_install=True - ) - - assert AcmeAccounts(store).find(org_context, "U0ADA") is not None - - -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")) - accounts = AcmeAccounts(store) - - assert accounts.forget(context(), "U0ADA") == "xoxp-test-ada" - assert accounts.find(context(), "U0ADA") is None - assert accounts.find(context(), "U0BOB") is not None - - -def test_forgets_org_wide_accounts(store): - store.save( - installation( - "U0ADA", - user_token="xoxp-test-ada", - team_id=None, - enterprise_id="E0001", - is_enterprise_install=True, - ) - ) - org_context = context( - team_id="T0002", enterprise_id="E0001", is_enterprise_install=True - ) - accounts = AcmeAccounts(store) - - assert accounts.forget(org_context, "U0ADA") == "xoxp-test-ada" - assert accounts.find(org_context, "U0ADA") is None - - -def test_forgetting_a_user_without_an_account_returns_nothing(store): - assert AcmeAccounts(store).forget(context(), "U0ADA") is None diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py index 48c2886..db62b83 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -1,60 +1,90 @@ -from src.acme import ( - CONNECT_ACTION_ID, - CONNECTED_GREETING, - DISCONNECT_ACTION_ID, - WELCOME_GREETING, - Connection, - acme_provider, - home_view, -) +from src.acme import find_user_token, forget, home_view, provider +from tests.helpers import context, installation -AUTH_URL = "https://acme.example/slack/install" +def org_wide_installation(): + return installation( + "U0ADA", + user_token="xoxp-test-ada", + team_id=None, + enterprise_id="E0001", + is_enterprise_install=True, + ) -def test_action_ids_follow_the_provider_value(): - assert CONNECT_ACTION_ID == "auth_provider_connect_acme" - assert DISCONNECT_ACTION_ID == "auth_provider_disconnect_acme" +def org_context(): + return context(team_id="T0002", enterprise_id="E0001", is_enterprise_install=True) -def test_disconnected_provider_offers_the_acme_sign_in(): - assert acme_provider(AUTH_URL, None) == { + +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": AUTH_URL, + "auth_url": "https://acme.example/slack/install", "is_connected": False, } + client.auth_test.assert_not_called() -def test_connected_provider_names_the_connected_account(): - provider = acme_provider(AUTH_URL, Connection(identity="@ada", context="Acme Corp")) +def test_connected_provider_names_the_account_behind_the_token(client): + acme = provider(client, "xoxp-test-ada") - assert provider["is_connected"] is True - assert provider["connected_identity"] == {"type": "plain_text", "text": "@ada"} - assert provider["connected_context"] == {"type": "plain_text", "text": "Acme Corp"} + 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(): - provider = acme_provider(AUTH_URL, None) +def test_home_view_renders_the_provider_as_an_auth_provider_block(client): + acme = provider(client, None) - view = home_view(provider).to_dict() + view = home_view(acme).to_dict() assert view["type"] == "home" - assert view["blocks"][-1] == {"type": "auth_provider", **provider} - - -def test_home_view_welcomes_users_who_are_not_connected(): - view = home_view(acme_provider(AUTH_URL, None)).to_dict() - - assert view["blocks"][0]["text"]["text"] == WELCOME_GREETING - - -def test_home_view_celebrates_users_who_are_connected(): - connection = Connection(identity="@ada", context="Acme Corp") - - view = home_view(acme_provider(AUTH_URL, connection)).to_dict() - - assert view["blocks"][0]["text"]["text"] == CONNECTED_GREETING + assert view["blocks"][-1] == {"type": "auth_provider", **acme} diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index dcb64ce..c4cf54a 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -1,7 +1,6 @@ import hashlib import hmac import json -import os import time from collections.abc import Callable, Iterator from contextlib import contextmanager @@ -11,14 +10,8 @@ import pytest from slack_bolt import App, BoltContext, BoltRequest from slack_sdk import WebClient -from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore -os.environ["SLACK_CLIENT_ID"] = "111.222" -os.environ["SLACK_CLIENT_SECRET"] = "test_client_secret" -os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" - -from src.accounts import AcmeAccounts -from src.acme import CONNECT_ACTION_ID, DISCONNECT_ACTION_ID +from src.acme import find_user_token from src.app import build_app from tests.helpers import installation @@ -40,15 +33,12 @@ def auth_test(token: str) -> dict: @pytest.fixture def app(store) -> App: store.save(installation("U0ADA", user_token="xoxp-test-ada")) - state_store = StatelessOAuthStateStore( - expiration_seconds=300, signing_secret=SIGNING_SECRET - ) - return build_app(store, state_store, "https://acme.example") + return build_app() def test_connect_clicks_are_acknowledged(app): with patch.object(WebClient, "auth_test", side_effect=auth_test): - response = app.dispatch(block_action(CONNECT_ACTION_ID)) + response = app.dispatch(block_action("auth_provider_connect_acme")) assert response.status == 200 @@ -62,11 +52,11 @@ def test_clicks_for_other_providers_are_not_handled(app): def test_disconnect_clicks_forget_the_account(app, store): with slack_api() as api: - response = app.dispatch(block_action(DISCONNECT_ACTION_ID)) + response = app.dispatch(block_action("auth_provider_disconnect_acme")) wait_until(lambda: api.auth_revoke.called) assert response.status == 200 - assert AcmeAccounts(store).find(workspace(), "U0ADA") is None + assert find_user_token(workspace(), "U0ADA") is None def test_app_home_opened_publishes_the_home_view(app): @@ -97,7 +87,7 @@ def test_revoked_user_tokens_are_forgotten(app, store): wait_until(lambda: api.views_publish.called) assert response.status == 200 - assert AcmeAccounts(store).find(workspace(), "U0ADA") is None + assert find_user_token(workspace(), "U0ADA") is None def test_install_redirects_straight_to_slack_with_the_user_scope(app): diff --git a/standardized-auth/tests/test_listeners.py b/standardized-auth/tests/test_listeners.py deleted file mode 100644 index e949a01..0000000 --- a/standardized-auth/tests/test_listeners.py +++ /dev/null @@ -1,224 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from slack_bolt.oauth.callback_options import SuccessArgs -from slack_sdk import WebClient - -from src.accounts import AcmeAccounts -from src.acme import Connection, acme_provider, home_view -from src.listeners import AcmeListeners -from tests.helpers import context, installation - -AUTH_URL = "https://acme.example/slack/install" -ADA = Connection(identity="@ada", context="Acme Corp") - - -@pytest.fixture -def client() -> MagicMock: - client = MagicMock(spec=WebClient) - client.auth_test.return_value = {"ok": True, "user": "ada", "team": "Acme Corp"} - return client - - -@pytest.fixture -def listeners(store) -> AcmeListeners: - return AcmeListeners(AcmeAccounts(store), AUTH_URL) - - -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 user_connection(subtype: str) -> dict: - return { - "type": "user_connection", - "subtype": subtype, - "user": {"id": "U0ADA", "team_id": "T0001"}, - "trigger_id": "123.456.abc", - } - - -def test_report_connected_records_the_connection_then_shows_the_success_page( - listeners, client -): - args = SuccessArgs( - request=MagicMock(), - installation=installation("U0ADA", user_token="xoxp-test-ada"), - settings=MagicMock(), - default=MagicMock(), - ) - - with patch("src.listeners.WebClient", return_value=client) as client_class: - response = listeners.report_connected(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=acme_provider(AUTH_URL, ADA) - ) - assert published_view(client) == home_view(acme_provider(AUTH_URL, ADA)).to_dict() - args.default.success.assert_called_once_with(args) - assert response == args.default.success.return_value - - -def test_publish_home_shows_a_connected_account(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - listeners.publish_home(client=client, context=context(), event={"user": "U0ADA"}) - - assert client.views_publish.call_args.kwargs["user_id"] == "U0ADA" - assert published_view(client) == home_view(acme_provider(AUTH_URL, ADA)).to_dict() - - -def test_publish_home_offers_to_connect_users_without_an_account(listeners, client): - listeners.publish_home(client=client, context=context(), event={"user": "U0ADA"}) - - client.auth_test.assert_not_called() - assert published_view(client) == home_view(acme_provider(AUTH_URL, None)).to_dict() - - -def test_acknowledge_connect_leaves_the_sign_in_to_the_auth_url(listeners): - ack = MagicMock() - - listeners.acknowledge_connect(ack=ack) - - ack.assert_called_once_with() - - -def test_acknowledge_disconnect_forgets_reports_then_revokes(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - ack = MagicMock() - - listeners.acknowledge_disconnect( - ack=ack, body={"user": {"id": "U0ADA"}}, client=client, context=context() - ) - - ack.assert_called_once_with() - assert AcmeAccounts(store).find(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=acme_provider(AUTH_URL, None) - ) - client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") - - -def test_disconnecting_without_an_account_still_reports_disconnected(listeners, client): - listeners.acknowledge_disconnect( - ack=MagicMock(), - body={"user": {"id": "U0ADA"}}, - client=client, - context=context(), - ) - - client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=acme_provider(AUTH_URL, None) - ) - client.auth_revoke.assert_not_called() - - -def test_answer_status_request_reports_connected_users(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - listeners.answer_status_request( - client=client, context=context(), event={"user": "U0ADA"} - ) - - client.apps_user_connection_update.assert_called_once_with( - user_id="U0ADA", status="connected" - ) - - -def test_answer_status_request_reports_users_without_an_account_as_disconnected( - listeners, client -): - listeners.answer_status_request( - client=client, context=context(), event={"user": "U0ADA"} - ) - - client.apps_user_connection_update.assert_called_once_with( - user_id="U0ADA", status="disconnected" - ) - - -def test_forget_revoked_users_reports_them_disconnected(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - listeners.forget_revoked_users( - client=client, context=context(), event={"tokens": {"oauth": ["U0ADA"]}} - ) - - assert AcmeAccounts(store).find(context(), "U0ADA") is None - assert called_methods(client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - - -def test_forget_revoked_users_ignores_revoked_bot_tokens(listeners, client): - listeners.forget_revoked_users( - client=client, context=context(), event={"tokens": {"bot": ["U0BOT"]}} - ) - - assert called_methods(client) == [] - - -def test_user_connection_connect_opens_the_auth_modal(listeners, client): - ack = MagicMock() - - listeners.handle_user_connection( - 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=acme_provider(AUTH_URL, None) - ) - - -def test_user_connection_connect_shows_an_existing_connection(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - listeners.handle_user_connection( - 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=acme_provider(AUTH_URL, ADA) - ) - - -def test_user_connection_disconnect_forgets_the_account(store, listeners, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - ack = MagicMock() - - listeners.handle_user_connection( - ack=ack, body=user_connection("disconnect"), client=client, context=context() - ) - - ack.assert_called_once_with() - assert AcmeAccounts(store).find(context(), "U0ADA") is None - client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") - - -def test_user_connection_with_an_unknown_subtype_is_only_acknowledged( - listeners, client -): - ack = MagicMock() - - listeners.handle_user_connection( - ack=ack, body=user_connection("reconnect"), client=client, context=context() - ) - - ack.assert_called_once_with() - assert called_methods(client) == [] From 2f734bb73f01eae95225244e319ef5ab1773e1c9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 12:44:23 -0400 Subject: [PATCH 04/24] refactor(standardized-auth): register listeners with decorators on load Each listener file now registers itself with @app.action or @app.event, so its id sits next to its callback. app.py imports every file under src/listeners at startup, replacing the hand-written register functions, and the user_connection listener is registered too. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/app.py | 16 ++++++++-- standardized-auth/src/app.py | 29 ++++++++----------- standardized-auth/src/listeners/__init__.py | 8 ----- .../src/listeners/actions/__init__.py | 9 ------ .../src/listeners/actions/connect.py | 3 ++ .../src/listeners/actions/disconnect.py | 2 ++ .../src/listeners/actions/user_connection.py | 2 ++ .../src/listeners/events/__init__.py | 13 --------- .../src/listeners/events/app_home_opened.py | 2 ++ .../src/listeners/events/tokens_revoked.py | 2 ++ .../user_connection_status_requested.py | 2 ++ standardized-auth/tests/conftest.py | 7 +++-- standardized-auth/tests/test_app.py | 4 +-- 14 files changed, 46 insertions(+), 55 deletions(-) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 87c11c2..76ab097 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,7 +6,7 @@ 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). +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`. [`app.py`](./app.py) imports every file in that directory at startup, so a new listener only needs a new file. - **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, [`oauth/success.py`](./src/listeners/oauth/success.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. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index 51d2f8c..ec7a6df 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,6 +1,18 @@ import os +from importlib import import_module +from pathlib import Path -from src.app import build_app +from src.app import app + +ROOT = Path(__file__).parent + + +def import_listeners(directory: Path): + for path in sorted(directory.rglob("[!_]*.py")): + import_module(".".join(path.relative_to(ROOT).with_suffix("").parts)) + + +import_listeners(ROOT / "src" / "listeners") if __name__ == "__main__": - build_app().start(port=int(os.environ.get("PORT", "3000"))) + app.start(port=int(os.environ.get("PORT", "3000"))) diff --git a/standardized-auth/src/app.py b/standardized-auth/src/app.py index d3f70c4..2339a00 100644 --- a/standardized-auth/src/app.py +++ b/standardized-auth/src/app.py @@ -5,23 +5,18 @@ from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore from src import acme -from src.listeners import register_listeners from src.listeners.oauth import callback_options - -def build_app() -> App: - 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=callback_options, +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"], ), - ) - register_listeners(app) - return app + callback_options=callback_options, + ), +) diff --git a/standardized-auth/src/listeners/__init__.py b/standardized-auth/src/listeners/__init__.py index 59eb6f1..e69de29 100644 --- a/standardized-auth/src/listeners/__init__.py +++ b/standardized-auth/src/listeners/__init__.py @@ -1,8 +0,0 @@ -from slack_bolt import App - -from src.listeners import actions, events - - -def register_listeners(app: App): - actions.register(app) - events.register(app) diff --git a/standardized-auth/src/listeners/actions/__init__.py b/standardized-auth/src/listeners/actions/__init__.py index 024a704..e69de29 100644 --- a/standardized-auth/src/listeners/actions/__init__.py +++ b/standardized-auth/src/listeners/actions/__init__.py @@ -1,9 +0,0 @@ -from slack_bolt import App - -from .connect import connect_callback -from .disconnect import disconnect_callback - - -def register(app: App): - app.action("auth_provider_connect_acme")(connect_callback) - app.action("auth_provider_disconnect_acme")(disconnect_callback) diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py index fc53357..c8ee4f0 100644 --- a/standardized-auth/src/listeners/actions/connect.py +++ b/standardized-auth/src/listeners/actions/connect.py @@ -1,5 +1,8 @@ from slack_bolt import Ack +from src.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 index 548cd6b..daeacb9 100644 --- a/standardized-auth/src/listeners/actions/disconnect.py +++ b/standardized-auth/src/listeners/actions/disconnect.py @@ -2,8 +2,10 @@ from slack_sdk import WebClient from src.acme import disconnect +from src.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 index 6254c14..b20638b 100644 --- a/standardized-auth/src/listeners/actions/user_connection.py +++ b/standardized-auth/src/listeners/actions/user_connection.py @@ -2,8 +2,10 @@ from slack_sdk import WebClient from src.acme import disconnect, find_user_token, provider +from src.app import app +@app.action("user_connection") def user_connection_callback( ack: Ack, body: dict, client: WebClient, context: BoltContext ): diff --git a/standardized-auth/src/listeners/events/__init__.py b/standardized-auth/src/listeners/events/__init__.py index aba6fb8..e69de29 100644 --- a/standardized-auth/src/listeners/events/__init__.py +++ b/standardized-auth/src/listeners/events/__init__.py @@ -1,13 +0,0 @@ -from slack_bolt import App - -from .app_home_opened import app_home_opened_callback -from .tokens_revoked import tokens_revoked_callback -from .user_connection_status_requested import user_connection_status_requested_callback - - -def register(app: App): - app.event("app_home_opened")(app_home_opened_callback) - app.event("tokens_revoked")(tokens_revoked_callback) - app.event("user_connection_status_requested")( - user_connection_status_requested_callback - ) diff --git a/standardized-auth/src/listeners/events/app_home_opened.py b/standardized-auth/src/listeners/events/app_home_opened.py index c3dc84f..829f69f 100644 --- a/standardized-auth/src/listeners/events/app_home_opened.py +++ b/standardized-auth/src/listeners/events/app_home_opened.py @@ -2,8 +2,10 @@ from slack_sdk import WebClient from src.acme import find_user_token, home_view, provider +from src.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( diff --git a/standardized-auth/src/listeners/events/tokens_revoked.py b/standardized-auth/src/listeners/events/tokens_revoked.py index dc57876..814c031 100644 --- a/standardized-auth/src/listeners/events/tokens_revoked.py +++ b/standardized-auth/src/listeners/events/tokens_revoked.py @@ -2,8 +2,10 @@ from slack_sdk import WebClient from src.acme import forget, report +from src.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) diff --git a/standardized-auth/src/listeners/events/user_connection_status_requested.py b/standardized-auth/src/listeners/events/user_connection_status_requested.py index 14628a4..44b45e7 100644 --- a/standardized-auth/src/listeners/events/user_connection_status_requested.py +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -2,8 +2,10 @@ from slack_sdk import WebClient from src.acme import find_user_token +from src.app import app +@app.event("user_connection_status_requested") def user_connection_status_requested_callback( client: WebClient, context: BoltContext, event: dict ): diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py index 7844818..282689e 100644 --- a/standardized-auth/tests/conftest.py +++ b/standardized-auth/tests/conftest.py @@ -5,6 +5,8 @@ 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" @@ -13,9 +15,8 @@ @pytest.fixture def store(tmp_path, monkeypatch) -> FileInstallationStore: - store = FileInstallationStore(base_dir=str(tmp_path / "installations")) - monkeypatch.setattr("src.acme.installation_store", store) - return store + monkeypatch.setattr(installation_store, "base_dir", str(tmp_path / "installations")) + return installation_store @pytest.fixture diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index c4cf54a..78e8f13 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -11,8 +11,8 @@ from slack_bolt import App, BoltContext, BoltRequest from slack_sdk import WebClient +import app as entrypoint from src.acme import find_user_token -from src.app import build_app from tests.helpers import installation SIGNING_SECRET = "test_signing_secret" @@ -33,7 +33,7 @@ def auth_test(token: str) -> dict: @pytest.fixture def app(store) -> App: store.save(installation("U0ADA", user_token="xoxp-test-ada")) - return build_app() + return entrypoint.app def test_connect_clicks_are_acknowledged(app): From ce7b9c98326cc0aea510f9466f5a7e83ec9d8a62 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 12:54:45 -0400 Subject: [PATCH 05/24] refactor(standardized-auth): build the app and its oauth callbacks in bolt_app.py Renaming src/app.py to src/bolt_app.py leaves one module named app, the entry point. The OAuth success and failure callbacks are arguments to OAuthSettings rather than listeners, so they now live next to the App they configure, and src/listeners holds only self-registering listeners. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/app.py | 2 +- standardized-auth/src/app.py | 22 ----------- standardized-auth/src/bolt_app.py | 37 +++++++++++++++++++ .../src/listeners/actions/connect.py | 2 +- .../src/listeners/actions/disconnect.py | 2 +- .../src/listeners/actions/user_connection.py | 2 +- .../src/listeners/events/app_home_opened.py | 2 +- .../src/listeners/events/tokens_revoked.py | 2 +- .../user_connection_status_requested.py | 2 +- .../src/listeners/oauth/__init__.py | 11 ------ .../src/listeners/oauth/success.py | 12 ------ .../tests/listeners/oauth/__init__.py | 0 .../test_success.py => test_bolt_app.py} | 6 +-- 14 files changed, 47 insertions(+), 57 deletions(-) delete mode 100644 standardized-auth/src/app.py create mode 100644 standardized-auth/src/bolt_app.py delete mode 100644 standardized-auth/src/listeners/oauth/__init__.py delete mode 100644 standardized-auth/src/listeners/oauth/success.py delete mode 100644 standardized-auth/tests/listeners/oauth/__init__.py rename standardized-auth/tests/{listeners/oauth/test_success.py => test_bolt_app.py} (85%) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 76ab097..e2a5930 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -8,7 +8,7 @@ Read the docs: https://docs.slack.dev/authentication/standardized-auth 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`. [`app.py`](./app.py) imports every file in that directory at startup, so a new listener only needs a new file. -- **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, [`oauth/success.py`](./src/listeners/oauth/success.py) reports the connection with `apps.user.connection.requestAuth` and refreshes App Home. +- **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. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index ec7a6df..2609865 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -2,7 +2,7 @@ from importlib import import_module from pathlib import Path -from src.app import app +from src.bolt_app import app ROOT = Path(__file__).parent diff --git a/standardized-auth/src/app.py b/standardized-auth/src/app.py deleted file mode 100644 index 2339a00..0000000 --- a/standardized-auth/src/app.py +++ /dev/null @@ -1,22 +0,0 @@ -import os - -from slack_bolt import App -from slack_bolt.oauth.oauth_settings import OAuthSettings -from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore - -from src import acme -from src.listeners.oauth import callback_options - -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=callback_options, - ), -) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py new file mode 100644 index 0000000..040c20c --- /dev/null +++ b/standardized-auth/src/bolt_app.py @@ -0,0 +1,37 @@ +import os + +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 + + +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 + ), + ), +) diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py index c8ee4f0..d7ed2a7 100644 --- a/standardized-auth/src/listeners/actions/connect.py +++ b/standardized-auth/src/listeners/actions/connect.py @@ -1,6 +1,6 @@ from slack_bolt import Ack -from src.app import app +from src.bolt_app import app @app.action("auth_provider_connect_acme") diff --git a/standardized-auth/src/listeners/actions/disconnect.py b/standardized-auth/src/listeners/actions/disconnect.py index daeacb9..0000d5b 100644 --- a/standardized-auth/src/listeners/actions/disconnect.py +++ b/standardized-auth/src/listeners/actions/disconnect.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from src.acme import disconnect -from src.app import app +from src.bolt_app import app @app.action("auth_provider_disconnect_acme") diff --git a/standardized-auth/src/listeners/actions/user_connection.py b/standardized-auth/src/listeners/actions/user_connection.py index b20638b..2ff7dd6 100644 --- a/standardized-auth/src/listeners/actions/user_connection.py +++ b/standardized-auth/src/listeners/actions/user_connection.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from src.acme import disconnect, find_user_token, provider -from src.app import app +from src.bolt_app import app @app.action("user_connection") diff --git a/standardized-auth/src/listeners/events/app_home_opened.py b/standardized-auth/src/listeners/events/app_home_opened.py index 829f69f..7fac7e2 100644 --- a/standardized-auth/src/listeners/events/app_home_opened.py +++ b/standardized-auth/src/listeners/events/app_home_opened.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from src.acme import find_user_token, home_view, provider -from src.app import app +from src.bolt_app import app @app.event("app_home_opened") diff --git a/standardized-auth/src/listeners/events/tokens_revoked.py b/standardized-auth/src/listeners/events/tokens_revoked.py index 814c031..1ba5749 100644 --- a/standardized-auth/src/listeners/events/tokens_revoked.py +++ b/standardized-auth/src/listeners/events/tokens_revoked.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from src.acme import forget, report -from src.app import app +from src.bolt_app import app @app.event("tokens_revoked") diff --git a/standardized-auth/src/listeners/events/user_connection_status_requested.py b/standardized-auth/src/listeners/events/user_connection_status_requested.py index 44b45e7..033c144 100644 --- a/standardized-auth/src/listeners/events/user_connection_status_requested.py +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from src.acme import find_user_token -from src.app import app +from src.bolt_app import app @app.event("user_connection_status_requested") diff --git a/standardized-auth/src/listeners/oauth/__init__.py b/standardized-auth/src/listeners/oauth/__init__.py deleted file mode 100644 index b433366..0000000 --- a/standardized-auth/src/listeners/oauth/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from slack_bolt import BoltResponse -from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs - -from .success import success_callback - - -def failure_callback(args: FailureArgs) -> BoltResponse: - return args.default.failure(args) - - -callback_options = CallbackOptions(success=success_callback, failure=failure_callback) diff --git a/standardized-auth/src/listeners/oauth/success.py b/standardized-auth/src/listeners/oauth/success.py deleted file mode 100644 index 1d003aa..0000000 --- a/standardized-auth/src/listeners/oauth/success.py +++ /dev/null @@ -1,12 +0,0 @@ -from slack_bolt import BoltResponse -from slack_bolt.oauth.callback_options import SuccessArgs -from slack_sdk import WebClient - -from src.acme import report - - -def success_callback(args: SuccessArgs) -> BoltResponse: - installation = args.installation - client = WebClient(token=installation.bot_token) - report(client, installation.user_id, installation.user_token) - return args.default.success(args) diff --git a/standardized-auth/tests/listeners/oauth/__init__.py b/standardized-auth/tests/listeners/oauth/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/tests/listeners/oauth/test_success.py b/standardized-auth/tests/test_bolt_app.py similarity index 85% rename from standardized-auth/tests/listeners/oauth/test_success.py rename to standardized-auth/tests/test_bolt_app.py index 9d33828..bf33481 100644 --- a/standardized-auth/tests/listeners/oauth/test_success.py +++ b/standardized-auth/tests/test_bolt_app.py @@ -2,7 +2,7 @@ from slack_bolt.oauth.callback_options import SuccessArgs -from src.listeners.oauth.success import success_callback +from src.bolt_app import success_callback from tests.helpers import expected_home, expected_provider, installation, published_view @@ -14,9 +14,7 @@ def test_reports_the_connection_then_shows_the_success_page(client): default=MagicMock(), ) - with patch( - "src.listeners.oauth.success.WebClient", return_value=client - ) as client_class: + 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") From 6504b4613899124feacf6cc079de0be7ff4be220 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 12:56:54 -0400 Subject: [PATCH 06/24] refactor(standardized-auth): import listeners from bolt_app.py Loading the listener directory where the app is built means any import of the app comes with every listener registered, and app.py is left as a plain entry point. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/app.py | 12 ------------ standardized-auth/src/bolt_app.py | 12 ++++++++++++ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index e2a5930..869d571 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,7 +6,7 @@ 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`. [`app.py`](./app.py) imports every file in that directory at startup, so a new listener only needs a new file. +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. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index 2609865..c689b01 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,18 +1,6 @@ import os -from importlib import import_module -from pathlib import Path from src.bolt_app import app -ROOT = Path(__file__).parent - - -def import_listeners(directory: Path): - for path in sorted(directory.rglob("[!_]*.py")): - import_module(".".join(path.relative_to(ROOT).with_suffix("").parts)) - - -import_listeners(ROOT / "src" / "listeners") - if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", "3000"))) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 040c20c..9fbe8b2 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -1,4 +1,6 @@ import os +from importlib import import_module +from pathlib import Path from slack_bolt import App, BoltResponse from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs @@ -8,6 +10,8 @@ from src import acme +ROOT = Path(__file__).parents[1] + def success_callback(args: SuccessArgs) -> BoltResponse: installation = args.installation @@ -35,3 +39,11 @@ def failure_callback(args: FailureArgs) -> BoltResponse: ), ), ) + + +def import_listeners(directory: Path): + for path in sorted(directory.rglob("[!_]*.py")): + import_module(".".join(path.relative_to(ROOT).with_suffix("").parts)) + + +import_listeners(ROOT / "src" / "listeners") From 1d3d91772ae97814b96d3500cacbc3cf270eb16b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 13:03:37 -0400 Subject: [PATCH 07/24] refactor(standardized-auth): name listener modules relative to their package The loader builds each module name from the listeners directory and the package bolt_app.py lives in, so no project root path is needed. Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 9fbe8b2..64b0912 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -10,8 +10,6 @@ from src import acme -ROOT = Path(__file__).parents[1] - def success_callback(args: SuccessArgs) -> BoltResponse: installation = args.installation @@ -43,7 +41,8 @@ def failure_callback(args: FailureArgs) -> BoltResponse: def import_listeners(directory: Path): for path in sorted(directory.rglob("[!_]*.py")): - import_module(".".join(path.relative_to(ROOT).with_suffix("").parts)) + module = path.relative_to(directory.parent).with_suffix("") + import_module("." + ".".join(module.parts), __package__) -import_listeners(ROOT / "src" / "listeners") +import_listeners(Path(__file__).parent / "listeners") From 250924c6e915acc6d34c5d235f37b9ec7eddef03 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 13:05:08 -0400 Subject: [PATCH 08/24] refactor(standardized-auth): find listener files with glob Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 64b0912..19ebfeb 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -40,7 +40,7 @@ def failure_callback(args: FailureArgs) -> BoltResponse: def import_listeners(directory: Path): - for path in sorted(directory.rglob("[!_]*.py")): + for path in sorted(directory.glob("**/[!_]*.py")): module = path.relative_to(directory.parent).with_suffix("") import_module("." + ".".join(module.parts), __package__) From 0506019b5b5337af18fb7f54ea37a61f22851296 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 13:07:58 -0400 Subject: [PATCH 09/24] refactor(standardized-auth): glob every python file in listeners Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 19ebfeb..7797999 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -40,7 +40,7 @@ def failure_callback(args: FailureArgs) -> BoltResponse: def import_listeners(directory: Path): - for path in sorted(directory.glob("**/[!_]*.py")): + for path in sorted(directory.glob("**/*.py")): module = path.relative_to(directory.parent).with_suffix("") import_module("." + ".".join(module.parts), __package__) From 03a47e156cb2d78ee664321ff5120dd545d1c615 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 13:14:22 -0400 Subject: [PATCH 10/24] refactor(standardized-auth): load listeners with pkgutil.walk_packages Walking the listeners package by module name reads more plainly than building names from file paths. Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 7797999..404d36e 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -1,6 +1,6 @@ import os from importlib import import_module -from pathlib import Path +from pkgutil import walk_packages from slack_bolt import App, BoltResponse from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs @@ -8,7 +8,7 @@ from slack_sdk import WebClient from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore -from src import acme +from src import acme, listeners def success_callback(args: SuccessArgs) -> BoltResponse: @@ -39,10 +39,9 @@ def failure_callback(args: FailureArgs) -> BoltResponse: ) -def import_listeners(directory: Path): - for path in sorted(directory.glob("**/*.py")): - module = path.relative_to(directory.parent).with_suffix("") - import_module("." + ".".join(module.parts), __package__) +def load_listeners() -> None: + for module_info in walk_packages(listeners.__path__, listeners.__name__ + "."): + import_module(module_info.name) -import_listeners(Path(__file__).parent / "listeners") +load_listeners() From 0bfe44184181a1fb19487652d10456f0f2d79f43 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 13:15:48 -0400 Subject: [PATCH 11/24] style(standardized-auth): build the listener prefix with an f-string Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 404d36e..611d0c8 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -40,7 +40,7 @@ def failure_callback(args: FailureArgs) -> BoltResponse: def load_listeners() -> None: - for module_info in walk_packages(listeners.__path__, listeners.__name__ + "."): + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): import_module(module_info.name) From c883e8001a9ed6765f5d4cc0dae10ad0e2e538f8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 14:23:58 -0400 Subject: [PATCH 12/24] fix(standardized-auth): clear the user token from the latest installation on forget Deleting only the per-user installer files left the revoked user token in the workspace's latest installation. Bolt's authorize then ran auth.test on that token for the disconnected user and dropped their next request. Saving the installation without the user token updates both files. Co-Authored-By: Claude --- standardized-auth/src/acme.py | 10 ++++------ standardized-auth/tests/test_acme.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index bb294a6..4fb5743 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -37,12 +37,10 @@ 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 + user_token = account.user_token + account.user_token = None + installation_store.save(account) + return user_token def plain_text(text: str) -> dict[str, str]: diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py index db62b83..4014c02 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -54,6 +54,17 @@ def test_forgets_org_wide_accounts(store): assert find_user_token(org_context(), "U0ADA") is None +def test_forget_clears_the_token_from_the_latest_installation(store): + store.save(installation("U0ADA", user_token="xoxp-test-ada")) + + forget(context(), "U0ADA") + + latest = store.find_installation(enterprise_id=None, team_id="T0001") + assert latest is not None + assert latest.user_token is None + assert latest.bot_token == "xoxb-test-bot" + + def test_forgetting_a_user_without_an_account_returns_nothing(store): assert forget(context(), "U0ADA") is None From c6597239ff42b7f1152cf6886005fcfb0866c698 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 19 Oct 2026 16:52:57 -0400 Subject: [PATCH 13/24] fix(standardized-auth): return users to app home after connecting The default success page sends org-wide installs to the admin page for adding workspaces. A user who clicked Connect should land back in the app's Home tab, so the success callback now redirects there. Co-Authored-By: Claude --- standardized-auth/src/bolt_app.py | 8 +++++- standardized-auth/tests/test_bolt_app.py | 32 ++++++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py index 611d0c8..e07ae74 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -6,16 +6,22 @@ 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.installation_store import Installation from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore from src import acme, listeners +def app_home_url(installation: Installation) -> str: + team_id = installation.team_id or installation.enterprise_id + return f"slack://app?team={team_id}&id={installation.app_id}&tab=home" + + 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) + return BoltResponse(status=302, headers={"Location": app_home_url(installation)}) def failure_callback(args: FailureArgs) -> BoltResponse: diff --git a/standardized-auth/tests/test_bolt_app.py b/standardized-auth/tests/test_bolt_app.py index bf33481..6481d87 100644 --- a/standardized-auth/tests/test_bolt_app.py +++ b/standardized-auth/tests/test_bolt_app.py @@ -1,19 +1,24 @@ from unittest.mock import MagicMock, patch from slack_bolt.oauth.callback_options import SuccessArgs +from slack_sdk.oauth.installation_store import Installation 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( +def success_args(connected: Installation) -> SuccessArgs: + return SuccessArgs( request=MagicMock(), - installation=installation("U0ADA", user_token="xoxp-test-ada"), + installation=connected, settings=MagicMock(), default=MagicMock(), ) + +def test_reports_the_connection_then_returns_to_app_home(client): + args = success_args(installation("U0ADA", user_token="xoxp-test-ada")) + with patch("src.bolt_app.WebClient", return_value=client) as client_class: response = success_callback(args) @@ -23,5 +28,22 @@ def test_reports_the_connection_then_shows_the_success_page(client): 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 + assert response.status == 302 + assert response.headers["location"] == ["slack://app?team=T0001&id=A0001&tab=home"] + + +def test_org_wide_connections_return_to_app_home_in_the_org(client): + args = success_args( + installation( + "U0ADA", + user_token="xoxp-test-ada", + team_id=None, + enterprise_id="E0001", + is_enterprise_install=True, + ) + ) + + with patch("src.bolt_app.WebClient", return_value=client): + response = success_callback(args) + + assert response.headers["location"] == ["slack://app?team=E0001&id=A0001&tab=home"] From 0bcc0d5d82542eda08c00e581a7cc54f3e52d012 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 13:40:50 -0400 Subject: [PATCH 14/24] refactor(standardized-auth): run over socket mode with a sample sign-in page The installation store pulled in OAuth, request URLs, a signing secret, token revocation, and tokens_revoked, none of which the sample is about. Acme now runs over Socket Mode, keeps connections in SQLite, and serves a small sign-in page as the Connect auth_url. A signed state ties that page to the Slack user who clicked Connect. Only the sign-in page needs ngrok, since auth_url must be HTTPS. Co-Authored-By: Claude --- standardized-auth/.env.example | 3 - standardized-auth/README.md | 19 ++-- standardized-auth/app.py | 8 +- standardized-auth/manifest.json | 16 +-- standardized-auth/src/acme.py | 62 ++++------ standardized-auth/src/bolt_app.py | 42 +------ standardized-auth/src/connections.py | 40 +++++++ .../src/listeners/actions/disconnect.py | 6 +- .../src/listeners/actions/user_connection.py | 14 +-- .../src/listeners/events/app_home_opened.py | 10 +- .../src/listeners/events/tokens_revoked.py | 12 -- .../user_connection_status_requested.py | 11 +- standardized-auth/src/sign_in.py | 72 ++++++++++++ standardized-auth/src/state.py | 20 ++++ standardized-auth/tests/conftest.py | 33 ++++-- standardized-auth/tests/helpers.py | 47 +------- .../listeners/actions/test_disconnect.py | 28 ++--- .../listeners/actions/test_user_connection.py | 34 +++--- .../listeners/events/test_app_home_opened.py | 13 +-- .../listeners/events/test_tokens_revoked.py | 25 ---- .../test_user_connection_status_requested.py | 11 +- standardized-auth/tests/test_acme.py | 92 ++------------- standardized-auth/tests/test_app.py | 107 ++++-------------- standardized-auth/tests/test_bolt_app.py | 49 -------- standardized-auth/tests/test_connections.py | 48 ++++++++ standardized-auth/tests/test_sign_in.py | 88 ++++++++++++++ standardized-auth/tests/test_state.py | 19 ++++ 27 files changed, 429 insertions(+), 500 deletions(-) create mode 100644 standardized-auth/src/connections.py delete mode 100644 standardized-auth/src/listeners/events/tokens_revoked.py create mode 100644 standardized-auth/src/sign_in.py create mode 100644 standardized-auth/src/state.py delete mode 100644 standardized-auth/tests/listeners/events/test_tokens_revoked.py delete mode 100644 standardized-auth/tests/test_bolt_app.py create mode 100644 standardized-auth/tests/test_connections.py create mode 100644 standardized-auth/tests/test_sign_in.py create mode 100644 standardized-auth/tests/test_state.py diff --git a/standardized-auth/.env.example b/standardized-auth/.env.example index 1b64f2a..a1705fd 100644 --- a/standardized-auth/.env.example +++ b/standardized-auth/.env.example @@ -1,5 +1,2 @@ -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/README.md b/standardized-auth/README.md index 869d571..f975c43 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,26 +6,23 @@ 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. +Acme runs over Socket Mode and keeps every connection in a local SQLite database ([`src/connections.py`](./src/connections.py)): a user is connected when it holds their Acme username. 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. +- **Connect** opens Acme's sign-in page ([`src/sign_in.py`](./src/sign_in.py)) with a signed `state` ([`src/state.py`](./src/state.py)) naming the Slack user. Signing in saves the connection, reports it with `apps.user.connection.requestAuth`, and refreshes App Home. +- **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the connection and reports the user as disconnected. - **`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. +The sign-in page stands in for your service's real login. Swap it for your own OAuth or SSO flow, and keep the signed `state` so a connection always lands on the Slack user who clicked **Connect**. + ## Setup +Events arrive over Socket Mode, so only the sign-in page needs a public HTTPS URL. + ```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 env init # Set BASE_URL to your ngrok URL $ 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 index c689b01..8ae9854 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,6 +1,12 @@ import os +from threading import Thread + +from slack_bolt.adapter.socket_mode import SocketModeHandler from src.bolt_app import app +from src.sign_in import SignInServer if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", "3000"))) + sign_in = SignInServer(int(os.environ.get("PORT", "3000")), app.client) + Thread(target=sign_in.serve_forever, daemon=True).start() + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json index 89b07bf..920f039 100644 --- a/standardized-auth/manifest.json +++ b/standardized-auth/manifest.json @@ -19,27 +19,19 @@ } }, "oauth_config": { - "redirect_urls": ["https://1234-56-78-90-0.ngrok-free.app/slack/oauth_redirect"], "scopes": { - "bot": ["users:read"], - "user": ["users.profile:read"] + "bot": ["users:read"] } }, "settings": { "event_subscriptions": { - "request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events", - "bot_events": [ - "app_home_opened", - "tokens_revoked", - "user_connection_status_requested" - ] + "bot_events": ["app_home_opened", "user_connection_status_requested"] }, "interactivity": { - "is_enabled": true, - "request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events" + "is_enabled": true }, "org_deploy_enabled": true, - "socket_mode_enabled": false, + "socket_mode_enabled": true, "token_rotation_enabled": false } } diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index 4fb5743..b312ca4 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -1,13 +1,14 @@ 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") +from src.connections import connections +from src.state import issue + +CONNECT_PATH = "/acme/connect" GREETING = ( "*Welcome to Acme!* :rocket:\n" @@ -16,51 +17,23 @@ ) -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 - user_token = account.user_token - account.user_token = None - installation_store.save(account) - return 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]: +def provider(user_id: str) -> dict[str, Any]: + username = connections.find(user_id) 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, + "auth_url": f"{os.environ['BASE_URL']}{CONNECT_PATH}?state={issue(user_id)}", + "is_connected": username 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"]) + if username is not None: + acme["connected_identity"] = plain_text(username) return acme @@ -74,14 +47,17 @@ def home_view(acme: dict[str, Any]) -> View: ) -def report(client: WebClient, user_id: str, user_token: str | None = None): - acme = provider(client, user_token) +def report(client: WebClient, user_id: str): + 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)) -def disconnect(client: WebClient, context: BoltContext, user_id: str): - forgotten_token = forget(context, user_id) +def connect(client: WebClient, user_id: str, username: str): + connections.connect(user_id, username) + report(client, user_id) + + +def disconnect(client: WebClient, user_id: str): + connections.disconnect(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 index e07ae74..a902a60 100644 --- a/standardized-auth/src/bolt_app.py +++ b/standardized-auth/src/bolt_app.py @@ -2,47 +2,11 @@ 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.installation_store import Installation -from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore +from slack_bolt import App -from src import acme, listeners +from src import listeners - -def app_home_url(installation: Installation) -> str: - team_id = installation.team_id or installation.enterprise_id - return f"slack://app?team={team_id}&id={installation.app_id}&tab=home" - - -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 BoltResponse(status=302, headers={"Location": app_home_url(installation)}) - - -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 - ), - ), -) +app = App(token=os.environ["SLACK_BOT_TOKEN"]) def load_listeners() -> None: diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py new file mode 100644 index 0000000..751dbf9 --- /dev/null +++ b/standardized-auth/src/connections.py @@ -0,0 +1,40 @@ +import sqlite3 +from contextlib import closing +from pathlib import Path + + +class Connections: + def __init__(self, path: Path): + self.path = path + + def connect(self, user_id: str, username: str) -> None: + self._write( + "INSERT OR REPLACE INTO connections (user_id, username) VALUES (?, ?)", + (user_id, username), + ) + + def disconnect(self, user_id: str) -> bool: + return self._write("DELETE FROM connections WHERE user_id = ?", (user_id,)) > 0 + + def find(self, user_id: str) -> str | None: + with closing(self._open()) as db: + row = db.execute( + "SELECT username FROM connections WHERE user_id = ?", (user_id,) + ).fetchone() + return None if row is None else row[0] + + def _write(self, sql: str, parameters: tuple[str, ...]) -> int: + with closing(self._open()) as db, db: + return db.execute(sql, parameters).rowcount + + def _open(self) -> sqlite3.Connection: + self.path.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(self.path) + db.execute( + "CREATE TABLE IF NOT EXISTS connections" + " (user_id TEXT PRIMARY KEY, username TEXT NOT NULL)" + ) + return db + + +connections = Connections(Path("data/acme.db")) diff --git a/standardized-auth/src/listeners/actions/disconnect.py b/standardized-auth/src/listeners/actions/disconnect.py index 0000d5b..6a2d12f 100644 --- a/standardized-auth/src/listeners/actions/disconnect.py +++ b/standardized-auth/src/listeners/actions/disconnect.py @@ -1,4 +1,4 @@ -from slack_bolt import Ack, BoltContext +from slack_bolt import Ack from slack_sdk import WebClient from src.acme import disconnect @@ -6,6 +6,6 @@ @app.action("auth_provider_disconnect_acme") -def disconnect_callback(ack: Ack, body: dict, client: WebClient, context: BoltContext): +def disconnect_callback(ack: Ack, body: dict, client: WebClient): ack() - disconnect(client, context, body["user"]["id"]) + disconnect(client, body["user"]["id"]) diff --git a/standardized-auth/src/listeners/actions/user_connection.py b/standardized-auth/src/listeners/actions/user_connection.py index 2ff7dd6..53aaa60 100644 --- a/standardized-auth/src/listeners/actions/user_connection.py +++ b/standardized-auth/src/listeners/actions/user_connection.py @@ -1,21 +1,17 @@ -from slack_bolt import Ack, BoltContext +from slack_bolt import Ack from slack_sdk import WebClient -from src.acme import disconnect, find_user_token, provider +from src.acme import disconnect, provider from src.bolt_app import app @app.action("user_connection") -def user_connection_callback( - ack: Ack, body: dict, client: WebClient, context: BoltContext -): +def user_connection_callback(ack: Ack, body: dict, client: WebClient): 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), + trigger_id=body["trigger_id"], primary_provider=provider(user_id) ) elif body["subtype"] == "disconnect": - disconnect(client, context, user_id) + disconnect(client, user_id) diff --git a/standardized-auth/src/listeners/events/app_home_opened.py b/standardized-auth/src/listeners/events/app_home_opened.py index 7fac7e2..6129574 100644 --- a/standardized-auth/src/listeners/events/app_home_opened.py +++ b/standardized-auth/src/listeners/events/app_home_opened.py @@ -1,13 +1,9 @@ -from slack_bolt import BoltContext from slack_sdk import WebClient -from src.acme import find_user_token, home_view, provider +from src.acme import 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)) - ) +def app_home_opened_callback(client: WebClient, event: dict): + client.views_publish(user_id=event["user"], view=home_view(provider(event["user"]))) diff --git a/standardized-auth/src/listeners/events/tokens_revoked.py b/standardized-auth/src/listeners/events/tokens_revoked.py deleted file mode 100644 index 1ba5749..0000000 --- a/standardized-auth/src/listeners/events/tokens_revoked.py +++ /dev/null @@ -1,12 +0,0 @@ -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 index 033c144..54d494e 100644 --- a/standardized-auth/src/listeners/events/user_connection_status_requested.py +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -1,14 +1,11 @@ -from slack_bolt import BoltContext from slack_sdk import WebClient -from src.acme import find_user_token from src.bolt_app import app +from src.connections import connections @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" +def user_connection_status_requested_callback(client: WebClient, event: dict): + connected = connections.find(event["user"]) is not None + status = "connected" if connected else "disconnected" client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/src/sign_in.py b/standardized-auth/src/sign_in.py new file mode 100644 index 0000000..932eba3 --- /dev/null +++ b/standardized-auth/src/sign_in.py @@ -0,0 +1,72 @@ +from html import escape +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import cast +from urllib.parse import parse_qs, urlsplit + +from slack_sdk import WebClient + +from src.acme import CONNECT_PATH, connect +from src.state import redeem + +CONNECTED_PAGE = """ +Connected to Acme +

Connected to Acme

+

You can close this tab and return to Slack.

+""" + + +def sign_in_page(state: str) -> str: + return f""" +Sign in to Acme +

Sign in to Acme

+
+ + + +
+""" + + +def field(fields: dict[str, list[str]], name: str) -> str: + return fields.get(name, [""])[0] + + +class SignInServer(ThreadingHTTPServer): + def __init__(self, port: int, client: WebClient): + super().__init__(("", port), SignInHandler) + self.client = client + + +class SignInHandler(BaseHTTPRequestHandler): + def do_GET(self): + url = urlsplit(self.path) + if url.path != CONNECT_PATH: + return self.send_error(HTTPStatus.NOT_FOUND) + state = field(parse_qs(url.query), "state") + if redeem(state) is None: + return self.send_error(HTTPStatus.BAD_REQUEST) + self.send_page(sign_in_page(state)) + + def do_POST(self): + if self.path != CONNECT_PATH: + return self.send_error(HTTPStatus.NOT_FOUND) + form = self.read_form() + user_id = redeem(field(form, "state")) + username = field(form, "username").strip() + if user_id is None or not username: + return self.send_error(HTTPStatus.BAD_REQUEST) + connect(cast(SignInServer, self.server).client, user_id, username) + self.send_page(CONNECTED_PAGE) + + def read_form(self) -> dict[str, list[str]]: + length = int(self.headers.get("Content-Length", 0)) + return parse_qs(self.rfile.read(length).decode()) + + def send_page(self, html: str): + body = html.encode() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) diff --git a/standardized-auth/src/state.py b/standardized-auth/src/state.py new file mode 100644 index 0000000..3fb25e3 --- /dev/null +++ b/standardized-auth/src/state.py @@ -0,0 +1,20 @@ +import hmac +import secrets +from hashlib import sha256 + +key = secrets.token_bytes(32) + + +def sign(user_id: str) -> str: + return hmac.new(key, user_id.encode(), sha256).hexdigest() + + +def issue(user_id: str) -> str: + return f"{user_id}.{sign(user_id)}" + + +def redeem(state: str) -> str | None: + user_id, _, signature = state.partition(".") + if hmac.compare_digest(signature.encode(), sign(user_id).encode()): + return user_id + return None diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py index 282689e..0cc4ca7 100644 --- a/standardized-auth/tests/conftest.py +++ b/standardized-auth/tests/conftest.py @@ -1,26 +1,35 @@ import os -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from slack_sdk import WebClient -from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.web import SlackResponse -from src.acme import installation_store +from src.connections import Connections, connections -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["SLACK_BOT_TOKEN"] = "xoxb-test-bot" os.environ["BASE_URL"] = "https://acme.example" +patch( + "slack_sdk.web.client.WebClient.auth_test", + return_value=SlackResponse( + client=None, + http_verb="POST", + api_url="https://slack.com/api/auth.test", + req_args={}, + data={"ok": True, "team_id": "T0001", "bot_id": "B0001", "user_id": "U0BOT"}, + headers={}, + status_code=200, + ), +).start() + @pytest.fixture -def store(tmp_path, monkeypatch) -> FileInstallationStore: - monkeypatch.setattr(installation_store, "base_dir", str(tmp_path / "installations")) - return installation_store +def store(tmp_path, monkeypatch) -> Connections: + monkeypatch.setattr(connections, "path", tmp_path / "acme.db") + return connections @pytest.fixture def client() -> MagicMock: - client = MagicMock(spec=WebClient) - client.auth_test.return_value = {"ok": True, "user": "ada", "team": "Acme Corp"} - return client + return MagicMock(spec=WebClient) diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py index bd0ddee..28ed7a3 100644 --- a/standardized-auth/tests/helpers.py +++ b/standardized-auth/tests/helpers.py @@ -1,45 +1,8 @@ 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() @@ -48,11 +11,5 @@ 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() +def expected_home(user_id: str) -> dict: + return home_view(provider(user_id)).to_dict() diff --git a/standardized-auth/tests/listeners/actions/test_disconnect.py b/standardized-auth/tests/listeners/actions/test_disconnect.py index 754ef05..d72cd63 100644 --- a/standardized-auth/tests/listeners/actions/test_disconnect.py +++ b/standardized-auth/tests/listeners/actions/test_disconnect.py @@ -1,40 +1,30 @@ from unittest.mock import MagicMock -from src.acme import find_user_token +from src.acme import provider from src.listeners.actions.disconnect import disconnect_callback -from tests.helpers import called_methods, context, expected_provider, installation +from tests.helpers import called_methods -def test_forgets_reports_then_revokes(store, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) +def test_forgets_then_reports_the_user(store, client): + store.connect("U0ADA", "ada") ack = MagicMock() - disconnect_callback( - ack=ack, body={"user": {"id": "U0ADA"}}, client=client, context=context() - ) + disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=client) ack.assert_called_once_with() - assert find_user_token(context(), "U0ADA") is None + assert store.find("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() + user_id="U0ADA", primary_provider=provider("U0ADA") ) - 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(), - ) + disconnect_callback(ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=client) client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=expected_provider() + user_id="U0ADA", primary_provider=provider("U0ADA") ) - 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 index e85d4cf..a1f6e6a 100644 --- a/standardized-auth/tests/listeners/actions/test_user_connection.py +++ b/standardized-auth/tests/listeners/actions/test_user_connection.py @@ -1,8 +1,8 @@ from unittest.mock import MagicMock -from src.acme import find_user_token +from src.acme import provider from src.listeners.actions.user_connection import user_connection_callback -from tests.helpers import called_methods, context, expected_provider, installation +from tests.helpers import called_methods def user_connection(subtype: str) -> dict: @@ -17,50 +17,42 @@ def user_connection(subtype: str) -> dict: def test_connect_opens_the_auth_modal(store, client): ack = MagicMock() - user_connection_callback( - ack=ack, body=user_connection("connect"), client=client, context=context() - ) + user_connection_callback(ack=ack, body=user_connection("connect"), client=client) ack.assert_called_once_with() client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=expected_provider() + trigger_id="123.456.abc", primary_provider=provider("U0ADA") ) + assert provider("U0ADA")["is_connected"] is False def test_connect_shows_an_existing_connection(store, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) + store.connect("U0ADA", "ada") user_connection_callback( - ack=MagicMock(), - body=user_connection("connect"), - client=client, - context=context(), + ack=MagicMock(), body=user_connection("connect"), client=client ) client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=expected_provider("xoxp-test-ada") + trigger_id="123.456.abc", primary_provider=provider("U0ADA") ) + assert provider("U0ADA")["is_connected"] is True def test_disconnect_forgets_the_account(store, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) + store.connect("U0ADA", "ada") ack = MagicMock() - user_connection_callback( - ack=ack, body=user_connection("disconnect"), client=client, context=context() - ) + user_connection_callback(ack=ack, body=user_connection("disconnect"), client=client) ack.assert_called_once_with() - assert find_user_token(context(), "U0ADA") is None - client.auth_revoke.assert_called_once_with(token="xoxp-test-ada") + assert store.find("U0ADA") is None 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() - ) + user_connection_callback(ack=ack, body=user_connection("reconnect"), client=client) ack.assert_called_once_with() assert called_methods(client) == [] diff --git a/standardized-auth/tests/listeners/events/test_app_home_opened.py b/standardized-auth/tests/listeners/events/test_app_home_opened.py index 5a41331..c22a1e7 100644 --- a/standardized-auth/tests/listeners/events/test_app_home_opened.py +++ b/standardized-auth/tests/listeners/events/test_app_home_opened.py @@ -1,18 +1,17 @@ from src.listeners.events.app_home_opened import app_home_opened_callback -from tests.helpers import context, expected_home, installation, published_view +from tests.helpers import expected_home, published_view def test_shows_a_connected_account(store, client): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) + store.connect("U0ADA", "ada") - app_home_opened_callback(client=client, context=context(), event={"user": "U0ADA"}) + app_home_opened_callback(client=client, event={"user": "U0ADA"}) assert client.views_publish.call_args.kwargs["user_id"] == "U0ADA" - assert published_view(client) == expected_home("xoxp-test-ada") + assert published_view(client) == expected_home("U0ADA") def test_offers_to_connect_users_without_an_account(store, client): - app_home_opened_callback(client=client, context=context(), event={"user": "U0ADA"}) + app_home_opened_callback(client=client, event={"user": "U0ADA"}) - client.auth_test.assert_not_called() - assert published_view(client) == expected_home() + assert published_view(client) == expected_home("U0ADA") diff --git a/standardized-auth/tests/listeners/events/test_tokens_revoked.py b/standardized-auth/tests/listeners/events/test_tokens_revoked.py deleted file mode 100644 index b898895..0000000 --- a/standardized-auth/tests/listeners/events/test_tokens_revoked.py +++ /dev/null @@ -1,25 +0,0 @@ -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 index 0bacf57..3cdd532 100644 --- a/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py +++ b/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py @@ -1,15 +1,12 @@ 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")) + store.connect("U0ADA", "ada") - user_connection_status_requested_callback( - client=client, context=context(), event={"user": "U0ADA"} - ) + user_connection_status_requested_callback(client=client, event={"user": "U0ADA"}) client.apps_user_connection_update.assert_called_once_with( user_id="U0ADA", status="connected" @@ -17,9 +14,7 @@ def test_reports_connected_users(store, client): def test_reports_users_without_an_account_as_disconnected(store, client): - user_connection_status_requested_callback( - client=client, context=context(), event={"user": "U0ADA"} - ) + user_connection_status_requested_callback(client=client, 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 index 4014c02..dc9eb58 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -1,99 +1,31 @@ -from src.acme import find_user_token, forget, home_view, provider -from tests.helpers import context, installation +from src.acme import home_view, provider +from src.state import issue -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_forget_clears_the_token_from_the_latest_installation(store): - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - - forget(context(), "U0ADA") - - latest = store.find_installation(enterprise_id=None, team_id="T0001") - assert latest is not None - assert latest.user_token is None - assert latest.bot_token == "xoxb-test-bot" - - -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) == { +def test_disconnected_provider_offers_the_acme_sign_in(store): + 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.", }, - "auth_url": "https://acme.example/slack/install", + "auth_url": f"https://acme.example/acme/connect?state={issue('U0ADA')}", "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") +def test_connected_provider_names_the_acme_account(store): + store.connect("U0ADA", "ada") + + acme = provider("U0ADA") - 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"} + assert acme["connected_identity"] == {"type": "plain_text", "text": "ada"} -def test_home_view_renders_the_provider_as_an_auth_provider_block(client): - acme = provider(client, None) +def test_home_view_renders_the_provider_as_an_auth_provider_block(store): + acme = provider("U0ADA") view = home_view(acme).to_dict() diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index 78e8f13..9077bce 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -1,51 +1,29 @@ -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_bolt import App, 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 +from src.bolt_app import app as bolt_app @pytest.fixture def app(store) -> App: - store.save(installation("U0ADA", user_token="xoxp-test-ada")) - return entrypoint.app + store.connect("U0ADA", "ada") + return bolt_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")) + 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")) + response = app.dispatch(block_action("auth_provider_connect_other")) assert response.status == 404 @@ -53,10 +31,10 @@ def test_clicks_for_other_providers_are_not_handled(app): 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) + wait_until(lambda: api.views_publish.called) assert response.status == 200 - assert find_user_token(workspace(), "U0ADA") is None + assert store.find("U0ADA") is None def test_app_home_opened_publishes_the_home_view(app): @@ -80,38 +58,16 @@ def test_status_requests_are_answered(app): ) -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} - ), + with patch.multiple( + WebClient, **{method: getattr(api, method) for method in methods} ): yield api @@ -123,24 +79,16 @@ def wait_until(condition: Callable[[], bool]) -> None: 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) + envelope = { + "type": "event_callback", + "api_app_id": "A0001", + "team_id": "T0001", + "event": body, + "event_id": "Ev0001", + "event_time": 1700000000, + } + return BoltRequest(body=envelope, mode="socket_mode") def block_action(action_id: str) -> BoltRequest: @@ -159,19 +107,4 @@ def block_action(action_id: str) -> BoltRequest: } ], } - 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], - } + return BoltRequest(body=payload, mode="socket_mode") diff --git a/standardized-auth/tests/test_bolt_app.py b/standardized-auth/tests/test_bolt_app.py deleted file mode 100644 index 6481d87..0000000 --- a/standardized-auth/tests/test_bolt_app.py +++ /dev/null @@ -1,49 +0,0 @@ -from unittest.mock import MagicMock, patch - -from slack_bolt.oauth.callback_options import SuccessArgs -from slack_sdk.oauth.installation_store import Installation - -from src.bolt_app import success_callback -from tests.helpers import expected_home, expected_provider, installation, published_view - - -def success_args(connected: Installation) -> SuccessArgs: - return SuccessArgs( - request=MagicMock(), - installation=connected, - settings=MagicMock(), - default=MagicMock(), - ) - - -def test_reports_the_connection_then_returns_to_app_home(client): - args = success_args(installation("U0ADA", user_token="xoxp-test-ada")) - - 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") - assert response.status == 302 - assert response.headers["location"] == ["slack://app?team=T0001&id=A0001&tab=home"] - - -def test_org_wide_connections_return_to_app_home_in_the_org(client): - args = success_args( - installation( - "U0ADA", - user_token="xoxp-test-ada", - team_id=None, - enterprise_id="E0001", - is_enterprise_install=True, - ) - ) - - with patch("src.bolt_app.WebClient", return_value=client): - response = success_callback(args) - - assert response.headers["location"] == ["slack://app?team=E0001&id=A0001&tab=home"] diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py new file mode 100644 index 0000000..5caa7c9 --- /dev/null +++ b/standardized-auth/tests/test_connections.py @@ -0,0 +1,48 @@ +from src.connections import Connections + + +def test_remembers_a_connected_username(tmp_path): + connections = Connections(tmp_path / "acme.db") + + connections.connect("U0ADA", "ada") + + assert connections.find("U0ADA") == "ada" + + +def test_reconnecting_replaces_the_username(tmp_path): + connections = Connections(tmp_path / "acme.db") + connections.connect("U0ADA", "ada") + + connections.connect("U0ADA", "countess") + + assert connections.find("U0ADA") == "countess" + + +def test_disconnect_forgets_the_user(tmp_path): + connections = Connections(tmp_path / "acme.db") + connections.connect("U0ADA", "ada") + + assert connections.disconnect("U0ADA") is True + assert connections.find("U0ADA") is None + + +def test_disconnect_keeps_other_users_connected(tmp_path): + connections = Connections(tmp_path / "acme.db") + connections.connect("U0ADA", "ada") + connections.connect("U0BOB", "bob") + + connections.disconnect("U0ADA") + + assert connections.find("U0BOB") == "bob" + + +def test_disconnecting_an_unknown_user_changes_nothing(tmp_path): + connections = Connections(tmp_path / "acme.db") + + assert connections.disconnect("U0ADA") is False + + +def test_connections_survive_a_restart(tmp_path): + Connections(tmp_path / "acme.db").connect("U0ADA", "ada") + + assert Connections(tmp_path / "acme.db").find("U0ADA") == "ada" diff --git a/standardized-auth/tests/test_sign_in.py b/standardized-auth/tests/test_sign_in.py new file mode 100644 index 0000000..b9e70f9 --- /dev/null +++ b/standardized-auth/tests/test_sign_in.py @@ -0,0 +1,88 @@ +from collections.abc import Iterator +from threading import Thread +from unittest.mock import MagicMock +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import urlopen + +import pytest + +from src.sign_in import SignInServer +from src.state import issue +from tests.helpers import called_methods + + +@pytest.fixture +def base_url(client) -> Iterator[str]: + server = SignInServer(0, client) + Thread(target=server.serve_forever, daemon=True).start() + yield f"http://localhost:{server.server_port}" + server.shutdown() + server.server_close() + + +def get(url: str) -> str: + with urlopen(url) as response: + return response.read().decode() + + +def post(url: str, form: dict) -> str: + with urlopen(url, data=urlencode(form).encode()) as response: + return response.read().decode() + + +def status_of(error: pytest.ExceptionInfo[HTTPError]) -> int: + return error.value.code + + +def test_shows_the_sign_in_form(store, base_url): + page = get(f"{base_url}/acme/connect?state={issue('U0ADA')}") + + assert "Sign in to Acme" in page + assert f'value="{issue("U0ADA")}"' in page + + +def test_refuses_to_show_the_form_for_a_forged_state(store, base_url): + with pytest.raises(HTTPError) as error: + get(f"{base_url}/acme/connect?state=U0ADA.forged") + + assert status_of(error) == 400 + + +def test_signing_in_connects_and_reports_the_user(store, base_url, client: MagicMock): + page = post( + f"{base_url}/acme/connect", {"state": issue("U0ADA"), "username": "ada"} + ) + + assert "Connected to Acme" in page + assert store.find("U0ADA") == "ada" + assert called_methods(client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + + +def test_signing_in_with_a_forged_state_connects_nobody( + store, base_url, client: MagicMock +): + with pytest.raises(HTTPError) as error: + post(f"{base_url}/acme/connect", {"state": "U0ADA.forged", "username": "ada"}) + + assert status_of(error) == 400 + assert store.find("U0ADA") is None + assert called_methods(client) == [] + + +def test_signing_in_requires_a_username(store, base_url, client: MagicMock): + with pytest.raises(HTTPError) as error: + post(f"{base_url}/acme/connect", {"state": issue("U0ADA"), "username": " "}) + + assert status_of(error) == 400 + assert store.find("U0ADA") is None + + +def test_other_paths_are_not_found(base_url): + with pytest.raises(HTTPError) as error: + get(f"{base_url}/slack/events") + + assert status_of(error) == 404 diff --git a/standardized-auth/tests/test_state.py b/standardized-auth/tests/test_state.py new file mode 100644 index 0000000..4443926 --- /dev/null +++ b/standardized-auth/tests/test_state.py @@ -0,0 +1,19 @@ +from src.state import issue, redeem + + +def test_redeems_the_user_it_was_issued_for(): + assert redeem(issue("U0ADA")) == "U0ADA" + + +def test_rejects_a_state_issued_for_someone_else(): + _, _, signature = issue("U0ADA").partition(".") + + assert redeem(f"U0EVE.{signature}") is None + + +def test_rejects_a_state_without_a_signature(): + assert redeem("U0ADA") is None + + +def test_rejects_a_non_ascii_signature(): + assert redeem("U0ADA.é") is None From 2793bff86eab0741b46715fb6cca78739a1963ed Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 14:11:03 -0400 Subject: [PATCH 15/24] refactor(standardized-auth): reuse the sdk state store and trim the sample Sign-in state now comes from StatelessOAuthStateStore, signed with a per-user secret so the user_id tag can't be swapped, and expires after 5 minutes as the SDK docs recommend. Connections persist with shelve instead of a hand-written SQLite table, the sign-in GET only renders the form (the POST checks the state), and the Bolt dispatch tests that duplicated the listener tests are gone. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/src/connections.py | 34 ++---- standardized-auth/src/sign_in.py | 5 +- standardized-auth/src/state.py | 20 ++-- standardized-auth/tests/test_acme.py | 7 +- standardized-auth/tests/test_app.py | 110 -------------------- standardized-auth/tests/test_connections.py | 7 +- standardized-auth/tests/test_sign_in.py | 13 +-- standardized-auth/tests/test_state.py | 14 ++- 9 files changed, 49 insertions(+), 163 deletions(-) delete mode 100644 standardized-auth/tests/test_app.py diff --git a/standardized-auth/README.md b/standardized-auth/README.md index f975c43..9227b16 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -8,7 +8,7 @@ Read the docs: https://docs.slack.dev/authentication/standardized-auth Acme runs over Socket Mode and keeps every connection in a local SQLite database ([`src/connections.py`](./src/connections.py)): a user is connected when it holds their Acme username. 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 Acme's sign-in page ([`src/sign_in.py`](./src/sign_in.py)) with a signed `state` ([`src/state.py`](./src/state.py)) naming the Slack user. Signing in saves the connection, reports it with `apps.user.connection.requestAuth`, and refreshes App Home. +- **Connect** opens Acme's sign-in page ([`src/sign_in.py`](./src/sign_in.py)) with a `state` ([`src/state.py`](./src/state.py)) from the SDK's `StatelessOAuthStateStore`, signed for the Slack user and valid for 5 minutes. Signing in saves the connection, reports it with `apps.user.connection.requestAuth`, and refreshes App Home. - **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the connection and reports the user as disconnected. - **`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. - **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. diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py index 751dbf9..4638503 100644 --- a/standardized-auth/src/connections.py +++ b/standardized-auth/src/connections.py @@ -1,5 +1,4 @@ -import sqlite3 -from contextlib import closing +import shelve from pathlib import Path @@ -8,33 +7,20 @@ def __init__(self, path: Path): self.path = path def connect(self, user_id: str, username: str) -> None: - self._write( - "INSERT OR REPLACE INTO connections (user_id, username) VALUES (?, ?)", - (user_id, username), - ) + with self._open() as db: + db[user_id] = username - def disconnect(self, user_id: str) -> bool: - return self._write("DELETE FROM connections WHERE user_id = ?", (user_id,)) > 0 + def disconnect(self, user_id: str) -> None: + with self._open() as db: + db.pop(user_id, None) def find(self, user_id: str) -> str | None: - with closing(self._open()) as db: - row = db.execute( - "SELECT username FROM connections WHERE user_id = ?", (user_id,) - ).fetchone() - return None if row is None else row[0] + with self._open() as db: + return db.get(user_id) - def _write(self, sql: str, parameters: tuple[str, ...]) -> int: - with closing(self._open()) as db, db: - return db.execute(sql, parameters).rowcount - - def _open(self) -> sqlite3.Connection: + def _open(self) -> shelve.Shelf[str]: self.path.parent.mkdir(parents=True, exist_ok=True) - db = sqlite3.connect(self.path) - db.execute( - "CREATE TABLE IF NOT EXISTS connections" - " (user_id TEXT PRIMARY KEY, username TEXT NOT NULL)" - ) - return db + return shelve.open(str(self.path)) connections = Connections(Path("data/acme.db")) diff --git a/standardized-auth/src/sign_in.py b/standardized-auth/src/sign_in.py index 932eba3..431e5d8 100644 --- a/standardized-auth/src/sign_in.py +++ b/standardized-auth/src/sign_in.py @@ -43,10 +43,7 @@ def do_GET(self): url = urlsplit(self.path) if url.path != CONNECT_PATH: return self.send_error(HTTPStatus.NOT_FOUND) - state = field(parse_qs(url.query), "state") - if redeem(state) is None: - return self.send_error(HTTPStatus.BAD_REQUEST) - self.send_page(sign_in_page(state)) + self.send_page(sign_in_page(field(parse_qs(url.query), "state"))) def do_POST(self): if self.path != CONNECT_PATH: diff --git a/standardized-auth/src/state.py b/standardized-auth/src/state.py index 3fb25e3..464678a 100644 --- a/standardized-auth/src/state.py +++ b/standardized-auth/src/state.py @@ -1,20 +1,20 @@ -import hmac import secrets -from hashlib import sha256 -key = secrets.token_bytes(32) +from slack_sdk.oauth.state_store import StatelessOAuthStateStore +key = secrets.token_hex(32) -def sign(user_id: str) -> str: - return hmac.new(key, user_id.encode(), sha256).hexdigest() + +def store_for(user_id: str) -> StatelessOAuthStateStore: + return StatelessOAuthStateStore( + expiration_seconds=300, signing_secret=key + user_id + ) def issue(user_id: str) -> str: - return f"{user_id}.{sign(user_id)}" + return f"{store_for(user_id).issue()}.{user_id}" def redeem(state: str) -> str | None: - user_id, _, signature = state.partition(".") - if hmac.compare_digest(signature.encode(), sign(user_id).encode()): - return user_id - return None + token, _, user_id = state.rpartition(".") + return user_id if store_for(user_id).consume(token) else None diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py index dc9eb58..d77d4d3 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -1,8 +1,13 @@ +import time + from src.acme import home_view, provider from src.state import issue -def test_disconnected_provider_offers_the_acme_sign_in(store): +def test_disconnected_provider_offers_the_acme_sign_in(store, monkeypatch): + now = time.time() + monkeypatch.setattr(time, "time", lambda: now) + assert provider("U0ADA") == { "value": "acme", "name": {"type": "plain_text", "text": "Acme account"}, diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py deleted file mode 100644 index 9077bce..0000000 --- a/standardized-auth/tests/test_app.py +++ /dev/null @@ -1,110 +0,0 @@ -import time -from collections.abc import Callable, Iterator -from contextlib import contextmanager -from unittest.mock import MagicMock, patch - -import pytest -from slack_bolt import App, BoltRequest -from slack_sdk import WebClient - -from src.bolt_app import app as bolt_app - - -@pytest.fixture -def app(store) -> App: - store.connect("U0ADA", "ada") - return bolt_app - - -def test_connect_clicks_are_acknowledged(app): - response = app.dispatch(block_action("auth_provider_connect_acme")) - - assert response.status == 200 - - -def test_clicks_for_other_providers_are_not_handled(app): - 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.views_publish.called) - - assert response.status == 200 - assert store.find("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" - ) - - -@contextmanager -def slack_api() -> Iterator[MagicMock]: - methods = [ - "apps_user_connection_requestAuth", - "apps_user_connection_update", - "views_publish", - ] - api = MagicMock() - with 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 event(body: dict) -> BoltRequest: - envelope = { - "type": "event_callback", - "api_app_id": "A0001", - "team_id": "T0001", - "event": body, - "event_id": "Ev0001", - "event_time": 1700000000, - } - return BoltRequest(body=envelope, mode="socket_mode") - - -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", - } - ], - } - return BoltRequest(body=payload, mode="socket_mode") diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py index 5caa7c9..7539d87 100644 --- a/standardized-auth/tests/test_connections.py +++ b/standardized-auth/tests/test_connections.py @@ -22,7 +22,8 @@ def test_disconnect_forgets_the_user(tmp_path): connections = Connections(tmp_path / "acme.db") connections.connect("U0ADA", "ada") - assert connections.disconnect("U0ADA") is True + connections.disconnect("U0ADA") + assert connections.find("U0ADA") is None @@ -39,7 +40,9 @@ def test_disconnect_keeps_other_users_connected(tmp_path): def test_disconnecting_an_unknown_user_changes_nothing(tmp_path): connections = Connections(tmp_path / "acme.db") - assert connections.disconnect("U0ADA") is False + connections.disconnect("U0ADA") + + assert connections.find("U0ADA") is None def test_connections_survive_a_restart(tmp_path): diff --git a/standardized-auth/tests/test_sign_in.py b/standardized-auth/tests/test_sign_in.py index b9e70f9..e124bce 100644 --- a/standardized-auth/tests/test_sign_in.py +++ b/standardized-auth/tests/test_sign_in.py @@ -36,17 +36,12 @@ def status_of(error: pytest.ExceptionInfo[HTTPError]) -> int: def test_shows_the_sign_in_form(store, base_url): - page = get(f"{base_url}/acme/connect?state={issue('U0ADA')}") + state = issue("U0ADA") - assert "Sign in to Acme" in page - assert f'value="{issue("U0ADA")}"' in page - - -def test_refuses_to_show_the_form_for_a_forged_state(store, base_url): - with pytest.raises(HTTPError) as error: - get(f"{base_url}/acme/connect?state=U0ADA.forged") + page = get(f"{base_url}/acme/connect?state={state}") - assert status_of(error) == 400 + assert "Sign in to Acme" in page + assert f'value="{state}"' in page def test_signing_in_connects_and_reports_the_user(store, base_url, client: MagicMock): diff --git a/standardized-auth/tests/test_state.py b/standardized-auth/tests/test_state.py index 4443926..f21e65b 100644 --- a/standardized-auth/tests/test_state.py +++ b/standardized-auth/tests/test_state.py @@ -1,3 +1,5 @@ +import time + from src.state import issue, redeem @@ -6,9 +8,9 @@ def test_redeems_the_user_it_was_issued_for(): def test_rejects_a_state_issued_for_someone_else(): - _, _, signature = issue("U0ADA").partition(".") + token, _, _ = issue("U0ADA").rpartition(".") - assert redeem(f"U0EVE.{signature}") is None + assert redeem(f"{token}.U0EVE") is None def test_rejects_a_state_without_a_signature(): @@ -17,3 +19,11 @@ def test_rejects_a_state_without_a_signature(): def test_rejects_a_non_ascii_signature(): assert redeem("U0ADA.é") is None + + +def test_rejects_an_expired_state(monkeypatch): + state = issue("U0ADA") + later = time.time() + 301 + monkeypatch.setattr(time, "time", lambda: later) + + assert redeem(state) is None From c436555cba0fb6ddd738c69e52ae0336b61362aa Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 15:14:54 -0400 Subject: [PATCH 16/24] refactor(standardized-auth): connect in slack without a sign-in page Clicking Connect now connects the Slack user who clicked it, with their user ID as the connected identity. Slack signs that click, so the sample no longer needs a browser sign-in page, a state, or a public URL; everything runs over Socket Mode. The manifest keeps example.com in auth_hosts because Slack requires at least one host when user connections are enabled. The README explains how to bring back auth_url with a signed state for a real login, and notes that the Slack CLI must be newer than v4.10.0 to send user_connections. Tests use setup_method/teardown_method in place of fixtures. Co-Authored-By: Claude --- standardized-auth/.env.example | 2 - standardized-auth/.gitignore | 1 - standardized-auth/README.md | 10 +-- standardized-auth/app.py | 4 - standardized-auth/manifest.json | 2 +- standardized-auth/src/acme.py | 16 ++-- standardized-auth/src/connections.py | 10 +-- .../src/listeners/actions/connect.py | 5 +- .../user_connection_status_requested.py | 2 +- standardized-auth/src/sign_in.py | 69 --------------- standardized-auth/src/state.py | 20 ----- standardized-auth/tests/conftest.py | 18 +--- standardized-auth/tests/helpers.py | 14 ++++ .../tests/listeners/actions/test_connect.py | 29 ++++++- .../listeners/actions/test_disconnect.py | 50 ++++++----- .../listeners/actions/test_user_connection.py | 74 ++++++++++------- .../listeners/events/test_app_home_opened.py | 30 +++++-- .../test_user_connection_status_requested.py | 39 ++++++--- standardized-auth/tests/test_acme.py | 54 ++++++------ standardized-auth/tests/test_connections.py | 63 +++++++------- standardized-auth/tests/test_sign_in.py | 83 ------------------- standardized-auth/tests/test_state.py | 29 ------- 22 files changed, 239 insertions(+), 385 deletions(-) delete mode 100644 standardized-auth/.env.example delete mode 100644 standardized-auth/src/sign_in.py delete mode 100644 standardized-auth/src/state.py delete mode 100644 standardized-auth/tests/test_sign_in.py delete mode 100644 standardized-auth/tests/test_state.py diff --git a/standardized-auth/.env.example b/standardized-auth/.env.example deleted file mode 100644 index a1705fd..0000000 --- a/standardized-auth/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -BASE_URL=https://1234-56-78-90-0.ngrok-free.app -PORT=3000 diff --git a/standardized-auth/.gitignore b/standardized-auth/.gitignore index d58fa68..40e52b4 100644 --- a/standardized-auth/.gitignore +++ b/standardized-auth/.gitignore @@ -1,5 +1,4 @@ __pycache__ .venv .env* -!.env.example data/ diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 9227b16..d593366 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,22 +6,20 @@ 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 SQLite database ([`src/connections.py`](./src/connections.py)): a user is connected when it holds their Acme username. 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. +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. 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 Acme's sign-in page ([`src/sign_in.py`](./src/sign_in.py)) with a `state` ([`src/state.py`](./src/state.py)) from the SDK's `StatelessOAuthStateStore`, signed for the Slack user and valid for 5 minutes. Signing in saves the connection, reports it with `apps.user.connection.requestAuth`, and refreshes App Home. +- **Connect** ([`actions/connect.py`](./src/listeners/actions/connect.py)) 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. - **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the connection and reports the user as disconnected. - **`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. - **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. -The sign-in page stands in for your service's real login. Swap it for your own OAuth or SSO flow, and keep the signed `state` so a connection always lands on the Slack user who clicked **Connect**. +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 -Events arrive over Socket Mode, so only the sign-in page needs a public HTTPS URL. +Everything arrives over Socket Mode, so the app needs no public URL. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest. ```sh -$ ngrok http 3000 # Replace 1234-56-78-90-0.ngrok-free.app in manifest.json -$ slack env init # Set BASE_URL to your ngrok URL $ slack run ``` diff --git a/standardized-auth/app.py b/standardized-auth/app.py index 8ae9854..9f280db 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,12 +1,8 @@ import os -from threading import Thread from slack_bolt.adapter.socket_mode import SocketModeHandler from src.bolt_app import app -from src.sign_in import SignInServer if __name__ == "__main__": - sign_in = SignInServer(int(os.environ.get("PORT", "3000")), app.client) - Thread(target=sign_in.serve_forever, daemon=True).start() SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json index 920f039..4aa1a15 100644 --- a/standardized-auth/manifest.json +++ b/standardized-auth/manifest.json @@ -15,7 +15,7 @@ }, "user_connections": { "is_enabled": true, - "auth_hosts": ["1234-56-78-90-0.ngrok-free.app"] + "auth_hosts": ["example.com"] } }, "oauth_config": { diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index b312ca4..39da30f 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -1,4 +1,3 @@ -import os from typing import Any from slack_sdk import WebClient @@ -6,9 +5,6 @@ from slack_sdk.models.views import View from src.connections import connections -from src.state import issue - -CONNECT_PATH = "/acme/connect" GREETING = ( "*Welcome to Acme!* :rocket:\n" @@ -22,18 +18,16 @@ def plain_text(text: str) -> dict[str, str]: def provider(user_id: str) -> dict[str, Any]: - username = connections.find(user_id) 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']}{CONNECT_PATH}?state={issue(user_id)}", - "is_connected": username is not None, + "is_connected": connections.is_connected(user_id), } - if username is not None: - acme["connected_identity"] = plain_text(username) + if acme["is_connected"]: + acme["connected_identity"] = plain_text(user_id) return acme @@ -53,8 +47,8 @@ def report(client: WebClient, user_id: str): client.views_publish(user_id=user_id, view=home_view(acme)) -def connect(client: WebClient, user_id: str, username: str): - connections.connect(user_id, username) +def connect(client: WebClient, user_id: str): + connections.connect(user_id) report(client, user_id) diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py index 4638503..88f64e7 100644 --- a/standardized-auth/src/connections.py +++ b/standardized-auth/src/connections.py @@ -6,19 +6,19 @@ class Connections: def __init__(self, path: Path): self.path = path - def connect(self, user_id: str, username: str) -> None: + def connect(self, user_id: str) -> None: with self._open() as db: - db[user_id] = username + db[user_id] = True def disconnect(self, user_id: str) -> None: with self._open() as db: db.pop(user_id, None) - def find(self, user_id: str) -> str | None: + def is_connected(self, user_id: str) -> bool: with self._open() as db: - return db.get(user_id) + return user_id in db - def _open(self) -> shelve.Shelf[str]: + def _open(self) -> shelve.Shelf[bool]: self.path.parent.mkdir(parents=True, exist_ok=True) return shelve.open(str(self.path)) diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py index d7ed2a7..1f7d001 100644 --- a/standardized-auth/src/listeners/actions/connect.py +++ b/standardized-auth/src/listeners/actions/connect.py @@ -1,8 +1,11 @@ from slack_bolt import Ack +from slack_sdk import WebClient +from src.acme import connect from src.bolt_app import app @app.action("auth_provider_connect_acme") -def connect_callback(ack: Ack): +def connect_callback(ack: Ack, body: dict, client: WebClient): ack() + connect(client, body["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 index 54d494e..09bba30 100644 --- a/standardized-auth/src/listeners/events/user_connection_status_requested.py +++ b/standardized-auth/src/listeners/events/user_connection_status_requested.py @@ -6,6 +6,6 @@ @app.event("user_connection_status_requested") def user_connection_status_requested_callback(client: WebClient, event: dict): - connected = connections.find(event["user"]) is not None + connected = connections.is_connected(event["user"]) status = "connected" if connected else "disconnected" client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/src/sign_in.py b/standardized-auth/src/sign_in.py deleted file mode 100644 index 431e5d8..0000000 --- a/standardized-auth/src/sign_in.py +++ /dev/null @@ -1,69 +0,0 @@ -from html import escape -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import cast -from urllib.parse import parse_qs, urlsplit - -from slack_sdk import WebClient - -from src.acme import CONNECT_PATH, connect -from src.state import redeem - -CONNECTED_PAGE = """ -Connected to Acme -

Connected to Acme

-

You can close this tab and return to Slack.

-""" - - -def sign_in_page(state: str) -> str: - return f""" -Sign in to Acme -

Sign in to Acme

-
- - - -
-""" - - -def field(fields: dict[str, list[str]], name: str) -> str: - return fields.get(name, [""])[0] - - -class SignInServer(ThreadingHTTPServer): - def __init__(self, port: int, client: WebClient): - super().__init__(("", port), SignInHandler) - self.client = client - - -class SignInHandler(BaseHTTPRequestHandler): - def do_GET(self): - url = urlsplit(self.path) - if url.path != CONNECT_PATH: - return self.send_error(HTTPStatus.NOT_FOUND) - self.send_page(sign_in_page(field(parse_qs(url.query), "state"))) - - def do_POST(self): - if self.path != CONNECT_PATH: - return self.send_error(HTTPStatus.NOT_FOUND) - form = self.read_form() - user_id = redeem(field(form, "state")) - username = field(form, "username").strip() - if user_id is None or not username: - return self.send_error(HTTPStatus.BAD_REQUEST) - connect(cast(SignInServer, self.server).client, user_id, username) - self.send_page(CONNECTED_PAGE) - - def read_form(self) -> dict[str, list[str]]: - length = int(self.headers.get("Content-Length", 0)) - return parse_qs(self.rfile.read(length).decode()) - - def send_page(self, html: str): - body = html.encode() - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) diff --git a/standardized-auth/src/state.py b/standardized-auth/src/state.py deleted file mode 100644 index 464678a..0000000 --- a/standardized-auth/src/state.py +++ /dev/null @@ -1,20 +0,0 @@ -import secrets - -from slack_sdk.oauth.state_store import StatelessOAuthStateStore - -key = secrets.token_hex(32) - - -def store_for(user_id: str) -> StatelessOAuthStateStore: - return StatelessOAuthStateStore( - expiration_seconds=300, signing_secret=key + user_id - ) - - -def issue(user_id: str) -> str: - return f"{store_for(user_id).issue()}.{user_id}" - - -def redeem(state: str) -> str | None: - token, _, user_id = state.rpartition(".") - return user_id if store_for(user_id).consume(token) else None diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py index 0cc4ca7..5ba180e 100644 --- a/standardized-auth/tests/conftest.py +++ b/standardized-auth/tests/conftest.py @@ -1,14 +1,9 @@ import os -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest -from slack_sdk import WebClient from slack_sdk.web import SlackResponse -from src.connections import Connections, connections - os.environ["SLACK_BOT_TOKEN"] = "xoxb-test-bot" -os.environ["BASE_URL"] = "https://acme.example" patch( "slack_sdk.web.client.WebClient.auth_test", @@ -22,14 +17,3 @@ status_code=200, ), ).start() - - -@pytest.fixture -def store(tmp_path, monkeypatch) -> Connections: - monkeypatch.setattr(connections, "path", tmp_path / "acme.db") - return connections - - -@pytest.fixture -def client() -> MagicMock: - return MagicMock(spec=WebClient) diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py index 28ed7a3..28382c4 100644 --- a/standardized-auth/tests/helpers.py +++ b/standardized-auth/tests/helpers.py @@ -1,6 +1,20 @@ +from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock from src.acme import home_view, provider +from src.connections import connections + + +class TempStore: + def __init__(self): + self.saved_path = connections.path + self.dir = TemporaryDirectory() + connections.path = Path(self.dir.name) / "acme.db" + + def close(self): + connections.path = self.saved_path + self.dir.cleanup() def published_view(client: MagicMock) -> dict: diff --git a/standardized-auth/tests/listeners/actions/test_connect.py b/standardized-auth/tests/listeners/actions/test_connect.py index 3924b2d..474081f 100644 --- a/standardized-auth/tests/listeners/actions/test_connect.py +++ b/standardized-auth/tests/listeners/actions/test_connect.py @@ -1,11 +1,32 @@ from unittest.mock import MagicMock +from slack_sdk import WebClient + +from src.acme import provider +from src.connections import connections from src.listeners.actions.connect import connect_callback +from tests.helpers import TempStore, called_methods + + +class TestConnect: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + def teardown_method(self): + self.store.close() -def test_leaves_the_sign_in_to_the_auth_url(): - ack = MagicMock() + def test_connects_and_reports_the_user(self): + ack = MagicMock() - connect_callback(ack=ack) + connect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) - ack.assert_called_once_with() + ack.assert_called_once_with() + assert connections.is_connected("U0ADA") + assert called_methods(self.client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) diff --git a/standardized-auth/tests/listeners/actions/test_disconnect.py b/standardized-auth/tests/listeners/actions/test_disconnect.py index d72cd63..d047951 100644 --- a/standardized-auth/tests/listeners/actions/test_disconnect.py +++ b/standardized-auth/tests/listeners/actions/test_disconnect.py @@ -1,30 +1,42 @@ from unittest.mock import MagicMock +from slack_sdk import WebClient + from src.acme import provider +from src.connections import connections from src.listeners.actions.disconnect import disconnect_callback -from tests.helpers import called_methods +from tests.helpers import TempStore, called_methods + +class TestDisconnect: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) -def test_forgets_then_reports_the_user(store, client): - store.connect("U0ADA", "ada") - ack = MagicMock() + def teardown_method(self): + self.store.close() - disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=client) + def test_forgets_then_reports_the_user(self): + connections.connect("U0ADA") + ack = MagicMock() - ack.assert_called_once_with() - assert store.find("U0ADA") is None - assert called_methods(client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) + disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) + ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") + assert called_methods(self.client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) -def test_disconnecting_without_an_account_still_reports_disconnected(store, client): - disconnect_callback(ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=client) + def test_disconnecting_without_an_account_still_reports_disconnected(self): + disconnect_callback( + ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=self.client + ) - client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) diff --git a/standardized-auth/tests/listeners/actions/test_user_connection.py b/standardized-auth/tests/listeners/actions/test_user_connection.py index a1f6e6a..59f8376 100644 --- a/standardized-auth/tests/listeners/actions/test_user_connection.py +++ b/standardized-auth/tests/listeners/actions/test_user_connection.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock +from slack_sdk import WebClient + from src.acme import provider +from src.connections import connections from src.listeners.actions.user_connection import user_connection_callback -from tests.helpers import called_methods +from tests.helpers import TempStore, called_methods def user_connection(subtype: str) -> dict: @@ -14,45 +17,56 @@ def user_connection(subtype: str) -> dict: } -def test_connect_opens_the_auth_modal(store, client): - ack = MagicMock() - - user_connection_callback(ack=ack, body=user_connection("connect"), client=client) +class TestUserConnection: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) - ack.assert_called_once_with() - client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") - ) - assert provider("U0ADA")["is_connected"] is False + def teardown_method(self): + self.store.close() + def test_connect_opens_the_auth_modal(self): + ack = MagicMock() -def test_connect_shows_an_existing_connection(store, client): - store.connect("U0ADA", "ada") + user_connection_callback( + ack=ack, body=user_connection("connect"), client=self.client + ) - user_connection_callback( - ack=MagicMock(), body=user_connection("connect"), client=client - ) + ack.assert_called_once_with() + self.client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=provider("U0ADA") + ) + assert provider("U0ADA")["is_connected"] is False - client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") - ) - assert provider("U0ADA")["is_connected"] is True + def test_connect_shows_an_existing_connection(self): + connections.connect("U0ADA") + user_connection_callback( + ack=MagicMock(), body=user_connection("connect"), client=self.client + ) -def test_disconnect_forgets_the_account(store, client): - store.connect("U0ADA", "ada") - ack = MagicMock() + self.client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=provider("U0ADA") + ) + assert provider("U0ADA")["is_connected"] is True - user_connection_callback(ack=ack, body=user_connection("disconnect"), client=client) + def test_disconnect_forgets_the_account(self): + connections.connect("U0ADA") + ack = MagicMock() - ack.assert_called_once_with() - assert store.find("U0ADA") is None + user_connection_callback( + ack=ack, body=user_connection("disconnect"), client=self.client + ) + ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") -def test_an_unknown_subtype_is_only_acknowledged(store, client): - ack = MagicMock() + def test_an_unknown_subtype_is_only_acknowledged(self): + ack = MagicMock() - user_connection_callback(ack=ack, body=user_connection("reconnect"), client=client) + user_connection_callback( + ack=ack, body=user_connection("reconnect"), client=self.client + ) - ack.assert_called_once_with() - assert called_methods(client) == [] + ack.assert_called_once_with() + assert called_methods(self.client) == [] diff --git a/standardized-auth/tests/listeners/events/test_app_home_opened.py b/standardized-auth/tests/listeners/events/test_app_home_opened.py index c22a1e7..f7702ae 100644 --- a/standardized-auth/tests/listeners/events/test_app_home_opened.py +++ b/standardized-auth/tests/listeners/events/test_app_home_opened.py @@ -1,17 +1,29 @@ +from unittest.mock import MagicMock + +from slack_sdk import WebClient + +from src.connections import connections from src.listeners.events.app_home_opened import app_home_opened_callback -from tests.helpers import expected_home, published_view +from tests.helpers import TempStore, expected_home, published_view + +class TestAppHomeOpened: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) -def test_shows_a_connected_account(store, client): - store.connect("U0ADA", "ada") + def teardown_method(self): + self.store.close() - app_home_opened_callback(client=client, event={"user": "U0ADA"}) + def test_shows_a_connected_account(self): + connections.connect("U0ADA") - assert client.views_publish.call_args.kwargs["user_id"] == "U0ADA" - assert published_view(client) == expected_home("U0ADA") + app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) + assert self.client.views_publish.call_args.kwargs["user_id"] == "U0ADA" + assert published_view(self.client) == expected_home("U0ADA") -def test_offers_to_connect_users_without_an_account(store, client): - app_home_opened_callback(client=client, event={"user": "U0ADA"}) + def test_offers_to_connect_users_without_an_account(self): + app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) - assert published_view(client) == expected_home("U0ADA") + assert published_view(self.client) == expected_home("U0ADA") 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 index 3cdd532..8d04e01 100644 --- a/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py +++ b/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py @@ -1,21 +1,38 @@ +from unittest.mock import MagicMock + +from slack_sdk import WebClient + +from src.connections import connections from src.listeners.events.user_connection_status_requested import ( user_connection_status_requested_callback, ) +from tests.helpers import TempStore + +class TestUserConnectionStatusRequested: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) -def test_reports_connected_users(store, client): - store.connect("U0ADA", "ada") + def teardown_method(self): + self.store.close() - user_connection_status_requested_callback(client=client, event={"user": "U0ADA"}) + def test_reports_connected_users(self): + connections.connect("U0ADA") - client.apps_user_connection_update.assert_called_once_with( - user_id="U0ADA", status="connected" - ) + 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_reports_users_without_an_account_as_disconnected(store, client): - user_connection_status_requested_callback(client=client, event={"user": "U0ADA"}) + def test_reports_users_without_an_account_as_disconnected(self): + user_connection_status_requested_callback( + client=self.client, event={"user": "U0ADA"} + ) - client.apps_user_connection_update.assert_called_once_with( - user_id="U0ADA", status="disconnected" - ) + self.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 index d77d4d3..e9deb85 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -1,38 +1,38 @@ -import time - from src.acme import home_view, provider -from src.state import issue - +from src.connections import connections +from tests.helpers import TempStore -def test_disconnected_provider_offers_the_acme_sign_in(store, monkeypatch): - now = time.time() - monkeypatch.setattr(time, "time", lambda: now) - 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.", - }, - "auth_url": f"https://acme.example/acme/connect?state={issue('U0ADA')}", - "is_connected": False, - } +class TestAcme: + def setup_method(self): + self.store = TempStore() + def teardown_method(self): + self.store.close() -def test_connected_provider_names_the_acme_account(store): - store.connect("U0ADA", "ada") + 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, + } - acme = provider("U0ADA") + def test_connected_provider_names_the_slack_user(self): + connections.connect("U0ADA") - assert acme["is_connected"] is True - assert acme["connected_identity"] == {"type": "plain_text", "text": "ada"} + 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(store): - acme = provider("U0ADA") + def test_home_view_renders_the_provider_as_an_auth_provider_block(self): + acme = provider("U0ADA") - view = home_view(acme).to_dict() + view = home_view(acme).to_dict() - assert view["type"] == "home" - assert view["blocks"][-1] == {"type": "auth_provider", **acme} + assert view["type"] == "home" + assert view["blocks"][-1] == {"type": "auth_provider", **acme} diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py index 7539d87..11f61b4 100644 --- a/standardized-auth/tests/test_connections.py +++ b/standardized-auth/tests/test_connections.py @@ -1,51 +1,44 @@ -from src.connections import Connections - - -def test_remembers_a_connected_username(tmp_path): - connections = Connections(tmp_path / "acme.db") - - connections.connect("U0ADA", "ada") - - assert connections.find("U0ADA") == "ada" +from pathlib import Path +from tempfile import TemporaryDirectory - -def test_reconnecting_replaces_the_username(tmp_path): - connections = Connections(tmp_path / "acme.db") - connections.connect("U0ADA", "ada") - - connections.connect("U0ADA", "countess") - - assert connections.find("U0ADA") == "countess" +from src.connections import Connections -def test_disconnect_forgets_the_user(tmp_path): - connections = Connections(tmp_path / "acme.db") - connections.connect("U0ADA", "ada") +class TestConnections: + def setup_method(self): + self.dir = TemporaryDirectory() + self.path = Path(self.dir.name) / "acme.db" + self.connections = Connections(self.path) - connections.disconnect("U0ADA") + def teardown_method(self): + self.dir.cleanup() - assert connections.find("U0ADA") is None + def test_remembers_a_connected_user(self): + self.connections.connect("U0ADA") + assert self.connections.is_connected("U0ADA") -def test_disconnect_keeps_other_users_connected(tmp_path): - connections = Connections(tmp_path / "acme.db") - connections.connect("U0ADA", "ada") - connections.connect("U0BOB", "bob") + def test_disconnect_forgets_the_user(self): + self.connections.connect("U0ADA") - connections.disconnect("U0ADA") + self.connections.disconnect("U0ADA") - assert connections.find("U0BOB") == "bob" + assert not self.connections.is_connected("U0ADA") + def test_disconnect_keeps_other_users_connected(self): + self.connections.connect("U0ADA") + self.connections.connect("U0BOB") -def test_disconnecting_an_unknown_user_changes_nothing(tmp_path): - connections = Connections(tmp_path / "acme.db") + self.connections.disconnect("U0ADA") - connections.disconnect("U0ADA") + assert self.connections.is_connected("U0BOB") - assert connections.find("U0ADA") is None + def test_disconnecting_an_unknown_user_changes_nothing(self): + self.connections.disconnect("U0ADA") + assert not self.connections.is_connected("U0ADA") -def test_connections_survive_a_restart(tmp_path): - Connections(tmp_path / "acme.db").connect("U0ADA", "ada") + def test_connections_survive_a_restart(self): + self.connections.connect("U0ADA") - assert Connections(tmp_path / "acme.db").find("U0ADA") == "ada" + assert Connections(self.path).is_connected("U0ADA") diff --git a/standardized-auth/tests/test_sign_in.py b/standardized-auth/tests/test_sign_in.py deleted file mode 100644 index e124bce..0000000 --- a/standardized-auth/tests/test_sign_in.py +++ /dev/null @@ -1,83 +0,0 @@ -from collections.abc import Iterator -from threading import Thread -from unittest.mock import MagicMock -from urllib.error import HTTPError -from urllib.parse import urlencode -from urllib.request import urlopen - -import pytest - -from src.sign_in import SignInServer -from src.state import issue -from tests.helpers import called_methods - - -@pytest.fixture -def base_url(client) -> Iterator[str]: - server = SignInServer(0, client) - Thread(target=server.serve_forever, daemon=True).start() - yield f"http://localhost:{server.server_port}" - server.shutdown() - server.server_close() - - -def get(url: str) -> str: - with urlopen(url) as response: - return response.read().decode() - - -def post(url: str, form: dict) -> str: - with urlopen(url, data=urlencode(form).encode()) as response: - return response.read().decode() - - -def status_of(error: pytest.ExceptionInfo[HTTPError]) -> int: - return error.value.code - - -def test_shows_the_sign_in_form(store, base_url): - state = issue("U0ADA") - - page = get(f"{base_url}/acme/connect?state={state}") - - assert "Sign in to Acme" in page - assert f'value="{state}"' in page - - -def test_signing_in_connects_and_reports_the_user(store, base_url, client: MagicMock): - page = post( - f"{base_url}/acme/connect", {"state": issue("U0ADA"), "username": "ada"} - ) - - assert "Connected to Acme" in page - assert store.find("U0ADA") == "ada" - assert called_methods(client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - - -def test_signing_in_with_a_forged_state_connects_nobody( - store, base_url, client: MagicMock -): - with pytest.raises(HTTPError) as error: - post(f"{base_url}/acme/connect", {"state": "U0ADA.forged", "username": "ada"}) - - assert status_of(error) == 400 - assert store.find("U0ADA") is None - assert called_methods(client) == [] - - -def test_signing_in_requires_a_username(store, base_url, client: MagicMock): - with pytest.raises(HTTPError) as error: - post(f"{base_url}/acme/connect", {"state": issue("U0ADA"), "username": " "}) - - assert status_of(error) == 400 - assert store.find("U0ADA") is None - - -def test_other_paths_are_not_found(base_url): - with pytest.raises(HTTPError) as error: - get(f"{base_url}/slack/events") - - assert status_of(error) == 404 diff --git a/standardized-auth/tests/test_state.py b/standardized-auth/tests/test_state.py deleted file mode 100644 index f21e65b..0000000 --- a/standardized-auth/tests/test_state.py +++ /dev/null @@ -1,29 +0,0 @@ -import time - -from src.state import issue, redeem - - -def test_redeems_the_user_it_was_issued_for(): - assert redeem(issue("U0ADA")) == "U0ADA" - - -def test_rejects_a_state_issued_for_someone_else(): - token, _, _ = issue("U0ADA").rpartition(".") - - assert redeem(f"{token}.U0EVE") is None - - -def test_rejects_a_state_without_a_signature(): - assert redeem("U0ADA") is None - - -def test_rejects_a_non_ascii_signature(): - assert redeem("U0ADA.é") is None - - -def test_rejects_an_expired_state(monkeypatch): - state = issue("U0ADA") - later = time.time() + 301 - monkeypatch.setattr(time, "time", lambda: later) - - assert redeem(state) is None From e9a652f8a4df9c4c7739418f4d0db4f49c5b1593 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 16:40:28 -0400 Subject: [PATCH 17/24] refactor(standardized-auth): move every listener into one file The app and its five listeners now live in src/listeners.py, which drops the walk_packages loader in src/bolt_app.py and the listeners package tree. Listener tests move into tests/test_listeners.py. Co-Authored-By: Claude --- standardized-auth/README.md | 10 +- standardized-auth/app.py | 2 +- standardized-auth/src/bolt_app.py | 17 -- standardized-auth/src/listeners.py | 45 +++++ standardized-auth/src/listeners/__init__.py | 0 .../src/listeners/actions/__init__.py | 0 .../src/listeners/actions/connect.py | 11 -- .../src/listeners/actions/disconnect.py | 11 -- .../src/listeners/actions/user_connection.py | 17 -- .../src/listeners/events/__init__.py | 0 .../src/listeners/events/app_home_opened.py | 9 - .../user_connection_status_requested.py | 11 -- standardized-auth/tests/listeners/__init__.py | 0 .../tests/listeners/actions/__init__.py | 0 .../tests/listeners/actions/test_connect.py | 32 --- .../listeners/actions/test_disconnect.py | 42 ---- .../listeners/actions/test_user_connection.py | 72 ------- .../tests/listeners/events/__init__.py | 0 .../listeners/events/test_app_home_opened.py | 29 --- .../test_user_connection_status_requested.py | 38 ---- standardized-auth/tests/test_listeners.py | 187 ++++++++++++++++++ 21 files changed, 238 insertions(+), 295 deletions(-) delete mode 100644 standardized-auth/src/bolt_app.py create mode 100644 standardized-auth/src/listeners.py delete mode 100644 standardized-auth/src/listeners/__init__.py delete mode 100644 standardized-auth/src/listeners/actions/__init__.py delete mode 100644 standardized-auth/src/listeners/actions/connect.py delete mode 100644 standardized-auth/src/listeners/actions/disconnect.py delete mode 100644 standardized-auth/src/listeners/actions/user_connection.py delete mode 100644 standardized-auth/src/listeners/events/__init__.py delete mode 100644 standardized-auth/src/listeners/events/app_home_opened.py delete mode 100644 standardized-auth/src/listeners/events/user_connection_status_requested.py delete mode 100644 standardized-auth/tests/listeners/__init__.py delete mode 100644 standardized-auth/tests/listeners/actions/__init__.py delete mode 100644 standardized-auth/tests/listeners/actions/test_connect.py delete mode 100644 standardized-auth/tests/listeners/actions/test_disconnect.py delete mode 100644 standardized-auth/tests/listeners/actions/test_user_connection.py delete mode 100644 standardized-auth/tests/listeners/events/__init__.py delete mode 100644 standardized-auth/tests/listeners/events/test_app_home_opened.py delete mode 100644 standardized-auth/tests/listeners/events/test_user_connection_status_requested.py create mode 100644 standardized-auth/tests/test_listeners.py diff --git a/standardized-auth/README.md b/standardized-auth/README.md index d593366..1cab7b1 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,12 +6,12 @@ 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. 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. +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. Shared helpers live in [`src/acme.py`](./src/acme.py), and every listener lives in [`src/listeners.py`](./src/listeners.py). -- **Connect** ([`actions/connect.py`](./src/listeners/actions/connect.py)) 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. -- **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the connection and reports the user as disconnected. -- **`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. -- **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. +- **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. +- **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**. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index 9f280db..aa8513c 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -2,7 +2,7 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler -from src.bolt_app import app +from src.listeners import app if __name__ == "__main__": SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/standardized-auth/src/bolt_app.py b/standardized-auth/src/bolt_app.py deleted file mode 100644 index a902a60..0000000 --- a/standardized-auth/src/bolt_app.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -from importlib import import_module -from pkgutil import walk_packages - -from slack_bolt import App - -from src import listeners - -app = App(token=os.environ["SLACK_BOT_TOKEN"]) - - -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.py b/standardized-auth/src/listeners.py new file mode 100644 index 0000000..398b1cb --- /dev/null +++ b/standardized-auth/src/listeners.py @@ -0,0 +1,45 @@ +import os + +from slack_bolt import Ack, App +from slack_sdk import WebClient + +from src.acme import connect, disconnect, home_view, provider +from src.connections import connections + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.action("auth_provider_connect_acme") +def connect_callback(ack: Ack, body: dict, client: WebClient): + ack() + connect(client, body["user"]["id"]) + + +@app.action("auth_provider_disconnect_acme") +def disconnect_callback(ack: Ack, body: dict, client: WebClient): + ack() + disconnect(client, body["user"]["id"]) + + +@app.action("user_connection") +def user_connection_callback(ack: Ack, body: dict, client: WebClient): + ack() + user_id = body["user"]["id"] + if body["subtype"] == "connect": + client.apps_user_connection_requestAuth( + trigger_id=body["trigger_id"], primary_provider=provider(user_id) + ) + elif body["subtype"] == "disconnect": + disconnect(client, user_id) + + +@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.event("user_connection_status_requested") +def user_connection_status_requested_callback(client: WebClient, event: dict): + connected = connections.is_connected(event["user"]) + status = "connected" if connected else "disconnected" + client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/src/listeners/__init__.py b/standardized-auth/src/listeners/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/src/listeners/actions/__init__.py b/standardized-auth/src/listeners/actions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/src/listeners/actions/connect.py b/standardized-auth/src/listeners/actions/connect.py deleted file mode 100644 index 1f7d001..0000000 --- a/standardized-auth/src/listeners/actions/connect.py +++ /dev/null @@ -1,11 +0,0 @@ -from slack_bolt import Ack -from slack_sdk import WebClient - -from src.acme import connect -from src.bolt_app import app - - -@app.action("auth_provider_connect_acme") -def connect_callback(ack: Ack, body: dict, client: WebClient): - ack() - connect(client, body["user"]["id"]) diff --git a/standardized-auth/src/listeners/actions/disconnect.py b/standardized-auth/src/listeners/actions/disconnect.py deleted file mode 100644 index 6a2d12f..0000000 --- a/standardized-auth/src/listeners/actions/disconnect.py +++ /dev/null @@ -1,11 +0,0 @@ -from slack_bolt import Ack -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): - ack() - disconnect(client, body["user"]["id"]) diff --git a/standardized-auth/src/listeners/actions/user_connection.py b/standardized-auth/src/listeners/actions/user_connection.py deleted file mode 100644 index 53aaa60..0000000 --- a/standardized-auth/src/listeners/actions/user_connection.py +++ /dev/null @@ -1,17 +0,0 @@ -from slack_bolt import Ack -from slack_sdk import WebClient - -from src.acme import disconnect, provider -from src.bolt_app import app - - -@app.action("user_connection") -def user_connection_callback(ack: Ack, body: dict, client: WebClient): - ack() - user_id = body["user"]["id"] - if body["subtype"] == "connect": - client.apps_user_connection_requestAuth( - trigger_id=body["trigger_id"], primary_provider=provider(user_id) - ) - elif body["subtype"] == "disconnect": - disconnect(client, user_id) diff --git a/standardized-auth/src/listeners/events/__init__.py b/standardized-auth/src/listeners/events/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/src/listeners/events/app_home_opened.py b/standardized-auth/src/listeners/events/app_home_opened.py deleted file mode 100644 index 6129574..0000000 --- a/standardized-auth/src/listeners/events/app_home_opened.py +++ /dev/null @@ -1,9 +0,0 @@ -from slack_sdk import WebClient - -from src.acme import home_view, provider -from src.bolt_app import app - - -@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"]))) diff --git a/standardized-auth/src/listeners/events/user_connection_status_requested.py b/standardized-auth/src/listeners/events/user_connection_status_requested.py deleted file mode 100644 index 09bba30..0000000 --- a/standardized-auth/src/listeners/events/user_connection_status_requested.py +++ /dev/null @@ -1,11 +0,0 @@ -from slack_sdk import WebClient - -from src.bolt_app import app -from src.connections import connections - - -@app.event("user_connection_status_requested") -def user_connection_status_requested_callback(client: WebClient, event: dict): - connected = connections.is_connected(event["user"]) - status = "connected" if connected else "disconnected" - client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/tests/listeners/__init__.py b/standardized-auth/tests/listeners/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/tests/listeners/actions/__init__.py b/standardized-auth/tests/listeners/actions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/tests/listeners/actions/test_connect.py b/standardized-auth/tests/listeners/actions/test_connect.py deleted file mode 100644 index 474081f..0000000 --- a/standardized-auth/tests/listeners/actions/test_connect.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import MagicMock - -from slack_sdk import WebClient - -from src.acme import provider -from src.connections import connections -from src.listeners.actions.connect import connect_callback -from tests.helpers import TempStore, called_methods - - -class TestConnect: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_connects_and_reports_the_user(self): - ack = MagicMock() - - connect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) - - ack.assert_called_once_with() - assert connections.is_connected("U0ADA") - assert called_methods(self.client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) diff --git a/standardized-auth/tests/listeners/actions/test_disconnect.py b/standardized-auth/tests/listeners/actions/test_disconnect.py deleted file mode 100644 index d047951..0000000 --- a/standardized-auth/tests/listeners/actions/test_disconnect.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest.mock import MagicMock - -from slack_sdk import WebClient - -from src.acme import provider -from src.connections import connections -from src.listeners.actions.disconnect import disconnect_callback -from tests.helpers import TempStore, called_methods - - -class TestDisconnect: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_forgets_then_reports_the_user(self): - connections.connect("U0ADA") - ack = MagicMock() - - disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) - - ack.assert_called_once_with() - assert not connections.is_connected("U0ADA") - assert called_methods(self.client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) - - def test_disconnecting_without_an_account_still_reports_disconnected(self): - disconnect_callback( - ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=self.client - ) - - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) diff --git a/standardized-auth/tests/listeners/actions/test_user_connection.py b/standardized-auth/tests/listeners/actions/test_user_connection.py deleted file mode 100644 index 59f8376..0000000 --- a/standardized-auth/tests/listeners/actions/test_user_connection.py +++ /dev/null @@ -1,72 +0,0 @@ -from unittest.mock import MagicMock - -from slack_sdk import WebClient - -from src.acme import provider -from src.connections import connections -from src.listeners.actions.user_connection import user_connection_callback -from tests.helpers import TempStore, called_methods - - -def user_connection(subtype: str) -> dict: - return { - "type": "user_connection", - "subtype": subtype, - "user": {"id": "U0ADA", "team_id": "T0001"}, - "trigger_id": "123.456.abc", - } - - -class TestUserConnection: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_connect_opens_the_auth_modal(self): - ack = MagicMock() - - user_connection_callback( - ack=ack, body=user_connection("connect"), client=self.client - ) - - ack.assert_called_once_with() - self.client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") - ) - assert provider("U0ADA")["is_connected"] is False - - def test_connect_shows_an_existing_connection(self): - connections.connect("U0ADA") - - user_connection_callback( - ack=MagicMock(), body=user_connection("connect"), client=self.client - ) - - self.client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") - ) - assert provider("U0ADA")["is_connected"] is True - - def test_disconnect_forgets_the_account(self): - connections.connect("U0ADA") - ack = MagicMock() - - user_connection_callback( - ack=ack, body=user_connection("disconnect"), client=self.client - ) - - ack.assert_called_once_with() - assert not connections.is_connected("U0ADA") - - def test_an_unknown_subtype_is_only_acknowledged(self): - ack = MagicMock() - - user_connection_callback( - ack=ack, body=user_connection("reconnect"), client=self.client - ) - - ack.assert_called_once_with() - assert called_methods(self.client) == [] diff --git a/standardized-auth/tests/listeners/events/__init__.py b/standardized-auth/tests/listeners/events/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standardized-auth/tests/listeners/events/test_app_home_opened.py b/standardized-auth/tests/listeners/events/test_app_home_opened.py deleted file mode 100644 index f7702ae..0000000 --- a/standardized-auth/tests/listeners/events/test_app_home_opened.py +++ /dev/null @@ -1,29 +0,0 @@ -from unittest.mock import MagicMock - -from slack_sdk import WebClient - -from src.connections import connections -from src.listeners.events.app_home_opened import app_home_opened_callback -from tests.helpers import TempStore, expected_home, published_view - - -class TestAppHomeOpened: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_shows_a_connected_account(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 published_view(self.client) == expected_home("U0ADA") - - def test_offers_to_connect_users_without_an_account(self): - app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) - - assert published_view(self.client) == expected_home("U0ADA") 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 deleted file mode 100644 index 8d04e01..0000000 --- a/standardized-auth/tests/listeners/events/test_user_connection_status_requested.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import MagicMock - -from slack_sdk import WebClient - -from src.connections import connections -from src.listeners.events.user_connection_status_requested import ( - user_connection_status_requested_callback, -) -from tests.helpers import TempStore - - -class TestUserConnectionStatusRequested: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_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_reports_users_without_an_account_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_listeners.py b/standardized-auth/tests/test_listeners.py new file mode 100644 index 0000000..b4e74ca --- /dev/null +++ b/standardized-auth/tests/test_listeners.py @@ -0,0 +1,187 @@ +from unittest.mock import MagicMock + +from slack_sdk import WebClient + +from src.acme import provider +from src.connections import connections +from src.listeners import ( + app_home_opened_callback, + connect_callback, + disconnect_callback, + user_connection_callback, + user_connection_status_requested_callback, +) +from tests.helpers import TempStore, called_methods, expected_home, published_view + + +def user_connection(subtype: str) -> dict: + return { + "type": "user_connection", + "subtype": subtype, + "user": {"id": "U0ADA", "team_id": "T0001"}, + "trigger_id": "123.456.abc", + } + + +class TestConnect: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.store.close() + + def test_connects_and_reports_the_user(self): + ack = MagicMock() + + connect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) + + ack.assert_called_once_with() + assert connections.is_connected("U0ADA") + assert called_methods(self.client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) + + +class TestDisconnect: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.store.close() + + def test_forgets_then_reports_the_user(self): + connections.connect("U0ADA") + ack = MagicMock() + + disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) + + ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") + assert called_methods(self.client) == [ + "apps_user_connection_requestAuth", + "views_publish", + ] + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) + + def test_disconnecting_without_an_account_still_reports_disconnected(self): + disconnect_callback( + ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=self.client + ) + + self.client.apps_user_connection_requestAuth.assert_called_once_with( + user_id="U0ADA", primary_provider=provider("U0ADA") + ) + + +class TestUserConnection: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.store.close() + + def test_connect_opens_the_auth_modal(self): + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("connect"), client=self.client + ) + + ack.assert_called_once_with() + self.client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=provider("U0ADA") + ) + assert provider("U0ADA")["is_connected"] is False + + def test_connect_shows_an_existing_connection(self): + connections.connect("U0ADA") + + user_connection_callback( + ack=MagicMock(), body=user_connection("connect"), client=self.client + ) + + self.client.apps_user_connection_requestAuth.assert_called_once_with( + trigger_id="123.456.abc", primary_provider=provider("U0ADA") + ) + assert provider("U0ADA")["is_connected"] is True + + def test_disconnect_forgets_the_account(self): + connections.connect("U0ADA") + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("disconnect"), client=self.client + ) + + ack.assert_called_once_with() + assert not connections.is_connected("U0ADA") + + def test_an_unknown_subtype_is_only_acknowledged(self): + ack = MagicMock() + + user_connection_callback( + ack=ack, body=user_connection("reconnect"), client=self.client + ) + + ack.assert_called_once_with() + assert called_methods(self.client) == [] + + +class TestAppHomeOpened: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.store.close() + + def test_shows_a_connected_account(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 published_view(self.client) == expected_home("U0ADA") + + def test_offers_to_connect_users_without_an_account(self): + app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) + + assert published_view(self.client) == expected_home("U0ADA") + + +class TestUserConnectionStatusRequested: + def setup_method(self): + self.store = TempStore() + self.client = MagicMock(spec=WebClient) + + def teardown_method(self): + self.store.close() + + def test_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_reports_users_without_an_account_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" + ) From 921444545b01f22a5a029ee75d48b7c4110f0662 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 16:41:25 -0400 Subject: [PATCH 18/24] refactor(standardized-auth): define the app and its listeners in app.py The App, its five listeners, and the Socket Mode entry point now share app.py, so src/listeners.py goes away. Listener tests follow as tests/test_app.py. Co-Authored-By: Claude --- standardized-auth/README.md | 2 +- standardized-auth/app.py | 44 +++++++++++++++++- standardized-auth/src/listeners.py | 45 ------------------- .../tests/{test_listeners.py => test_app.py} | 6 +-- 4 files changed, 47 insertions(+), 50 deletions(-) delete mode 100644 standardized-auth/src/listeners.py rename standardized-auth/tests/{test_listeners.py => test_app.py} (99%) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 1cab7b1..48ea906 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,7 +6,7 @@ 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. Shared helpers live in [`src/acme.py`](./src/acme.py), and every listener lives in [`src/listeners.py`](./src/listeners.py). +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. Shared helpers live in [`src/acme.py`](./src/acme.py), and the app and every listener live 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. - **Disconnect** forgets the connection and reports the user as disconnected. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index aa8513c..a625ca4 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -1,8 +1,50 @@ import os +from slack_bolt import Ack, App from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +from src.acme import connect, disconnect, home_view, provider +from src.connections import connections + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.action("auth_provider_connect_acme") +def connect_callback(ack: Ack, body: dict, client: WebClient): + ack() + connect(client, body["user"]["id"]) + + +@app.action("auth_provider_disconnect_acme") +def disconnect_callback(ack: Ack, body: dict, client: WebClient): + ack() + disconnect(client, body["user"]["id"]) + + +@app.action("user_connection") +def user_connection_callback(ack: Ack, body: dict, client: WebClient): + ack() + user_id = body["user"]["id"] + if body["subtype"] == "connect": + client.apps_user_connection_requestAuth( + trigger_id=body["trigger_id"], primary_provider=provider(user_id) + ) + elif body["subtype"] == "disconnect": + disconnect(client, user_id) + + +@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.event("user_connection_status_requested") +def user_connection_status_requested_callback(client: WebClient, event: dict): + connected = connections.is_connected(event["user"]) + status = "connected" if connected else "disconnected" + client.apps_user_connection_update(user_id=event["user"], status=status) -from src.listeners import app if __name__ == "__main__": SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/standardized-auth/src/listeners.py b/standardized-auth/src/listeners.py deleted file mode 100644 index 398b1cb..0000000 --- a/standardized-auth/src/listeners.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -from slack_bolt import Ack, App -from slack_sdk import WebClient - -from src.acme import connect, disconnect, home_view, provider -from src.connections import connections - -app = App(token=os.environ["SLACK_BOT_TOKEN"]) - - -@app.action("auth_provider_connect_acme") -def connect_callback(ack: Ack, body: dict, client: WebClient): - ack() - connect(client, body["user"]["id"]) - - -@app.action("auth_provider_disconnect_acme") -def disconnect_callback(ack: Ack, body: dict, client: WebClient): - ack() - disconnect(client, body["user"]["id"]) - - -@app.action("user_connection") -def user_connection_callback(ack: Ack, body: dict, client: WebClient): - ack() - user_id = body["user"]["id"] - if body["subtype"] == "connect": - client.apps_user_connection_requestAuth( - trigger_id=body["trigger_id"], primary_provider=provider(user_id) - ) - elif body["subtype"] == "disconnect": - disconnect(client, user_id) - - -@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.event("user_connection_status_requested") -def user_connection_status_requested_callback(client: WebClient, event: dict): - connected = connections.is_connected(event["user"]) - status = "connected" if connected else "disconnected" - client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/tests/test_listeners.py b/standardized-auth/tests/test_app.py similarity index 99% rename from standardized-auth/tests/test_listeners.py rename to standardized-auth/tests/test_app.py index b4e74ca..232cc62 100644 --- a/standardized-auth/tests/test_listeners.py +++ b/standardized-auth/tests/test_app.py @@ -2,15 +2,15 @@ from slack_sdk import WebClient -from src.acme import provider -from src.connections import connections -from src.listeners import ( +from app import ( app_home_opened_callback, connect_callback, disconnect_callback, user_connection_callback, user_connection_status_requested_callback, ) +from src.acme import provider +from src.connections import connections from tests.helpers import TempStore, called_methods, expected_home, published_view From 44dfd6351ff42c34149c606f1cd8f683f9e35885 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 13:42:40 -0400 Subject: [PATCH 19/24] fix(standardized-auth): handle the user_connection event and fixed auth_provider action ids Connect and Manage on the app profile now send the Events API user_connection event instead of an interaction payload, so the listener moves to app.event and reads subtype, trigger_id, and the bare user ID from the event. Only connect is answered, since Disconnect arrives as a block action. The auth_provider buttons now use the fixed auth_provider_connect and auth_provider_disconnect action ids, and the manifest subscribes to user_connection. Co-Authored-By: Claude --- standardized-auth/README.md | 3 ++- standardized-auth/app.py | 16 ++++++--------- standardized-auth/manifest.json | 6 +++++- standardized-auth/tests/test_app.py | 30 ++++++----------------------- 4 files changed, 19 insertions(+), 36 deletions(-) diff --git a/standardized-auth/README.md b/standardized-auth/README.md index 48ea906..b6deb03 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -9,6 +9,7 @@ Read the docs: https://docs.slack.dev/authentication/standardized-auth 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. Shared helpers live in [`src/acme.py`](./src/acme.py), and the app and every listener live 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. @@ -17,7 +18,7 @@ Connecting in one click stands in for your service's real login. Slack requires ## Setup -Everything arrives over Socket Mode, so the app needs no public URL. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest. +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 diff --git a/standardized-auth/app.py b/standardized-auth/app.py index a625ca4..e24cad0 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -10,28 +10,24 @@ app = App(token=os.environ["SLACK_BOT_TOKEN"]) -@app.action("auth_provider_connect_acme") +@app.action("auth_provider_connect") def connect_callback(ack: Ack, body: dict, client: WebClient): ack() connect(client, body["user"]["id"]) -@app.action("auth_provider_disconnect_acme") +@app.action("auth_provider_disconnect") def disconnect_callback(ack: Ack, body: dict, client: WebClient): ack() disconnect(client, body["user"]["id"]) -@app.action("user_connection") -def user_connection_callback(ack: Ack, body: dict, client: WebClient): - ack() - user_id = body["user"]["id"] - if body["subtype"] == "connect": +@app.event("user_connection") +def user_connection_callback(client: WebClient, event: dict): + if event["subtype"] == "connect": client.apps_user_connection_requestAuth( - trigger_id=body["trigger_id"], primary_provider=provider(user_id) + trigger_id=event["trigger_id"], primary_provider=provider(event["user"]) ) - elif body["subtype"] == "disconnect": - disconnect(client, user_id) @app.event("app_home_opened") diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json index 4aa1a15..10eaab7 100644 --- a/standardized-auth/manifest.json +++ b/standardized-auth/manifest.json @@ -25,7 +25,11 @@ }, "settings": { "event_subscriptions": { - "bot_events": ["app_home_opened", "user_connection_status_requested"] + "bot_events": [ + "app_home_opened", + "user_connection", + "user_connection_status_requested" + ] }, "interactivity": { "is_enabled": true diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index 232cc62..ba72321 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -18,7 +18,7 @@ def user_connection(subtype: str) -> dict: return { "type": "user_connection", "subtype": subtype, - "user": {"id": "U0ADA", "team_id": "T0001"}, + "user": "U0ADA", "trigger_id": "123.456.abc", } @@ -90,13 +90,8 @@ def teardown_method(self): self.store.close() def test_connect_opens_the_auth_modal(self): - ack = MagicMock() + user_connection_callback(client=self.client, event=user_connection("connect")) - user_connection_callback( - ack=ack, body=user_connection("connect"), client=self.client - ) - - ack.assert_called_once_with() self.client.apps_user_connection_requestAuth.assert_called_once_with( trigger_id="123.456.abc", primary_provider=provider("U0ADA") ) @@ -105,34 +100,21 @@ def test_connect_opens_the_auth_modal(self): def test_connect_shows_an_existing_connection(self): connections.connect("U0ADA") - user_connection_callback( - ack=MagicMock(), body=user_connection("connect"), client=self.client - ) + user_connection_callback(client=self.client, event=user_connection("connect")) self.client.apps_user_connection_requestAuth.assert_called_once_with( trigger_id="123.456.abc", primary_provider=provider("U0ADA") ) assert provider("U0ADA")["is_connected"] is True - def test_disconnect_forgets_the_account(self): + def test_a_disconnect_event_is_ignored(self): connections.connect("U0ADA") - ack = MagicMock() - - user_connection_callback( - ack=ack, body=user_connection("disconnect"), client=self.client - ) - - ack.assert_called_once_with() - assert not connections.is_connected("U0ADA") - - def test_an_unknown_subtype_is_only_acknowledged(self): - ack = MagicMock() user_connection_callback( - ack=ack, body=user_connection("reconnect"), client=self.client + client=self.client, event=user_connection("disconnect") ) - ack.assert_called_once_with() + assert connections.is_connected("U0ADA") assert called_methods(self.client) == [] From 8e90abe48d96a582f0ae596e5bc12215363fa861 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 14:24:06 -0400 Subject: [PATCH 20/24] refactor(standardized-auth): create the connections folder once The data folder is now made when Connections is built, so each method opens the shelf directly and the _open helper goes away. Co-Authored-By: Claude --- standardized-auth/src/connections.py | 11 ++++------- standardized-auth/tests/test_connections.py | 7 +++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py index 88f64e7..fac496d 100644 --- a/standardized-auth/src/connections.py +++ b/standardized-auth/src/connections.py @@ -5,22 +5,19 @@ class Connections: def __init__(self, path: Path): self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) def connect(self, user_id: str) -> None: - with self._open() as db: + with shelve.open(str(self.path)) as db: db[user_id] = True def disconnect(self, user_id: str) -> None: - with self._open() as db: + with shelve.open(str(self.path)) as db: db.pop(user_id, None) def is_connected(self, user_id: str) -> bool: - with self._open() as db: + with shelve.open(str(self.path)) as db: return user_id in db - def _open(self) -> shelve.Shelf[bool]: - self.path.parent.mkdir(parents=True, exist_ok=True) - return shelve.open(str(self.path)) - connections = Connections(Path("data/acme.db")) diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py index 11f61b4..ecfacad 100644 --- a/standardized-auth/tests/test_connections.py +++ b/standardized-auth/tests/test_connections.py @@ -38,6 +38,13 @@ def test_disconnecting_an_unknown_user_changes_nothing(self): assert not self.connections.is_connected("U0ADA") + def test_creates_the_folder_for_its_file(self): + path = Path(self.dir.name) / "data" / "acme.db" + + Connections(path) + + assert path.parent.is_dir() + def test_connections_survive_a_restart(self): self.connections.connect("U0ADA") From ebfc9a0aced61a88798c51191413ced1e8dad3db Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 15:00:54 -0400 Subject: [PATCH 21/24] refactor(standardized-auth): show every slack call in its listener The connect and disconnect listeners now make their requestAuth and views_publish calls in app.py, so a reader sees each Slack API call where it happens. Connections become three shelve functions instead of a class with a module singleton and a data folder. src/acme.py keeps only the provider object, written as literal plain_text JSON, and the App Home view. Tests share one setup per file, patch the store's filename, stub auth.test without a fake response, and assert what each listener sends rather than rebuilding it with the code under test. Co-Authored-By: Claude --- standardized-auth/.gitignore | 2 +- standardized-auth/README.md | 2 +- standardized-auth/app.py | 29 ++-- standardized-auth/src/acme.py | 48 ++---- standardized-auth/src/connections.py | 25 ++-- standardized-auth/tests/conftest.py | 15 +- standardized-auth/tests/helpers.py | 26 +--- standardized-auth/tests/test_acme.py | 8 +- standardized-auth/tests/test_app.py | 155 ++++++-------------- standardized-auth/tests/test_connections.py | 46 ++---- 10 files changed, 110 insertions(+), 246 deletions(-) diff --git a/standardized-auth/.gitignore b/standardized-auth/.gitignore index 40e52b4..a8c44dc 100644 --- a/standardized-auth/.gitignore +++ b/standardized-auth/.gitignore @@ -1,4 +1,4 @@ __pycache__ .venv .env* -data/ +acme.db* diff --git a/standardized-auth/README.md b/standardized-auth/README.md index b6deb03..ad87614 100644 --- a/standardized-auth/README.md +++ b/standardized-auth/README.md @@ -6,7 +6,7 @@ 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. Shared helpers live in [`src/acme.py`](./src/acme.py), and the app and every listener live in [`app.py`](./app.py). +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`. diff --git a/standardized-auth/app.py b/standardized-auth/app.py index e24cad0..baa8f9a 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -4,22 +4,35 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient -from src.acme import connect, disconnect, home_view, provider -from src.connections import connections +from src import connections +from src.acme import 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() - connect(client, body["user"]["id"]) + 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() - disconnect(client, body["user"]["id"]) + 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") @@ -30,15 +43,9 @@ def user_connection_callback(client: WebClient, event: dict): ) -@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.event("user_connection_status_requested") def user_connection_status_requested_callback(client: WebClient, event: dict): - connected = connections.is_connected(event["user"]) - status = "connected" if connected else "disconnected" + status = "connected" if connections.is_connected(event["user"]) else "disconnected" client.apps_user_connection_update(user_id=event["user"], status=status) diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index 39da30f..a13a968 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -1,33 +1,23 @@ from typing import Any -from slack_sdk import WebClient -from slack_sdk.models.blocks import AuthProviderBlock, MarkdownTextObject, SectionBlock +from slack_sdk.models.blocks import AuthProviderBlock, SectionBlock from slack_sdk.models.views import View -from src.connections import connections - -GREETING = ( - "*Welcome to Acme!* :rocket:\n" - "Anvils ship same day, gravity permitting. " - "Please keep giant magnets away from your laptop." -) - - -def plain_text(text: str) -> dict[str, str]: - return {"type": "plain_text", "text": text} +from src import connections def provider(user_id: str) -> 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." - ), + "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"] = plain_text(user_id) + acme["connected_identity"] = {"type": "plain_text", "text": user_id} return acme @@ -35,23 +25,11 @@ def home_view(acme: dict[str, Any]) -> View: return View( type="home", blocks=[ - SectionBlock(text=MarkdownTextObject(text=GREETING)), + SectionBlock( + text="*Welcome to Acme!* :rocket:\n" + "Anvils ship same day, gravity permitting. " + "Please keep giant magnets away from your laptop." + ), AuthProviderBlock(**acme), ], ) - - -def report(client: WebClient, user_id: str): - 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)) - - -def connect(client: WebClient, user_id: str): - connections.connect(user_id) - report(client, user_id) - - -def disconnect(client: WebClient, user_id: str): - connections.disconnect(user_id) - report(client, user_id) diff --git a/standardized-auth/src/connections.py b/standardized-auth/src/connections.py index fac496d..906b379 100644 --- a/standardized-auth/src/connections.py +++ b/standardized-auth/src/connections.py @@ -1,23 +1,18 @@ import shelve -from pathlib import Path +FILENAME = "acme.db" -class Connections: - def __init__(self, path: Path): - self.path = path - self.path.parent.mkdir(parents=True, exist_ok=True) - def connect(self, user_id: str) -> None: - with shelve.open(str(self.path)) as db: - db[user_id] = True +def connect(user_id: str) -> None: + with shelve.open(FILENAME) as db: + db[user_id] = True - def disconnect(self, user_id: str) -> None: - with shelve.open(str(self.path)) as db: - db.pop(user_id, None) - def is_connected(self, user_id: str) -> bool: - with shelve.open(str(self.path)) as db: - return user_id in db +def disconnect(user_id: str) -> None: + with shelve.open(FILENAME) as db: + db.pop(user_id, None) -connections = Connections(Path("data/acme.db")) +def is_connected(user_id: str) -> bool: + with shelve.open(FILENAME) as db: + return user_id in db diff --git a/standardized-auth/tests/conftest.py b/standardized-auth/tests/conftest.py index 5ba180e..545e193 100644 --- a/standardized-auth/tests/conftest.py +++ b/standardized-auth/tests/conftest.py @@ -1,19 +1,6 @@ import os from unittest.mock import patch -from slack_sdk.web import SlackResponse - os.environ["SLACK_BOT_TOKEN"] = "xoxb-test-bot" -patch( - "slack_sdk.web.client.WebClient.auth_test", - return_value=SlackResponse( - client=None, - http_verb="POST", - api_url="https://slack.com/api/auth.test", - req_args={}, - data={"ok": True, "team_id": "T0001", "bot_id": "B0001", "user_id": "U0BOT"}, - headers={}, - status_code=200, - ), -).start() +patch("slack_sdk.WebClient.auth_test").start() diff --git a/standardized-auth/tests/helpers.py b/standardized-auth/tests/helpers.py index 28382c4..6d964da 100644 --- a/standardized-auth/tests/helpers.py +++ b/standardized-auth/tests/helpers.py @@ -1,29 +1,13 @@ -from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import MagicMock +from unittest.mock import patch -from src.acme import home_view, provider -from src.connections import connections - -class TempStore: +class TempConnections: def __init__(self): - self.saved_path = connections.path self.dir = TemporaryDirectory() - connections.path = Path(self.dir.name) / "acme.db" + self.filename = patch("src.connections.FILENAME", f"{self.dir.name}/acme.db") + self.filename.start() def close(self): - connections.path = self.saved_path + self.filename.stop() self.dir.cleanup() - - -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_home(user_id: str) -> dict: - return home_view(provider(user_id)).to_dict() diff --git a/standardized-auth/tests/test_acme.py b/standardized-auth/tests/test_acme.py index e9deb85..6eeff7a 100644 --- a/standardized-auth/tests/test_acme.py +++ b/standardized-auth/tests/test_acme.py @@ -1,14 +1,14 @@ +from src import connections from src.acme import home_view, provider -from src.connections import connections -from tests.helpers import TempStore +from tests.helpers import TempConnections class TestAcme: def setup_method(self): - self.store = TempStore() + self.connections = TempConnections() def teardown_method(self): - self.store.close() + self.connections.close() def test_disconnected_provider_offers_to_connect(self): assert provider("U0ADA") == { diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index ba72321..6bbd303 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -9,146 +9,75 @@ user_connection_callback, user_connection_status_requested_callback, ) -from src.acme import provider -from src.connections import connections -from tests.helpers import TempStore, called_methods, expected_home, published_view +from src import connections +from tests.helpers import TempConnections -def user_connection(subtype: str) -> dict: - return { - "type": "user_connection", - "subtype": subtype, - "user": "U0ADA", - "trigger_id": "123.456.abc", - } - - -class TestConnect: +class TestApp: def setup_method(self): - self.store = TempStore() + self.connections = TempConnections() + self.ack = MagicMock() self.client = MagicMock(spec=WebClient) def teardown_method(self): - self.store.close() - - def test_connects_and_reports_the_user(self): - ack = MagicMock() - - connect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) + self.connections.close() - ack.assert_called_once_with() - assert connections.is_connected("U0ADA") - assert called_methods(self.client) == [ - "apps_user_connection_requestAuth", - "views_publish", + def sent_provider(self) -> dict: + return self.client.apps_user_connection_requestAuth.call_args.kwargs[ + "primary_provider" ] - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) + def home_block(self) -> dict: + view = self.client.views_publish.call_args.kwargs["view"].to_dict() + return view["blocks"][-1] -class TestDisconnect: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_forgets_then_reports_the_user(self): + def test_app_home_opened_shows_the_connection(self): connections.connect("U0ADA") - ack = MagicMock() - - disconnect_callback(ack=ack, body={"user": {"id": "U0ADA"}}, client=self.client) - - ack.assert_called_once_with() - assert not connections.is_connected("U0ADA") - assert called_methods(self.client) == [ - "apps_user_connection_requestAuth", - "views_publish", - ] - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) - def test_disconnecting_without_an_account_still_reports_disconnected(self): - disconnect_callback( - ack=MagicMock(), body={"user": {"id": "U0ADA"}}, client=self.client - ) - - self.client.apps_user_connection_requestAuth.assert_called_once_with( - user_id="U0ADA", primary_provider=provider("U0ADA") - ) - - -class TestUserConnection: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() + app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) - def test_connect_opens_the_auth_modal(self): - user_connection_callback(client=self.client, event=user_connection("connect")) + assert self.client.views_publish.call_args.kwargs["user_id"] == "U0ADA" + assert self.home_block()["is_connected"] is True - self.client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") + def test_connect_saves_and_shows_the_connection(self): + connect_callback( + ack=self.ack, body={"user": {"id": "U0ADA"}}, client=self.client ) - assert provider("U0ADA")["is_connected"] is False - - def test_connect_shows_an_existing_connection(self): - connections.connect("U0ADA") - user_connection_callback(client=self.client, event=user_connection("connect")) - - self.client.apps_user_connection_requestAuth.assert_called_once_with( - trigger_id="123.456.abc", primary_provider=provider("U0ADA") - ) - assert provider("U0ADA")["is_connected"] is True + 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_a_disconnect_event_is_ignored(self): + def test_disconnect_forgets_and_shows_the_connection(self): connections.connect("U0ADA") - user_connection_callback( - client=self.client, event=user_connection("disconnect") + disconnect_callback( + ack=self.ack, body={"user": {"id": "U0ADA"}}, client=self.client ) - assert connections.is_connected("U0ADA") - assert called_methods(self.client) == [] - - -class TestAppHomeOpened: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) - - def teardown_method(self): - self.store.close() - - def test_shows_a_connected_account(self): - connections.connect("U0ADA") - - app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) + 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 - assert self.client.views_publish.call_args.kwargs["user_id"] == "U0ADA" - assert published_view(self.client) == expected_home("U0ADA") + def test_user_connection_connect_opens_the_modal(self): + event = {"subtype": "connect", "user": "U0ADA", "trigger_id": "123.456.abc"} - def test_offers_to_connect_users_without_an_account(self): - app_home_opened_callback(client=self.client, event={"user": "U0ADA"}) + user_connection_callback(client=self.client, event=event) - assert published_view(self.client) == expected_home("U0ADA") + 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"} -class TestUserConnectionStatusRequested: - def setup_method(self): - self.store = TempStore() - self.client = MagicMock(spec=WebClient) + user_connection_callback(client=self.client, event=event) - def teardown_method(self): - self.store.close() + assert self.client.mock_calls == [] - def test_reports_connected_users(self): + def test_status_requested_reports_connected_users(self): connections.connect("U0ADA") user_connection_status_requested_callback( @@ -159,7 +88,7 @@ def test_reports_connected_users(self): user_id="U0ADA", status="connected" ) - def test_reports_users_without_an_account_as_disconnected(self): + def test_status_requested_reports_unknown_users_as_disconnected(self): user_connection_status_requested_callback( client=self.client, event={"user": "U0ADA"} ) diff --git a/standardized-auth/tests/test_connections.py b/standardized-auth/tests/test_connections.py index ecfacad..df625b3 100644 --- a/standardized-auth/tests/test_connections.py +++ b/standardized-auth/tests/test_connections.py @@ -1,51 +1,35 @@ -from pathlib import Path -from tempfile import TemporaryDirectory - -from src.connections import Connections +from src import connections +from tests.helpers import TempConnections class TestConnections: def setup_method(self): - self.dir = TemporaryDirectory() - self.path = Path(self.dir.name) / "acme.db" - self.connections = Connections(self.path) + self.connections = TempConnections() def teardown_method(self): - self.dir.cleanup() + self.connections.close() def test_remembers_a_connected_user(self): - self.connections.connect("U0ADA") + connections.connect("U0ADA") - assert self.connections.is_connected("U0ADA") + assert connections.is_connected("U0ADA") def test_disconnect_forgets_the_user(self): - self.connections.connect("U0ADA") + connections.connect("U0ADA") - self.connections.disconnect("U0ADA") + connections.disconnect("U0ADA") - assert not self.connections.is_connected("U0ADA") + assert not connections.is_connected("U0ADA") def test_disconnect_keeps_other_users_connected(self): - self.connections.connect("U0ADA") - self.connections.connect("U0BOB") + connections.connect("U0ADA") + connections.connect("U0BOB") - self.connections.disconnect("U0ADA") + connections.disconnect("U0ADA") - assert self.connections.is_connected("U0BOB") + assert connections.is_connected("U0BOB") def test_disconnecting_an_unknown_user_changes_nothing(self): - self.connections.disconnect("U0ADA") - - assert not self.connections.is_connected("U0ADA") - - def test_creates_the_folder_for_its_file(self): - path = Path(self.dir.name) / "data" / "acme.db" - - Connections(path) - - assert path.parent.is_dir() - - def test_connections_survive_a_restart(self): - self.connections.connect("U0ADA") + connections.disconnect("U0ADA") - assert Connections(self.path).is_connected("U0ADA") + assert not connections.is_connected("U0ADA") From ebf8d22fdcb30dc0aad13ed7143b90087a0cc4fc Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 15:24:06 -0400 Subject: [PATCH 22/24] feat(standardized-auth): only handle clicks for the acme provider Every auth_provider button sends the same auth_provider_connect or auth_provider_disconnect action_id, and the provider's value is the only field in the payload that names the provider. The connect and disconnect listeners now acknowledge every click but act only when that value matches the one Acme sets on its provider. Co-Authored-By: Claude --- standardized-auth/app.py | 6 +++++- standardized-auth/src/acme.py | 4 +++- standardized-auth/tests/test_app.py | 32 +++++++++++++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/standardized-auth/app.py b/standardized-auth/app.py index baa8f9a..444cdf5 100644 --- a/standardized-auth/app.py +++ b/standardized-auth/app.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from src import connections -from src.acme import home_view, provider +from src.acme import PROVIDER_VALUE, home_view, provider app = App(token=os.environ["SLACK_BOT_TOKEN"]) @@ -18,6 +18,8 @@ def app_home_opened_callback(client: WebClient, event: dict): @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) @@ -28,6 +30,8 @@ def connect_callback(ack: Ack, body: dict, client: WebClient): @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) diff --git a/standardized-auth/src/acme.py b/standardized-auth/src/acme.py index a13a968..3ebae54 100644 --- a/standardized-auth/src/acme.py +++ b/standardized-auth/src/acme.py @@ -5,10 +5,12 @@ from src import connections +PROVIDER_VALUE = "acme" + def provider(user_id: str) -> dict[str, Any]: acme: dict[str, Any] = { - "value": "acme", + "value": PROVIDER_VALUE, "name": {"type": "plain_text", "text": "Acme account"}, "description": { "type": "plain_text", diff --git a/standardized-auth/tests/test_app.py b/standardized-auth/tests/test_app.py index 6bbd303..6f77466 100644 --- a/standardized-auth/tests/test_app.py +++ b/standardized-auth/tests/test_app.py @@ -13,6 +13,10 @@ 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() @@ -40,9 +44,7 @@ def test_app_home_opened_shows_the_connection(self): assert self.home_block()["is_connected"] is True def test_connect_saves_and_shows_the_connection(self): - connect_callback( - ack=self.ack, body={"user": {"id": "U0ADA"}}, client=self.client - ) + connect_callback(ack=self.ack, body=button_click("acme"), client=self.client) self.ack.assert_called_once_with() assert connections.is_connected("U0ADA") @@ -52,15 +54,33 @@ def test_connect_saves_and_shows_the_connection(self): def test_disconnect_forgets_and_shows_the_connection(self): connections.connect("U0ADA") - disconnect_callback( - ack=self.ack, body={"user": {"id": "U0ADA"}}, client=self.client - ) + 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"} From 866e35a293d42bd6e1295802a09ee1f828371287 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 15:47:27 -0400 Subject: [PATCH 23/24] refactor(standardized-auth): trim the manifest to what the sample uses Drop background_color and messages_tab_enabled, which no other sample sets. Keep users:read: the sample calls no scoped method, but App Home requires at least one bot scope (requires_a_bot_scope). Ignore the mypy, pytest, and ruff caches like the other top-level samples. Co-Authored-By: Claude --- standardized-auth/.gitignore | 3 +++ standardized-auth/manifest.json | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/standardized-auth/.gitignore b/standardized-auth/.gitignore index a8c44dc..c7b3a74 100644 --- a/standardized-auth/.gitignore +++ b/standardized-auth/.gitignore @@ -1,4 +1,7 @@ __pycache__ +.mypy_cache +.pytest_cache +.ruff_cache .venv .env* acme.db* diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json index 10eaab7..6b30ae7 100644 --- a/standardized-auth/manifest.json +++ b/standardized-auth/manifest.json @@ -1,13 +1,11 @@ { "display_information": { "name": "Acme", - "description": "Rocket skates, anvils, and giant magnets for every plan", - "background_color": "#4A154B" + "description": "Rocket skates, anvils, and giant magnets for every plan" }, "features": { "app_home": { - "home_tab_enabled": true, - "messages_tab_enabled": false + "home_tab_enabled": true }, "bot_user": { "display_name": "Acme", From cdbca97ac90c5a65a56a514cf1e6952bd0475763 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 23 Oct 2026 11:22:12 -0400 Subject: [PATCH 24/24] build(standardized-auth): pin slack_sdk 3.46.0 ahead of its release The sample needs requestAuth and the auth_provider block, which ship in the next slack_sdk minor. CI fails to install until 3.46.0 is published. Co-Authored-By: Claude --- standardized-auth/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standardized-auth/requirements.txt b/standardized-auth/requirements.txt index 078a5cd..097065c 100644 --- a/standardized-auth/requirements.txt +++ b/standardized-auth/requirements.txt @@ -3,4 +3,4 @@ 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 +slack_sdk==3.46.0