def test_render_failure_page(self): renderer = RedirectUriPageRenderer( install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", ) page = renderer.render_failure_page("something-wrong") self.assertTrue("Something Went Wrong!" in page)
def test_render_success_page(self): renderer = RedirectUriPageRenderer( install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", ) page = renderer.render_success_page(app_id="A111", team_id="T111") self.assertTrue("slack://app?team=T111&id=A111" in page) page = renderer.render_success_page(app_id="A111", team_id=None) self.assertTrue("slack://open" in page)
def _init_internal_utils(self): self.oauth_state_utils = OAuthStateUtils( cookie_name=self.oauth_state_cookie_name, expiration_seconds=self.oauth_state_expiration_seconds, ) self.authorize_url_generator = AuthorizeUrlGenerator( client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, scopes=self.scopes, user_scopes=self.user_scopes, ) self.redirect_uri_page_renderer = RedirectUriPageRenderer( install_path=self.install_path, redirect_uri_path=self.redirect_uri_path, success_url=self.success_url, failure_url=self.failure_url, )
def execute(self, code, state): logging.debug( "received authorization_grant code '%s'", code, ) logging.debug( "received authorization_grant state '%s'", state, ) gateway_response = slack.authorisation_grant( client_id=self.__client_id, client_secret=self.__client_secret, code=code, redirect_uri=self.__redirect_uri, ) response = ApiGatewayResponse() if not gateway_response.success: logging.warning("returning auth error due to gateway failure") return response.auth_error() if gateway_response.scope == EXPECTED_SCOPE: body = RedirectUriPageRenderer( install_path="", redirect_uri_path="" ).render_success_page(app_id="fakeappid", team_id=None) user = User( team_id=gateway_response.team, user_id=gateway_response.user, token=gateway_response.token, ) self.__user_token_store.store(user) return response.ok_html(body) else: logging.warning( f"scope differs from expected scope {gateway_response.scope} != {EXPECTED_SCOPE}" ) return response.auth_error()
return make_response("", 200) return make_response("", 404) # --------------------- # Flask App for Slack OAuth flow # --------------------- authorization_url_generator = AuthorizeUrlGenerator( client_id=client_id, scopes=scopes, user_scopes=user_scopes, ) redirect_page_renderer = RedirectUriPageRenderer( install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", ) @app.route("/slack/install", methods=["GET"]) def oauth_start(): state = state_store.issue() url = authorization_url_generator.generate(state) return ( '<html><head><link rel="icon" href="data:,"></head><body>' f'<a href="{url}">' f'<img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/[email protected] 2x" /></a>' "</body></html>") @app.route("/slack/oauth_redirect", methods=["GET"])
def __init__( self, *, # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required scopes: Optional[Union[Sequence[str], str]] = None, user_scopes: Optional[Union[Sequence[str], str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", install_page_rendering_enabled: bool = True, redirect_uri_path: str = "/slack/oauth_redirect", callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, # Installation Management installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, # state parameter related configurations state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils. default_expiration_seconds, # Others logger: Logger = logging.getLogger(__name__), ): """The settings for Slack App installation (OAuth flow). Args: client_id: Check the value in Settings > Basic Information > App Credentials client_secret: Check the value in Settings > Basic Information > App Credentials scopes: Check the value in Settings > Manage Distribution user_scopes: Check the value in Settings > Manage Distribution redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs install_path: The endpoint to start an OAuth flow (Default: `/slack/install`) install_page_rendering_enabled: Renders a web page for install_path access if True redirect_uri_path: The path of Redirect URL (Default: `/slack/oauth_redirect`) callback_options: Give success/failure functions f you want to customize callback functions. success_url: Set a complete URL if you want to redirect end-users when an installation completes. failure_url: Set a complete URL if you want to redirect end-users when an installation fails. authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) logger: The logger that will be used internally """ self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") self.client_secret = client_secret or os.environ.get( "SLACK_CLIENT_SECRET", None) if self.client_id is None or self.client_secret is None: raise BoltError("Both client_id and client_secret are required") self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") self.user_scopes = user_scopes or os.environ.get( "SLACK_USER_SCOPES", "").split(",") if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get( "SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( "SLACK_INSTALL_PATH", "/slack/install") self.install_page_rendering_enabled = install_page_rendering_enabled self.redirect_uri_path = redirect_uri_path or os.environ.get( "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect") self.callback_options = callback_options self.success_url = success_url self.failure_url = failure_url self.authorization_url = (authorization_url or "https://slack.com/oauth/v2/authorize") # Installation Management self.installation_store = ( installation_store or get_or_create_default_installation_store(client_id)) self.installation_store_bot_only = installation_store_bot_only self.authorize = InstallationStoreAuthorize( logger=logger, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, ) self.state_cookie_name = state_cookie_name self.state_expiration_seconds = state_expiration_seconds self.state_utils = OAuthStateUtils( cookie_name=self.state_cookie_name, expiration_seconds=self.state_expiration_seconds, ) self.authorize_url_generator = AuthorizeUrlGenerator( client_id=self.client_id, redirect_uri=self.redirect_uri, scopes=self.scopes, user_scopes=self.user_scopes, authorization_url=self.authorization_url, ) self.redirect_uri_page_renderer = RedirectUriPageRenderer( install_path=self.install_path, redirect_uri_path=self.redirect_uri_path, success_url=self.success_url, failure_url=self.failure_url, )
class AsyncOAuthFlow: installation_store: AsyncInstallationStore oauth_state_store: AsyncOAuthStateStore oauth_state_cookie_name: str oauth_state_expiration_seconds: int client_id: str client_secret: str redirect_uri: Optional[str] scopes: Optional[List[str]] user_scopes: Optional[List[str]] install_path: str redirect_uri_path: str success_url: Optional[str] failure_url: Optional[str] oauth_state_utils: OAuthStateUtils authorize_url_generator: AuthorizeUrlGenerator redirect_uri_page_renderer: RedirectUriPageRenderer @property def client(self) -> AsyncWebClient: if self._async_client is None: self._async_client = create_async_web_client() return self._async_client @property def logger(self) -> Logger: if self._logger is None: self._logger = logging.getLogger(__name__) return self._logger def __init__( self, *, client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, installation_store: AsyncInstallationStore, oauth_state_store: AsyncOAuthStateStore, oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, oauth_state_expiration_seconds: int = OAuthStateUtils. default_expiration_seconds, client_id: str, client_secret: str, scopes: Optional[List[str]] = None, user_scopes: Optional[List[str]] = None, redirect_uri: Optional[str] = None, install_path: str = "/slack/install", redirect_uri_path: str = "/slack/oauth_redirect", success_url: Optional[str] = None, failure_url: Optional[str] = None, ): self._async_client = client self._logger = logger self.installation_store = installation_store self.oauth_state_store = oauth_state_store self.oauth_state_cookie_name = oauth_state_cookie_name self.oauth_state_expiration_seconds = oauth_state_expiration_seconds self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri self.scopes = scopes self.user_scopes = user_scopes self.install_path = install_path self.redirect_uri_path = redirect_uri_path self.success_url = success_url self.failure_url = failure_url self._init_internal_utils() def _init_internal_utils(self): self.oauth_state_utils = OAuthStateUtils( cookie_name=self.oauth_state_cookie_name, expiration_seconds=self.oauth_state_expiration_seconds, ) self.authorize_url_generator = AuthorizeUrlGenerator( client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, scopes=self.scopes, user_scopes=self.user_scopes, ) self.redirect_uri_page_renderer = RedirectUriPageRenderer( install_path=self.install_path, redirect_uri_path=self.redirect_uri_path, success_url=self.success_url, failure_url=self.failure_url, ) # ----------------------------- # Factory Methods # ----------------------------- @classmethod def sqlite3( cls, database: str, client_id: Optional[str] = os.environ.get("SLACK_CLIENT_ID", None), client_secret: Optional[str] = os.environ.get("SLACK_CLIENT_SECRET", None), scopes: List[str] = os.environ.get("SLACK_SCOPES", "").split(","), user_scopes: List[str] = os.environ.get("SLACK_USER_SCOPES", "").split(","), redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, oauth_state_expiration_seconds: int = OAuthStateUtils. default_expiration_seconds, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": return AsyncOAuthFlow( client=create_async_web_client(), logger=logger, installation_store=SQLite3InstallationStore( database=database, client_id=client_id, logger=logger, ), oauth_state_store=SQLite3OAuthStateStore( database=database, expiration_seconds=oauth_state_expiration_seconds, logger=logger, ), oauth_state_cookie_name=oauth_state_cookie_name, oauth_state_expiration_seconds=oauth_state_expiration_seconds, client_id=client_id, client_secret=client_secret, scopes=scopes, user_scopes=user_scopes, redirect_uri=redirect_uri, ) # ----------------------------- # Installation # ----------------------------- async def handle_installation(self, request: BoltRequest) -> BoltResponse: state = await self.issue_new_state(request) return await self.build_authorize_url_redirection(request, state) # ---------------------- # Internal methods for Installation async def issue_new_state(self, request: BoltRequest) -> str: return await self.oauth_state_store.async_issue() async def build_authorize_url_redirection(self, request: BoltRequest, state: str) -> BoltResponse: return BoltResponse( status=302, headers={ "Location": [self.authorize_url_generator.generate(state)], "Set-Cookie": [self.oauth_state_utils.build_set_cookie_for_new_state(state)], }, ) # ----------------------------- # Callback # ----------------------------- async def handle_callback(self, request: BoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] if error is not None: return await self.build_callback_failure_response(request, reason=error, status=200) # state parameter verification state = request.query.get("state", [None])[0] if not self.oauth_state_utils.is_valid_browser(state, request.headers): return await self.build_callback_failure_response( request, reason="invalid_browser", status=400) valid_state_consumed = await self.oauth_state_store.async_consume(state ) if not valid_state_consumed: return await self.build_callback_failure_response( request, reason="invalid_state", status=401) # run installation code = request.query.get("code", [None])[0] if code is None: return await self.build_callback_failure_response( request, reason="missing_code", status=401) installation = await self.run_installation(code) if installation is None: # failed to run installation with the code return await self.build_callback_failure_response( request, reason="invalid_code", status=401) # persist the installation try: await self.store_installation(request, installation) except BoltError as e: return await self.build_callback_failure_response( request, reason="storage_error", error=e) # display a successful completion page to the end-user return await self.build_callback_success_response( request, installation) # ---------------------- # Internal methods for Callback async def run_installation(self, code: str) -> Optional[Installation]: try: oauth_response: AsyncSlackResponse = await self.client.oauth_v2_access( code=code, client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, # can be None ) installed_enterprise: Dict[str, str] = oauth_response.get( "enterprise", {}) installed_team: Dict[str, str] = oauth_response.get("team", {}) installer: Dict[str, str] = oauth_response.get("authed_user", {}) incoming_webhook: Dict[str, str] = oauth_response.get( "incoming_webhook", {}) bot_token: Optional[str] = oauth_response.get("access_token", None) # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None if bot_token is not None: auth_test = await self.client.auth_test(token=bot_token) bot_id = auth_test["bot_id"] return Installation( app_id=oauth_response.get("app_id", None), enterprise_id=installed_enterprise.get("id", None), team_id=installed_team.get("id", None), bot_token=bot_token, bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id", None), bot_scopes=oauth_response.get("scope", None), # comma-separated string user_id=installer.get("id", None), user_token=installer.get("access_token", None), user_scopes=installer.get("scope", None), # comma-separated string incoming_webhook_url=incoming_webhook.get("url", None), incoming_webhook_channel_id=incoming_webhook.get( "channel_id", None), incoming_webhook_configuration_url=incoming_webhook.get( "configuration_url", None), ) except SlackApiError as e: message = ( f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}" ) self.logger.warning(message) return None async def store_installation(self, request: BoltRequest, installation: Installation): # may raise BoltError await self.installation_store.async_save(installation) async def build_callback_failure_response( self, request: BoltRequest, reason: str, status: int = 500, error: Optional[Exception] = None, ) -> BoltResponse: debug_message = ( "Handling an OAuth callback failure " f"(reason: {reason}, error: {error}, request: {request.query})") self.logger.debug(debug_message) html = self.redirect_uri_page_renderer.render_failure_page(reason) return BoltResponse( status=status, headers={ "Content-Type": "text/html; charset=utf-8", "Content-Length": len(html), "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), }, body=html, ) async def build_callback_success_response( self, request: BoltRequest, installation: Installation, ) -> BoltResponse: debug_message = f"Handling an OAuth callback success (request: {request.query})" self.logger.debug(debug_message) html = self.redirect_uri_page_renderer.render_success_page( app_id=installation.app_id, team_id=installation.team_id, ) return BoltResponse( status=200, headers={ "Content-Type": "text/html; charset=utf-8", "Content-Length": len(html), "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), }, body=html, )