diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index 5ccd409f85608a..c9900d976ce40d 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -3,6 +3,7 @@ from collections.abc import Mapping, Sequence from enum import Enum from typing import Any +from urllib.parse import urlencode import requests from django.utils.translation import gettext_lazy as _ @@ -21,6 +22,7 @@ from sentry.pipeline.views.base import PipelineView from sentry.services.hybrid_cloud.organization.model import RpcOrganizationSummary from sentry.shared_integrations.exceptions import ApiError, IntegrationError +from sentry.utils import json from sentry.utils.http import absolute_uri from .utils import logger @@ -152,6 +154,7 @@ def __init__(self) -> None: self.client_secret = options.get("discord.client-secret") self.client = DiscordClient() self.setup_url = absolute_uri("extensions/discord/setup/") + self.configure_url = absolute_uri("extensions/discord/configure/") super().__init__() def get_pipeline_views(self) -> Sequence[PipelineView]: @@ -159,16 +162,19 @@ def get_pipeline_views(self) -> Sequence[PipelineView]: def build_integration(self, state: Mapping[str, object]) -> Mapping[str, object]: guild_id = str(state.get("guild_id")) + use_setup = state.get("use_setup") try: guild_name = self.client.get_guild_name(guild_id=guild_id) except (ApiError, AttributeError): guild_name = guild_id - discord_user_id = self._get_discord_user_id(str(state.get("code"))) + url = self.setup_url if use_setup == "1" else self.configure_url + discord_user_id = self._get_discord_user_id(str(state.get("code")), url) return { "name": guild_name, "external_id": guild_id, + "use_setup": use_setup, "user_identity": { "type": "discord", "external_id": discord_user_id, @@ -212,7 +218,7 @@ def post_install( ) raise ApiError(str(e)) - def _get_discord_user_id(self, auth_code: str) -> str: + def _get_discord_user_id(self, auth_code: str, url: str) -> str: """ Helper function for completing the oauth2 flow and grabbing the installing user's Discord user id so we can link their identities. @@ -225,7 +231,7 @@ def _get_discord_user_id(self, auth_code: str) -> str: integration. """ - form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={self.setup_url}" + form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={url}" headers = { "Content-Type": "application/x-www-form-urlencoded", } @@ -258,8 +264,21 @@ def _get_discord_user_id(self, auth_code: str) -> str: ) raise IntegrationError("Could not retrieve Discord user information.") - def _get_bot_install_url(self): - return f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}&permissions={self.bot_permissions}&redirect_uri={self.setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}" + def _get_bot_install_url( + self, + ): + state = json.dumps({"useSetup": 1}) + params = urlencode( + { + "client_id": self.application_id, + "permissions": self.bot_permissions, + "redirect_uri": self.setup_url, + "state": state, + "scope": " ".join(self.oauth_scopes), + "response_type": "code", + } + ) # typing + return f"https://discord.com/api/oauth2/authorize?{params}" def _credentials_exist(self) -> bool: has_credentials = all( @@ -289,4 +308,17 @@ def dispatch(self, request, pipeline): pipeline.bind_state("guild_id", request.GET["guild_id"]) pipeline.bind_state("code", request.GET["code"]) + try: + raw_state = json.loads(request.GET["state"]) + pipeline.bind_state("use_setup", raw_state.get("useSetup")) + except Exception as error: + logger.info( + "identity.discord.request-token", + extra={ + "state": request.GET("state"), + "guild_id": request.GET["guild_id"], + "code": request.GET["code"], + }, + ) + return pipeline.error(error) return pipeline.next_step() diff --git a/src/sentry/integrations/discord/urls.py b/src/sentry/integrations/discord/urls.py index 30ed4e49d0bc20..f047bfeeba6cb2 100644 --- a/src/sentry/integrations/discord/urls.py +++ b/src/sentry/integrations/discord/urls.py @@ -28,4 +28,10 @@ DiscordExtensionConfigurationView.as_view(), name="discord-extension-configuration", ), + # Install flow from Sentry + re_path( + r"^setup/$", + DiscordExtensionConfigurationView.as_view(), + name="discord-integration-setup", + ), ] diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py index 53989926b9bf8b..c5d6d8b4beb366 100644 --- a/tests/sentry/integrations/discord/test_integration.py +++ b/tests/sentry/integrations/discord/test_integration.py @@ -13,6 +13,7 @@ from sentry.models.integrations.integration import Integration from sentry.shared_integrations.exceptions import IntegrationError from sentry.testutils.cases import IntegrationTestCase +from sentry.utils import json class DiscordIntegrationTest(IntegrationTestCase): @@ -36,8 +37,10 @@ def assert_setup_flow( guild_id="1234567890", server_name="Cool server", auth_code="auth_code", + useSetup=None, command_response_empty=True, ): + state = json.dumps(useSetup if useSetup else {"useSetup": 1}) responses.reset() resp = self.client.get(self.init_path) @@ -94,7 +97,10 @@ def assert_setup_flow( ) resp = self.client.get( - "{}?{}".format(self.setup_path, urlencode({"guild_id": guild_id, "code": auth_code})) + "{}?{}".format( + self.setup_path, + urlencode({"guild_id": guild_id, "code": auth_code, "state": state}), + ) ) call_list = responses.calls @@ -110,6 +116,13 @@ def assert_setup_flow( else: assert mock_set_application_command.call_count == 0 + @responses.activate + def test_bot_flow_raises_error(self): + with self.tasks(): + bad_state = "{" + with pytest.raises(TypeError): + self.assert_setup_flow(useSetup=bad_state) + @responses.activate def test_bot_flow(self): with self.tasks(): @@ -210,7 +223,7 @@ def test_get_discord_user_id(self): responses.GET, url=f"{DiscordClient.base_url}/users/@me", json={"id": user_id} ) - result = provider._get_discord_user_id("auth_code") + result = provider._get_discord_user_id("auth_code", "1") assert result == user_id @@ -219,7 +232,7 @@ def test_get_discord_user_id_oauth_failure(self): provider = self.provider() responses.add(responses.POST, url="https://discord.com/api/v10/oauth2/token", status=500) with pytest.raises(IntegrationError): - provider._get_discord_user_id("auth_code") + provider._get_discord_user_id("auth_code", "1") @responses.activate def test_get_discord_user_id_oauth_no_token(self): @@ -230,7 +243,7 @@ def test_get_discord_user_id_oauth_no_token(self): json={}, ) with pytest.raises(IntegrationError): - provider._get_discord_user_id("auth_code") + provider._get_discord_user_id("auth_code", "1") @responses.activate def test_get_discord_user_id_request_fail(self): @@ -248,7 +261,27 @@ def test_get_discord_user_id_request_fail(self): status=401, ) with pytest.raises(IntegrationError): - provider._get_discord_user_id("auth_code") + provider._get_discord_user_id("auth_code", "1") + + @responses.activate + @mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command") + def test_post_install(self, mock_set_application_command): + provider = self.provider() + + responses.add( + responses.GET, + url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}", + match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})], + json=[], + ) + responses.add( + responses.POST, + url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}", + status=200, + ) + + provider.post_install(integration=self.integration, organization=self.organization) + assert mock_set_application_command.call_count == 3 # one for each command @mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command") def test_post_install_missing_credentials(self, mock_set_application_command):