From adce849c78083f057bc1e90b93b1f0df2203af0d Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 16 Oct 2026 11:28:02 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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)