예제 #1
0
 def test_build_set_cookie_for_new_state(self):
     utils = OAuthStateUtils()
     value = utils.build_set_cookie_for_new_state("state-value")
     expected = (
         "slack-app-oauth-state=state-value; Secure; HttpOnly; Path=/; Max-Age=600"
     )
     self.assertEqual(expected, value)
예제 #2
0
 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,
     )
예제 #3
0
    def test_is_valid_browser(self):
        utils = OAuthStateUtils()
        cookie_name = OAuthStateUtils.default_cookie_name
        result = utils.is_valid_browser(
            "state-value", {"cookie": f"{cookie_name}=state-value"})
        self.assertTrue(result)
        result = utils.is_valid_browser("state-value",
                                        {"cookie": f"{cookie_name}=xxx"})
        self.assertFalse(result)

        result = utils.is_valid_browser(
            "state-value", {"cookie": [f"{cookie_name}=state-value"]})
        self.assertTrue(result)
        result = utils.is_valid_browser("state-value",
                                        {"cookie": [f"{cookie_name}=xxx"]})
        self.assertFalse(result)
예제 #4
0
    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,
        )
예제 #5
0
 def test_build_set_cookie_for_deletion(self):
     utils = OAuthStateUtils()
     value = utils.build_set_cookie_for_deletion()
     expected = "slack-app-oauth-state=deleted; Secure; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
     self.assertEqual(expected, value)
예제 #6
0
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,
        )