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