diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 75fb5093e..b6825c7ab 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional, Callable, Awaitable, Dict, Any +from typing import Optional, Callable, Awaitable, Dict, Any, List from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import Bot, Installation @@ -156,6 +156,10 @@ async def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -163,7 +167,7 @@ async def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = await self.installation_store.async_find_installation( + latest_bot_installation = await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -173,20 +177,20 @@ async def __call__( # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists @@ -198,26 +202,32 @@ async def __call__( ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = await self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = await self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = await self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes except NotImplementedError as _: self.find_installation_available = False @@ -238,6 +248,7 @@ async def __call__( ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -249,6 +260,7 @@ async def __call__( if refreshed is not None: await self.installation_store.async_save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes except NotImplementedError as _: self.find_bot_available = False @@ -267,10 +279,16 @@ async def __call__( try: auth_test_api_response = await context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = await context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index a382f7097..4f113c9cc 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional, Callable, Dict, Any +from typing import Optional, Callable, Dict, Any, List from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore @@ -156,6 +156,10 @@ def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -163,7 +167,7 @@ def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = self.installation_store.find_installation( + latest_bot_installation = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -173,20 +177,20 @@ def __call__( # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists @@ -198,26 +202,32 @@ def __call__( ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes except NotImplementedError as _: self.find_installation_available = False @@ -238,6 +248,7 @@ def __call__( ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -249,6 +260,7 @@ def __call__( if refreshed is not None: self.installation_store.save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes except NotImplementedError as _: self.find_bot_available = False @@ -267,10 +279,16 @@ def __call__( try: auth_test_api_response = context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py index c27be6dc8..032375519 100644 --- a/slack_bolt/authorization/authorize_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List, Union from slack_sdk.web import SlackResponse @@ -11,8 +11,10 @@ class AuthorizeResult(dict): bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] + bot_scopes: Optional[List[str]] # since v1.17 user_id: Optional[str] user_token: Optional[str] + user_scopes: Optional[List[str]] # since v1.17 def __init__( self, @@ -23,9 +25,11 @@ def __init__( bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, + user_scopes: Optional[Union[List[str], str]] = None, ): """ Args: @@ -34,8 +38,10 @@ def __init__( bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token user_id: The request user ID user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id @@ -43,9 +49,15 @@ def __init__( self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes # type: ignore @classmethod def from_auth_test_response( @@ -53,7 +65,10 @@ def from_auth_test_response( *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -61,12 +76,18 @@ def from_auth_test_response( user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, ) diff --git a/tests/scenario_tests/test_app_installation_store.py b/tests/scenario_tests/test_app_installation_store.py new file mode 100644 index 000000000..b3b4390e3 --- /dev/null +++ b/tests/scenario_tests/test_app_installation_store.py @@ -0,0 +1,155 @@ +import datetime +import json +import logging +from time import time, sleep +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + 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]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + 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 build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_authorize_result(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + user_scopes=["search:read"], + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + def handle_app_mention(context: BoltContext, say: Say): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + say("What's up?") + + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py new file mode 100644 index 000000000..17bf92255 --- /dev/null +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -0,0 +1,167 @@ +import asyncio +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +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.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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 + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + 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], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_authorize_result(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + async def handle_app_mention(context: AsyncBoltContext, say: AsyncSay): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + await say("What's up?") + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + )