diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 1abc01a03..e512812dd 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -19,6 +19,7 @@ ) from slack_bolt.error import BoltError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner +from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener from slack_bolt.listener.listener_completion_handler import ( @@ -48,6 +49,7 @@ warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, ) from slack_bolt.middleware import ( Middleware, @@ -250,6 +252,12 @@ def message_hello(message, say): self._oauth_flow.settings.installation_store_bot_only = app_bot_only self._authorize.bot_only = app_bot_only + self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None + if self._installation_store is not None: + self._tokens_revocation_listeners = TokenRevocationListeners( + self._installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1089,6 +1097,27 @@ def __call__(*args, **kwargs): return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: BoltRequest): diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index cf4385701..f11497071 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -7,6 +7,7 @@ from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) @@ -49,6 +50,7 @@ warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -275,6 +277,14 @@ async def message_hello(message, say): # async function ) self._async_authorize.bot_only = app_bot_only + self._async_tokens_revocation_listeners: Optional[ + AsyncTokenRevocationListeners + ] = None + if self._async_installation_store is not None: + self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners( + self._async_installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1151,6 +1161,27 @@ def __call__(*args, **kwargs): return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: AsyncBoltRequest): diff --git a/slack_bolt/listener/async_builtins.py b/slack_bolt/listener/async_builtins.py new file mode 100644 index 000000000..87f588dad --- /dev/null +++ b/slack_bolt/listener/async_builtins.py @@ -0,0 +1,37 @@ +from slack_bolt.context.async_context import AsyncBoltContext +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + + +class AsyncTokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: AsyncInstallationStore + + def __init__(self, installation_store: AsyncInstallationStore): + self.installation_store = installation_store + + async def handle_tokens_revoked_events( + self, event: dict, context: AsyncBoltContext + ) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + await self.installation_store.async_delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + await self.installation_store.async_delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None: + await self.installation_store.async_delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/listener/builtins.py b/slack_bolt/listener/builtins.py new file mode 100644 index 000000000..66784d182 --- /dev/null +++ b/slack_bolt/listener/builtins.py @@ -0,0 +1,35 @@ +from slack_sdk.oauth import InstallationStore + +from slack_bolt.context.context import BoltContext +from slack_sdk.oauth.installation_store.installation_store import InstallationStore + + +class TokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: InstallationStore + + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + self.installation_store.delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + self.installation_store.delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + def handle_app_uninstalled_events(self, context: BoltContext) -> None: + self.installation_store.delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 57d12dfab..7d16c2861 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -58,6 +58,13 @@ def error_message_event_type(event_type: str) -> str: ) +def error_installation_store_required_for_builtin_listeners() -> str: + return ( + "To use the event listeners for token revocation handling, " + "setting a valid `installation_store` to `App`/`AsyncApp` is required." + ) + + # ------------------------------- # Warning # ------------------------------- diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 799e38266..ebc4ce2dd 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -310,7 +310,7 @@ def assert_auth_test_count(test: TestCase, expected_count: int): error = None while retry_count < 3: try: - test.mock_received_requests["/auth.test"] == expected_count + test.mock_received_requests.get("/auth.test", 0) == expected_count break except Exception as e: error = e @@ -328,7 +328,7 @@ async def assert_auth_test_count_async(test: TestCase, expected_count: int): error = None while retry_count < 3: try: - test.mock_received_requests["/auth.test"] == expected_count + test.mock_received_requests.get("/auth.test", 0) == expected_count break except Exception as e: error = e diff --git a/tests/scenario_tests/test_events_token_revocations.py b/tests/scenario_tests/test_events_token_revocations.py new file mode 100644 index 000000000..5bd9bffe7 --- /dev/null +++ b/tests/scenario_tests/test_events_token_revocations.py @@ -0,0 +1,181 @@ +import json +from time import time, sleep +from typing import Optional + +import pytest as pytest +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from slack_bolt.error import BoltError +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(InstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + def delete_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> None: + self.delete_bot_called = True + + def delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + def delete_all(self, *, enterprise_id: Optional[str], team_id: Optional[str]): + super().delete_all(enterprise_id=enterprise_id, team_id=team_id) + self.delete_all_called = True + + +class TestEventsTokenRevocations: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_no_installation_store(self): + self.web_client.token = valid_token + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + def test_tokens_revoked(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + def test_app_uninstalled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True diff --git a/tests/scenario_tests_async/test_events_token_revocations.py b/tests/scenario_tests_async/test_events_token_revocations.py new file mode 100644 index 000000000..e67e03f78 --- /dev/null +++ b/tests/scenario_tests_async/test_events_token_revocations.py @@ -0,0 +1,174 @@ +import asyncio +import json +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(AsyncInstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + async def async_delete_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> None: + self.delete_bot_called = True + + async def async_delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + async def async_delete_all( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ): + self.delete_all_called = True + return await super().async_delete_all( + enterprise_id=enterprise_id, team_id=team_id + ) + + +class TestEventsTokenRevocations: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_no_installation_store(self): + self.web_client.token = valid_token + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + @pytest.mark.asyncio + async def test_tokens_revoked(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + @pytest.mark.asyncio + async def test_app_uninstalled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True