From 7eed62cf56851518f23bea3642346a596f78849e Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 13:54:09 -0700 Subject: [PATCH 01/16] require kid --- .../integrations/jira/webhooks/installed.py | 19 +++++++++++-------- .../integrations/jira/test_installed.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 168381b3595aac..e9f19d19d3732a 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -55,15 +55,18 @@ def post(self, request: Request, *args, **kwargs) -> Response: "clientKey": state.get("clientKey", ""), } ) + if not key_id: + return self.respond( + {"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST + ) - if key_id: - if key_id in INVALID_KEY_IDS: - lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") - return self.respond( - {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST - ) - decoded_claims = authenticate_asymmetric_jwt(token, key_id) - verify_claims(decoded_claims, request.path, request.GET, method="POST") + if key_id in INVALID_KEY_IDS: + lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") + return self.respond( + {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST + ) + decoded_claims = authenticate_asymmetric_jwt(token, key_id) + verify_claims(decoded_claims, request.path, request.GET, method="POST") data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 41117dafd46efc..6ced341b468ddb 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -137,6 +137,16 @@ def test_with_key_id(self, mock_set_tag: MagicMock) -> None: mock_set_tag.assert_any_call("integration_id", integration.id) assert integration.status == ObjectStatus.ACTIVE + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_without_key_id(self, mock_record_event: MagicMock) -> None: + self.get_error_response( + **self.body(), + extra_headers=dict( + HTTP_AUTHORIZATION="JWT " + self._jwt_token("RS256", RS256_KEY, headers={}) + ), + status_code=status.HTTP_400_BAD_REQUEST, + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_with_invalid_key_id(self, mock_record_event: MagicMock) -> None: self.get_error_response( From 7fc460211497e69599ca1950db9e425a6ff80a83 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 14:21:50 -0700 Subject: [PATCH 02/16] validate jwt in bitbucket --- .../integrations/bitbucket/installed.py | 36 +++++ .../integrations/bitbucket/test_installed.py | 130 +++++++++++++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/sentry/integrations/bitbucket/installed.py b/src/sentry/integrations/bitbucket/installed.py index 4be5cda97459d8..031ca63890027e 100644 --- a/src/sentry/integrations/bitbucket/installed.py +++ b/src/sentry/integrations/bitbucket/installed.py @@ -1,14 +1,23 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.views.decorators.csrf import csrf_exempt +from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, control_silo_endpoint +from sentry.integrations.jira.webhooks.installed import INVALID_KEY_IDS from sentry.integrations.pipeline import ensure_integration from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.utils.atlassian_connect import ( + AtlassianConnectValidationError, + authenticate_asymmetric_jwt, + get_token, + verify_claims, +) +from sentry.utils import jwt from .integration import BitbucketIntegrationProvider @@ -27,7 +36,34 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def post(self, request: Request, *args, **kwargs) -> Response: + try: + token = get_token(request) + except AtlassianConnectValidationError: + return self.respond( + {"detail": "Missing authorization header"}, status=status.HTTP_400_BAD_REQUEST + ) + + key_id = jwt.peek_header(token).get("kid") + if not key_id: + return self.respond({"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST) + + if key_id in INVALID_KEY_IDS: + return self.respond({"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST) + + try: + decoded_claims = authenticate_asymmetric_jwt(token, key_id) + verify_claims(decoded_claims, request.path, request.GET, method="POST") + except AtlassianConnectValidationError: + return self.respond( + {"detail": "Could not validate JWT"}, status=status.HTTP_400_BAD_REQUEST + ) + state = request.data + if decoded_claims.get("iss") != state.get("clientKey"): + return self.respond( + {"detail": "JWT issuer does not match client key"}, + status=status.HTTP_400_BAD_REQUEST, + ) data = BitbucketIntegrationProvider().build_integration(state) ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data) diff --git a/tests/sentry/integrations/bitbucket/test_installed.py b/tests/sentry/integrations/bitbucket/test_installed.py index b7d0b37f575076..00c61c2c8d6723 100644 --- a/tests/sentry/integrations/bitbucket/test_installed.py +++ b/tests/sentry/integrations/bitbucket/test_installed.py @@ -3,11 +3,13 @@ from typing import Any from unittest import mock +import jwt as pyjwt import responses from sentry.integrations.bitbucket.installed import BitbucketInstalledEndpoint from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider, scopes from sentry.integrations.models.integration import Integration +from sentry.integrations.utils.atlassian_connect import get_query_hash from sentry.models.project import Project from sentry.models.repository import Repository from sentry.organizations.services.organization.serial import serialize_rpc_organization @@ -16,6 +18,8 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +from sentry.utils.http import absolute_uri +from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY class BitbucketPlugin(IssueTrackingPlugin2): @@ -29,6 +33,7 @@ class BitbucketInstalledEndpointTest(APITestCase): def setUp(self) -> None: self.provider = "bitbucket" self.path = "/extensions/bitbucket/installed/" + self.kid = "bitbucket-kid" self.username = "sentryuser" self.client_key = "connection:123" self.public_key = "123abcDEFg" @@ -98,19 +103,105 @@ def tearDown(self) -> None: plugins.unregister(BitbucketPlugin) super().tearDown() + def jwt_token_cdn(self) -> str: + return pyjwt.encode( + { + "iss": self.client_key, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + RS256_KEY, + algorithm="RS256", + headers={"kid": self.kid, "alg": "RS256"}, + ) + + def add_cdn_response(self) -> None: + responses.add( + responses.GET, + f"https://connect-install-keys.atlassian.com/{self.kid}", + body=RS256_PUB_KEY, + ) + def test_default_permissions(self) -> None: # Permissions must be empty so that it will be accessible to bitbucket. assert BitbucketInstalledEndpoint.authentication_classes == () assert BitbucketInstalledEndpoint.permission_classes == () - def test_installed_with_public_key(self) -> None: + def test_missing_token(self) -> None: response = self.client.post(self.path, data=self.team_data_from_bitbucket) + assert response.status_code == 400 + + def test_invalid_token(self) -> None: + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION="invalid", + ) + assert response.status_code == 400 + + @responses.activate + def test_missing_key_id(self) -> None: + token = pyjwt.encode( + { + "iss": self.client_key, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + RS256_KEY, + algorithm="RS256", + headers={"alg": "RS256"}, + ) + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == 400 + + @responses.activate + def test_invalid_key_id(self) -> None: + token = pyjwt.encode( + { + "iss": self.client_key, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + RS256_KEY, + algorithm="RS256", + headers={"kid": "fake-kid", "alg": "RS256"}, + ) + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == 400 + + @responses.activate + def test_jwt_issuer_mismatch(self) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data={**self.team_data_from_bitbucket, "clientKey": "different-client-key"}, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) + assert response.status_code == 400 + + @responses.activate + def test_installed_with_public_key(self) -> None: + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) assert integration.name == self.username del integration.metadata["webhook_secret"] assert integration.metadata == self.metadata + @responses.activate def test_installed_without_public_key(self) -> None: integration, created = Integration.objects.get_or_create( provider=self.provider, @@ -118,7 +209,12 @@ def test_installed_without_public_key(self) -> None: defaults={"name": self.user_display_name, "metadata": self.user_metadata}, ) del self.user_data_from_bitbucket["principal"]["username"] - response = self.client.post(self.path, data=self.user_data_from_bitbucket) + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.user_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) assert response.status_code == 200 # assert no changes have been made to the integration @@ -129,22 +225,34 @@ def test_installed_without_public_key(self) -> None: del integration_after.metadata["webhook_secret"] assert integration.metadata == integration_after.metadata + @responses.activate def test_installed_without_username(self) -> None: """Test a user (not team) installation where the user has hidden their username from public view""" # Remove username to simulate privacy mode del self.user_data_from_bitbucket["principal"]["username"] - response = self.client.post(self.path, data=self.user_data_from_bitbucket) + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.user_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) assert integration.name == self.user_display_name del integration.metadata["webhook_secret"] assert integration.metadata == self.user_metadata + @responses.activate @mock.patch("sentry.integrations.bitbucket.integration.generate_token", return_value="0" * 64) def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> None: - response = self.client.post(self.path, data=self.team_data_from_bitbucket) + self.add_cdn_response() + response = self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) assert mock_generate_token.called assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) @@ -172,7 +280,12 @@ def test_plugin_migration(self) -> None: config={"name": "otheruser/otherrepo"}, ) - self.client.post(self.path, data=self.team_data_from_bitbucket) + self.add_cdn_response() + self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) @@ -219,7 +332,12 @@ def test_disable_plugin_when_fully_migrated(self) -> None: config={"name": "sentryuser/repo"}, ) - self.client.post(self.path, data=self.team_data_from_bitbucket) + self.add_cdn_response() + self.client.post( + self.path, + data=self.team_data_from_bitbucket, + HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", + ) integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) From 0deeb99ca9fe91b3fafee91a6063003806a15b0d Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 14:58:40 -0700 Subject: [PATCH 03/16] Update src/sentry/integrations/jira/webhooks/installed.py Co-authored-by: sentry-warden[bot] <258096371+sentry-warden[bot]@users.noreply.github.com> --- src/sentry/integrations/jira/webhooks/installed.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index e9f19d19d3732a..de7ce2bf1fab19 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -65,10 +65,11 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) - decoded_claims = authenticate_asymmetric_jwt(token, key_id) - verify_claims(decoded_claims, request.path, request.GET, method="POST") - - data = JiraIntegrationProvider().build_integration(state) + if decoded_claims.get("iss") != state.get("clientKey"): + lifecycle.record_halt(halt_reason="JWT issuer does not match client key") + return self.respond( + {"detail": "JWT issuer does not match client key"}, status=status.HTTP_400_BAD_REQUEST + ) integration = ensure_integration(self.provider, data) # Note: Unlike in all other Jira webhooks, we don't call `bind_org_context_from_integration` From 80d877ea399a8671cd3bdb928bc95dc34ca75114 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 14:59:06 -0700 Subject: [PATCH 04/16] Update src/sentry/integrations/bitbucket/installed.py Co-authored-by: sentry-warden[bot] <258096371+sentry-warden[bot]@users.noreply.github.com> --- src/sentry/integrations/bitbucket/installed.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/bitbucket/installed.py b/src/sentry/integrations/bitbucket/installed.py index 031ca63890027e..56268a3b863df6 100644 --- a/src/sentry/integrations/bitbucket/installed.py +++ b/src/sentry/integrations/bitbucket/installed.py @@ -43,7 +43,12 @@ def post(self, request: Request, *args, **kwargs) -> Response: {"detail": "Missing authorization header"}, status=status.HTTP_400_BAD_REQUEST ) - key_id = jwt.peek_header(token).get("kid") + try: + key_id = jwt.peek_header(token).get("kid") + except jwt.DecodeError: + return self.respond( + {"detail": "Invalid JWT token"}, status=status.HTTP_400_BAD_REQUEST + ) if not key_id: return self.respond({"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST) From 4e0e5a393b111a24ca5a4d98d9970a2e8a27e8ac Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 15:32:25 -0700 Subject: [PATCH 05/16] rm bitbucket changes, handle symmetric and assymetric paths --- .../integrations/bitbucket/installed.py | 41 ------ .../integrations/jira/webhooks/installed.py | 41 ++++-- .../integrations/bitbucket/test_installed.py | 130 +----------------- .../integrations/jira/test_installed.py | 5 +- 4 files changed, 40 insertions(+), 177 deletions(-) diff --git a/src/sentry/integrations/bitbucket/installed.py b/src/sentry/integrations/bitbucket/installed.py index 56268a3b863df6..4be5cda97459d8 100644 --- a/src/sentry/integrations/bitbucket/installed.py +++ b/src/sentry/integrations/bitbucket/installed.py @@ -1,23 +1,14 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.views.decorators.csrf import csrf_exempt -from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, control_silo_endpoint -from sentry.integrations.jira.webhooks.installed import INVALID_KEY_IDS from sentry.integrations.pipeline import ensure_integration from sentry.integrations.types import IntegrationProviderSlug -from sentry.integrations.utils.atlassian_connect import ( - AtlassianConnectValidationError, - authenticate_asymmetric_jwt, - get_token, - verify_claims, -) -from sentry.utils import jwt from .integration import BitbucketIntegrationProvider @@ -36,39 +27,7 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def post(self, request: Request, *args, **kwargs) -> Response: - try: - token = get_token(request) - except AtlassianConnectValidationError: - return self.respond( - {"detail": "Missing authorization header"}, status=status.HTTP_400_BAD_REQUEST - ) - - try: - key_id = jwt.peek_header(token).get("kid") - except jwt.DecodeError: - return self.respond( - {"detail": "Invalid JWT token"}, status=status.HTTP_400_BAD_REQUEST - ) - if not key_id: - return self.respond({"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST) - - if key_id in INVALID_KEY_IDS: - return self.respond({"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST) - - try: - decoded_claims = authenticate_asymmetric_jwt(token, key_id) - verify_claims(decoded_claims, request.path, request.GET, method="POST") - except AtlassianConnectValidationError: - return self.respond( - {"detail": "Could not validate JWT"}, status=status.HTTP_400_BAD_REQUEST - ) - state = request.data - if decoded_claims.get("iss") != state.get("clientKey"): - return self.respond( - {"detail": "JWT issuer does not match client key"}, - status=status.HTTP_400_BAD_REQUEST, - ) data = BitbucketIntegrationProvider().build_integration(state) ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index de7ce2bf1fab19..9213155e64e703 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,5 +1,6 @@ import sentry_sdk from django.db import router, transaction +from jwt import DecodeError, ExpiredSignatureError, InvalidAlgorithmError, InvalidSignatureError from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -55,21 +56,41 @@ def post(self, request: Request, *args, **kwargs) -> Response: "clientKey": state.get("clientKey", ""), } ) - if not key_id: - return self.respond( - {"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST - ) - if key_id in INVALID_KEY_IDS: - lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") - return self.respond( - {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST - ) + if key_id: + if key_id in INVALID_KEY_IDS: + lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") + return self.respond( + {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST + ) + decoded_claims = authenticate_asymmetric_jwt(token, key_id) + else: + shared_secret = state.get("sharedSecret") + if not shared_secret: + return self.respond( + {"detail": "Missing shared secret"}, status=status.HTTP_400_BAD_REQUEST + ) + try: + decoded_claims = jwt.decode(token, shared_secret, audience=False) + except ( + InvalidSignatureError, + ExpiredSignatureError, + DecodeError, + InvalidAlgorithmError, + ): + return self.respond( + {"detail": "Invalid JWT"}, status=status.HTTP_400_BAD_REQUEST + ) + if decoded_claims.get("iss") != state.get("clientKey"): lifecycle.record_halt(halt_reason="JWT issuer does not match client key") return self.respond( - {"detail": "JWT issuer does not match client key"}, status=status.HTTP_400_BAD_REQUEST + {"detail": "JWT issuer does not match client key"}, + status=status.HTTP_400_BAD_REQUEST, ) + + verify_claims(decoded_claims, request.path, request.GET, method="POST") + data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) # Note: Unlike in all other Jira webhooks, we don't call `bind_org_context_from_integration` diff --git a/tests/sentry/integrations/bitbucket/test_installed.py b/tests/sentry/integrations/bitbucket/test_installed.py index 00c61c2c8d6723..b7d0b37f575076 100644 --- a/tests/sentry/integrations/bitbucket/test_installed.py +++ b/tests/sentry/integrations/bitbucket/test_installed.py @@ -3,13 +3,11 @@ from typing import Any from unittest import mock -import jwt as pyjwt import responses from sentry.integrations.bitbucket.installed import BitbucketInstalledEndpoint from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider, scopes from sentry.integrations.models.integration import Integration -from sentry.integrations.utils.atlassian_connect import get_query_hash from sentry.models.project import Project from sentry.models.repository import Repository from sentry.organizations.services.organization.serial import serialize_rpc_organization @@ -18,8 +16,6 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test -from sentry.utils.http import absolute_uri -from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY class BitbucketPlugin(IssueTrackingPlugin2): @@ -33,7 +29,6 @@ class BitbucketInstalledEndpointTest(APITestCase): def setUp(self) -> None: self.provider = "bitbucket" self.path = "/extensions/bitbucket/installed/" - self.kid = "bitbucket-kid" self.username = "sentryuser" self.client_key = "connection:123" self.public_key = "123abcDEFg" @@ -103,105 +98,19 @@ def tearDown(self) -> None: plugins.unregister(BitbucketPlugin) super().tearDown() - def jwt_token_cdn(self) -> str: - return pyjwt.encode( - { - "iss": self.client_key, - "aud": absolute_uri(), - "qsh": get_query_hash(self.path, method="POST", query_params={}), - }, - RS256_KEY, - algorithm="RS256", - headers={"kid": self.kid, "alg": "RS256"}, - ) - - def add_cdn_response(self) -> None: - responses.add( - responses.GET, - f"https://connect-install-keys.atlassian.com/{self.kid}", - body=RS256_PUB_KEY, - ) - def test_default_permissions(self) -> None: # Permissions must be empty so that it will be accessible to bitbucket. assert BitbucketInstalledEndpoint.authentication_classes == () assert BitbucketInstalledEndpoint.permission_classes == () - def test_missing_token(self) -> None: - response = self.client.post(self.path, data=self.team_data_from_bitbucket) - assert response.status_code == 400 - - def test_invalid_token(self) -> None: - response = self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION="invalid", - ) - assert response.status_code == 400 - - @responses.activate - def test_missing_key_id(self) -> None: - token = pyjwt.encode( - { - "iss": self.client_key, - "aud": absolute_uri(), - "qsh": get_query_hash(self.path, method="POST", query_params={}), - }, - RS256_KEY, - algorithm="RS256", - headers={"alg": "RS256"}, - ) - response = self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert response.status_code == 400 - - @responses.activate - def test_invalid_key_id(self) -> None: - token = pyjwt.encode( - { - "iss": self.client_key, - "aud": absolute_uri(), - "qsh": get_query_hash(self.path, method="POST", query_params={}), - }, - RS256_KEY, - algorithm="RS256", - headers={"kid": "fake-kid", "alg": "RS256"}, - ) - response = self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert response.status_code == 400 - - @responses.activate - def test_jwt_issuer_mismatch(self) -> None: - self.add_cdn_response() - response = self.client.post( - self.path, - data={**self.team_data_from_bitbucket, "clientKey": "different-client-key"}, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) - assert response.status_code == 400 - - @responses.activate def test_installed_with_public_key(self) -> None: - self.add_cdn_response() - response = self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + response = self.client.post(self.path, data=self.team_data_from_bitbucket) assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) assert integration.name == self.username del integration.metadata["webhook_secret"] assert integration.metadata == self.metadata - @responses.activate def test_installed_without_public_key(self) -> None: integration, created = Integration.objects.get_or_create( provider=self.provider, @@ -209,12 +118,7 @@ def test_installed_without_public_key(self) -> None: defaults={"name": self.user_display_name, "metadata": self.user_metadata}, ) del self.user_data_from_bitbucket["principal"]["username"] - self.add_cdn_response() - response = self.client.post( - self.path, - data=self.user_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + response = self.client.post(self.path, data=self.user_data_from_bitbucket) assert response.status_code == 200 # assert no changes have been made to the integration @@ -225,34 +129,22 @@ def test_installed_without_public_key(self) -> None: del integration_after.metadata["webhook_secret"] assert integration.metadata == integration_after.metadata - @responses.activate def test_installed_without_username(self) -> None: """Test a user (not team) installation where the user has hidden their username from public view""" # Remove username to simulate privacy mode del self.user_data_from_bitbucket["principal"]["username"] - self.add_cdn_response() - response = self.client.post( - self.path, - data=self.user_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + response = self.client.post(self.path, data=self.user_data_from_bitbucket) assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) assert integration.name == self.user_display_name del integration.metadata["webhook_secret"] assert integration.metadata == self.user_metadata - @responses.activate @mock.patch("sentry.integrations.bitbucket.integration.generate_token", return_value="0" * 64) def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> None: - self.add_cdn_response() - response = self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + response = self.client.post(self.path, data=self.team_data_from_bitbucket) assert mock_generate_token.called assert response.status_code == 200 integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) @@ -280,12 +172,7 @@ def test_plugin_migration(self) -> None: config={"name": "otheruser/otherrepo"}, ) - self.add_cdn_response() - self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + self.client.post(self.path, data=self.team_data_from_bitbucket) integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) @@ -332,12 +219,7 @@ def test_disable_plugin_when_fully_migrated(self) -> None: config={"name": "sentryuser/repo"}, ) - self.add_cdn_response() - self.client.post( - self.path, - data=self.team_data_from_bitbucket, - HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}", - ) + self.client.post(self.path, data=self.team_data_from_bitbucket) integration = Integration.objects.get(provider=self.provider, external_id=self.client_key) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 6ced341b468ddb..2232bb5caafc08 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -31,6 +31,7 @@ class JiraInstalledTest(APITestCase): kid = "cudi" shared_secret = "garden" path = "/extensions/jira/installed/" + client_key = "limepie" def _jwt_token( self, @@ -40,7 +41,7 @@ def _jwt_token( ) -> str: return jwt.encode( { - "iss": self.external_id, + "iss": self.client_key, "aud": absolute_uri(), "qsh": get_query_hash(self.path, method="POST", query_params={}), }, @@ -61,7 +62,7 @@ def body(self) -> Mapping[str, Any]: "metadata": {}, "external_id": self.external_id, }, - "clientKey": "limepie", + "clientKey": self.client_key, "oauthClientId": "EFG", "publicKey": "yourCar", "sharedSecret": self.shared_secret, From af7091bd3217b5c3322b148ab2db8f711c9213a9 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 16:28:07 -0700 Subject: [PATCH 06/16] require kid --- .../integrations/jira/webhooks/installed.py | 44 +++++-------------- .../integrations/jira/test_installed.py | 13 ------ 2 files changed, 12 insertions(+), 45 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 9213155e64e703..671a92b14234e2 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,6 +1,8 @@ import sentry_sdk from django.db import router, transaction -from jwt import DecodeError, ExpiredSignatureError, InvalidAlgorithmError, InvalidSignatureError +from jwt import ( + InvalidKeyError, +) from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -21,9 +23,6 @@ ) from sentry.utils import jwt -# Atlassian sends scanner bots to "test" Atlassian apps and they often hit this endpoint with a bad kid causing errors -INVALID_KEY_IDS = ["fake-kid"] - @control_silo_endpoint class JiraSentryInstalledWebhook(JiraWebhookBase): @@ -57,36 +56,17 @@ def post(self, request: Request, *args, **kwargs) -> Response: } ) - if key_id: - if key_id in INVALID_KEY_IDS: - lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") - return self.respond( - {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST - ) + if not key_id: + lifecycle.record_halt(halt_reason="Missing key_id (kid)") + return self.respond( + {"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST + ) + try: decoded_claims = authenticate_asymmetric_jwt(token, key_id) - else: - shared_secret = state.get("sharedSecret") - if not shared_secret: - return self.respond( - {"detail": "Missing shared secret"}, status=status.HTTP_400_BAD_REQUEST - ) - try: - decoded_claims = jwt.decode(token, shared_secret, audience=False) - except ( - InvalidSignatureError, - ExpiredSignatureError, - DecodeError, - InvalidAlgorithmError, - ): - return self.respond( - {"detail": "Invalid JWT"}, status=status.HTTP_400_BAD_REQUEST - ) - - if decoded_claims.get("iss") != state.get("clientKey"): - lifecycle.record_halt(halt_reason="JWT issuer does not match client key") + except InvalidKeyError: + lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") return self.respond( - {"detail": "JWT issuer does not match client key"}, - status=status.HTTP_400_BAD_REQUEST, + {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) verify_claims(decoded_claims, request.path, request.GET, method="POST") diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 2232bb5caafc08..022f96be426dac 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -111,19 +111,6 @@ def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: status_code=status.HTTP_409_CONFLICT, ) - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry_sdk.set_tag") - def test_with_shared_secret(self, mock_set_tag: MagicMock, mock_record_event) -> None: - self.get_success_response( - **self.body(), - extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_secret()), - ) - integration = Integration.objects.get(provider="jira", external_id=self.external_id) - - mock_set_tag.assert_any_call("integration_id", integration.id) - assert integration.status == ObjectStatus.ACTIVE - mock_record_event.assert_called_with(EventLifecycleOutcome.SUCCESS, None, False, None) - @patch("sentry_sdk.set_tag") @responses.activate def test_with_key_id(self, mock_set_tag: MagicMock) -> None: From b20f9770f4b857a24d1710fbfaf32cf13f4c4aec Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 16:32:26 -0700 Subject: [PATCH 07/16] get rid of that --- tests/sentry/integrations/jira/test_installed.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 022f96be426dac..26b0e3a35e2ff5 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -31,7 +31,6 @@ class JiraInstalledTest(APITestCase): kid = "cudi" shared_secret = "garden" path = "/extensions/jira/installed/" - client_key = "limepie" def _jwt_token( self, @@ -41,7 +40,7 @@ def _jwt_token( ) -> str: return jwt.encode( { - "iss": self.client_key, + "iss": self.external_id, "aud": absolute_uri(), "qsh": get_query_hash(self.path, method="POST", query_params={}), }, @@ -62,7 +61,7 @@ def body(self) -> Mapping[str, Any]: "metadata": {}, "external_id": self.external_id, }, - "clientKey": self.client_key, + "clientKey": "limepie", "oauthClientId": "EFG", "publicKey": "yourCar", "sharedSecret": self.shared_secret, From 30a7fcd12703a61d29caf797efca4bf867946368 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 7 Apr 2026 16:33:47 -0700 Subject: [PATCH 08/16] Add slo stuff to test --- tests/sentry/integrations/jira/test_installed.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 26b0e3a35e2ff5..8fdd1f48b7d337 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -133,6 +133,15 @@ def test_without_key_id(self, mock_record_event: MagicMock) -> None: ), status_code=status.HTTP_400_BAD_REQUEST, ) + # SLO metric asserts + # ENSURE_CONTROL_SILO (success) -> VERIFY_INSTALLATION (halt) -> GET_CONTROL_RESPONSE (success) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric( + mock_record_event, + "Missing key_id (kid)", + ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_with_invalid_key_id(self, mock_record_event: MagicMock) -> None: From 3d2c1801c27b7519d97697c82a5f850ba081bc4d Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 09:46:08 -0700 Subject: [PATCH 09/16] verify claims in try, update test --- src/sentry/integrations/jira/webhooks/installed.py | 2 +- tests/sentry/integrations/jira/test_installed.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 671a92b14234e2..ebabd8577562b9 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -63,13 +63,13 @@ def post(self, request: Request, *args, **kwargs) -> Response: ) try: decoded_claims = authenticate_asymmetric_jwt(token, key_id) + verify_claims(decoded_claims, request.path, request.GET, method="POST") except InvalidKeyError: lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)") return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) - verify_claims(decoded_claims, request.path, request.GET, method="POST") data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 8fdd1f48b7d337..efbd9ef4935aad 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -144,7 +144,15 @@ def test_without_key_id(self, mock_record_event: MagicMock) -> None: ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate def test_with_invalid_key_id(self, mock_record_event: MagicMock) -> None: + responses.add( + responses.GET, + "https://connect-install-keys.atlassian.com/fake-kid", + body=b"Not Found", + status=404, + ) + self.get_error_response( **self.body(), extra_headers=dict( From 02c42be1ddcf1d679f9ba47c407fa5ee584fd4d0 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 10:06:19 -0700 Subject: [PATCH 10/16] handle other errors --- src/sentry/integrations/jira/webhooks/installed.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index ebabd8577562b9..5b29ab56011a5c 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,8 +1,6 @@ import sentry_sdk from django.db import router, transaction -from jwt import ( - InvalidKeyError, -) +from jwt import ExpiredSignatureError, InvalidKeyError, InvalidSignatureError from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -69,6 +67,11 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) + except (InvalidSignatureError, ExpiredSignatureError): + lifecycle.record_halt(halt_reason="JWT contained invalid or expired signature") + return self.respond( + {"detail": "Invalid or expired signature"}, status=status.HTTP_400_BAD_REQUEST + ) data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) From 16c17c130bf76ca7b8decf9a9ba47559541541d8 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 10:14:58 -0700 Subject: [PATCH 11/16] generic error handling --- src/sentry/integrations/jira/webhooks/installed.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 5b29ab56011a5c..1c45bd49029060 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,6 +1,6 @@ import sentry_sdk from django.db import router, transaction -from jwt import ExpiredSignatureError, InvalidKeyError, InvalidSignatureError +from jwt import InvalidKeyError from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -67,10 +67,10 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) - except (InvalidSignatureError, ExpiredSignatureError): - lifecycle.record_halt(halt_reason="JWT contained invalid or expired signature") + except Exception: + lifecycle.record_halt(halt_reason="JWT authentication failed") return self.respond( - {"detail": "Invalid or expired signature"}, status=status.HTTP_400_BAD_REQUEST + {"detail": "JWT authentication failed"}, status=status.HTTP_400_BAD_REQUEST ) data = JiraIntegrationProvider().build_integration(state) From 3e7afa4b65d08e7278141fba936598de672eb596 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 10:16:08 -0700 Subject: [PATCH 12/16] unused code --- tests/sentry/integrations/jira/test_installed.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index efbd9ef4935aad..b94fba17c3c833 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -49,9 +49,6 @@ def _jwt_token( headers={**(headers or {}), "alg": jira_signing_algorithm}, ) - def jwt_token_secret(self): - return self._jwt_token("HS256", self.shared_secret) - def jwt_token_cdn(self): return self._jwt_token("RS256", RS256_KEY, headers={"kid": self.kid}) From 0d2da12d5204572a2a0c5b07cf386c495009740a Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 10:32:23 -0700 Subject: [PATCH 13/16] specific --- src/sentry/integrations/jira/webhooks/installed.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 1c45bd49029060..accb10c71934cb 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -1,6 +1,6 @@ import sentry_sdk from django.db import router, transaction -from jwt import InvalidKeyError +from jwt import DecodeError, ExpiredSignatureError, InvalidKeyError, InvalidSignatureError from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -67,10 +67,15 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) - except Exception: - lifecycle.record_halt(halt_reason="JWT authentication failed") + except (InvalidSignatureError, ExpiredSignatureError): + lifecycle.record_halt(halt_reason="JWT contained invalid or expired signature") return self.respond( - {"detail": "JWT authentication failed"}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Invalid or expired signature"}, status=status.HTTP_400_BAD_REQUEST + ) + except DecodeError: + lifecycle.record_halt(halt_reason="Could not decode JWT token") + return self.respond( + {"detail": "Could not decode JWT token"}, status=status.HTTP_400_BAD_REQUEST ) data = JiraIntegrationProvider().build_integration(state) From b0b411cc02f55d47e61c641b0787f48e628583ef Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 13:42:23 -0700 Subject: [PATCH 14/16] record fail for expired signature, add test cases --- .../integrations/jira/webhooks/installed.py | 16 +++- .../integrations/jira/test_installed.py | 87 ++++++++++++++++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index accb10c71934cb..3c906aa15cb1f7 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -67,16 +67,26 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST ) - except (InvalidSignatureError, ExpiredSignatureError): - lifecycle.record_halt(halt_reason="JWT contained invalid or expired signature") + except ExpiredSignatureError as e: + lifecycle.record_failure(e) return self.respond( - {"detail": "Invalid or expired signature"}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Expired signature"}, status=status.HTTP_400_BAD_REQUEST + ) + except InvalidSignatureError: + lifecycle.record_halt(halt_reason="JWT contained invalid signature") + return self.respond( + {"detail": "Invalid signature"}, status=status.HTTP_400_BAD_REQUEST ) except DecodeError: lifecycle.record_halt(halt_reason="Could not decode JWT token") return self.respond( {"detail": "Could not decode JWT token"}, status=status.HTTP_400_BAD_REQUEST ) + except Exception: + lifecycle.record_halt("JWT authentication failed") + return self.respond( + {"detail": "JWT authentication failed"}, status=status.HTTP_400_BAD_REQUEST + ) data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index b94fba17c3c833..d1531530626dc3 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -6,6 +6,7 @@ import jwt import responses +from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError from rest_framework import status from sentry.constants import ObjectStatus @@ -16,7 +17,11 @@ AtlassianConnectValidationError, get_query_hash, ) -from sentry.testutils.asserts import assert_count_of_metric, assert_halt_metric +from sentry.testutils.asserts import ( + assert_count_of_metric, + assert_failure_metric, + assert_halt_metric, +) from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test from sentry.utils.http import absolute_uri @@ -104,7 +109,85 @@ def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: self.get_error_response( **self.body(), extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + @patch( + "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + side_effect=ExpiredSignatureError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_expired_signature( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_response() + + self.get_error_response( + **self.body(), + extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), + status_code=status.HTTP_400_BAD_REQUEST, + ) + # SLO metric asserts + # ENSURE_CONTROL_SILO (success) -> VERIFY_INSTALLATION (failure) -> GET_CONTROL_RESPONSE (success) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.FAILURE, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_failure_metric( + mock_record_event, + ExpiredSignatureError(), + ) + + @patch( + "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + side_effect=InvalidSignatureError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_invalid_signature( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_response() + + self.get_error_response( + **self.body(), + extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), + status_code=status.HTTP_400_BAD_REQUEST, + ) + # SLO metric asserts + # ENSURE_CONTROL_SILO (success) -> VERIFY_INSTALLATION (halt) -> GET_CONTROL_RESPONSE (success) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric( + mock_record_event, + "JWT contained invalid signature", + ) + + @patch( + "sentry.integrations.jira.webhooks.installed.authenticate_asymmetric_jwt", + side_effect=DecodeError(), + ) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @responses.activate + def test_decode_error( + self, mock_record_event: MagicMock, mock_authenticate_asymmetric_jwt: MagicMock + ) -> None: + self.add_response() + + self.get_error_response( + **self.body(), + extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), + status_code=status.HTTP_400_BAD_REQUEST, + ) + # SLO metric asserts + # ENSURE_CONTROL_SILO (success) -> VERIFY_INSTALLATION (halt) -> GET_CONTROL_RESPONSE (success) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 3) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.HALTED, 1) + assert_count_of_metric(mock_record_event, EventLifecycleOutcome.SUCCESS, 2) + assert_halt_metric( + mock_record_event, + "Could not decode JWT token", ) @patch("sentry_sdk.set_tag") From f0be9b79b75e6141342127b308f81f40c0a2f132 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 14:21:54 -0700 Subject: [PATCH 15/16] dont broadly catch exceptions --- src/sentry/integrations/jira/webhooks/installed.py | 5 ----- tests/sentry/integrations/jira/test_installed.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 3c906aa15cb1f7..1ce7b14372985c 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -82,11 +82,6 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond( {"detail": "Could not decode JWT token"}, status=status.HTTP_400_BAD_REQUEST ) - except Exception: - lifecycle.record_halt("JWT authentication failed") - return self.respond( - {"detail": "JWT authentication failed"}, status=status.HTTP_400_BAD_REQUEST - ) data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration(self.provider, data) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index d1531530626dc3..a5326d93b12686 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -109,7 +109,7 @@ def test_no_claims(self, mock_authenticate_asymmetric_jwt: MagicMock) -> None: self.get_error_response( **self.body(), extra_headers=dict(HTTP_AUTHORIZATION="JWT " + self.jwt_token_cdn()), - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_409_CONFLICT, ) @patch( From a48ed95468e1e11916b5450511b05c0b6d4fb6b1 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 8 Apr 2026 17:08:10 -0700 Subject: [PATCH 16/16] wrap peek_header in try/catch --- src/sentry/integrations/jira/webhooks/installed.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 1ce7b14372985c..d43c478f913100 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -44,7 +44,14 @@ def post(self, request: Request, *args, **kwargs) -> Response: lifecycle.record_failure(ProjectManagementFailuresReason.INSTALLATION_STATE_MISSING) return self.respond(status=status.HTTP_400_BAD_REQUEST) - key_id = jwt.peek_header(token).get("kid") + try: + key_id = jwt.peek_header(token).get("kid") + except DecodeError: + lifecycle.record_halt(halt_reason="Failed to fetch key id") + return self.respond( + {"detail": "Failed to fetch key id"}, status=status.HTTP_400_BAD_REQUEST + ) + lifecycle.add_extras( { "key_id": key_id,