Skip to content

Commit 4e0e5a3

Browse files
committed
rm bitbucket changes, handle symmetric and assymetric paths
1 parent 80d877e commit 4e0e5a3

File tree

4 files changed

+40
-177
lines changed

4 files changed

+40
-177
lines changed

src/sentry/integrations/bitbucket/installed.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
from django.http.request import HttpRequest
22
from django.http.response import HttpResponseBase
33
from django.views.decorators.csrf import csrf_exempt
4-
from rest_framework import status
54
from rest_framework.request import Request
65
from rest_framework.response import Response
76

87
from sentry.api.api_owners import ApiOwner
98
from sentry.api.api_publish_status import ApiPublishStatus
109
from sentry.api.base import Endpoint, control_silo_endpoint
11-
from sentry.integrations.jira.webhooks.installed import INVALID_KEY_IDS
1210
from sentry.integrations.pipeline import ensure_integration
1311
from sentry.integrations.types import IntegrationProviderSlug
14-
from sentry.integrations.utils.atlassian_connect import (
15-
AtlassianConnectValidationError,
16-
authenticate_asymmetric_jwt,
17-
get_token,
18-
verify_claims,
19-
)
20-
from sentry.utils import jwt
2112

2213
from .integration import BitbucketIntegrationProvider
2314

@@ -36,39 +27,7 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase:
3627
return super().dispatch(request, *args, **kwargs)
3728

3829
def post(self, request: Request, *args, **kwargs) -> Response:
39-
try:
40-
token = get_token(request)
41-
except AtlassianConnectValidationError:
42-
return self.respond(
43-
{"detail": "Missing authorization header"}, status=status.HTTP_400_BAD_REQUEST
44-
)
45-
46-
try:
47-
key_id = jwt.peek_header(token).get("kid")
48-
except jwt.DecodeError:
49-
return self.respond(
50-
{"detail": "Invalid JWT token"}, status=status.HTTP_400_BAD_REQUEST
51-
)
52-
if not key_id:
53-
return self.respond({"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST)
54-
55-
if key_id in INVALID_KEY_IDS:
56-
return self.respond({"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST)
57-
58-
try:
59-
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
60-
verify_claims(decoded_claims, request.path, request.GET, method="POST")
61-
except AtlassianConnectValidationError:
62-
return self.respond(
63-
{"detail": "Could not validate JWT"}, status=status.HTTP_400_BAD_REQUEST
64-
)
65-
6630
state = request.data
67-
if decoded_claims.get("iss") != state.get("clientKey"):
68-
return self.respond(
69-
{"detail": "JWT issuer does not match client key"},
70-
status=status.HTTP_400_BAD_REQUEST,
71-
)
7231
data = BitbucketIntegrationProvider().build_integration(state)
7332
ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data)
7433

src/sentry/integrations/jira/webhooks/installed.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sentry_sdk
22
from django.db import router, transaction
3+
from jwt import DecodeError, ExpiredSignatureError, InvalidAlgorithmError, InvalidSignatureError
34
from rest_framework import status
45
from rest_framework.request import Request
56
from rest_framework.response import Response
@@ -55,21 +56,41 @@ def post(self, request: Request, *args, **kwargs) -> Response:
5556
"clientKey": state.get("clientKey", ""),
5657
}
5758
)
58-
if not key_id:
59-
return self.respond(
60-
{"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST
61-
)
6259

63-
if key_id in INVALID_KEY_IDS:
64-
lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)")
65-
return self.respond(
66-
{"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST
67-
)
60+
if key_id:
61+
if key_id in INVALID_KEY_IDS:
62+
lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)")
63+
return self.respond(
64+
{"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST
65+
)
66+
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
67+
else:
68+
shared_secret = state.get("sharedSecret")
69+
if not shared_secret:
70+
return self.respond(
71+
{"detail": "Missing shared secret"}, status=status.HTTP_400_BAD_REQUEST
72+
)
73+
try:
74+
decoded_claims = jwt.decode(token, shared_secret, audience=False)
75+
except (
76+
InvalidSignatureError,
77+
ExpiredSignatureError,
78+
DecodeError,
79+
InvalidAlgorithmError,
80+
):
81+
return self.respond(
82+
{"detail": "Invalid JWT"}, status=status.HTTP_400_BAD_REQUEST
83+
)
84+
6885
if decoded_claims.get("iss") != state.get("clientKey"):
6986
lifecycle.record_halt(halt_reason="JWT issuer does not match client key")
7087
return self.respond(
71-
{"detail": "JWT issuer does not match client key"}, status=status.HTTP_400_BAD_REQUEST
88+
{"detail": "JWT issuer does not match client key"},
89+
status=status.HTTP_400_BAD_REQUEST,
7290
)
91+
92+
verify_claims(decoded_claims, request.path, request.GET, method="POST")
93+
data = JiraIntegrationProvider().build_integration(state)
7394
integration = ensure_integration(self.provider, data)
7495

7596
# Note: Unlike in all other Jira webhooks, we don't call `bind_org_context_from_integration`

tests/sentry/integrations/bitbucket/test_installed.py

Lines changed: 6 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
from typing import Any
44
from unittest import mock
55

6-
import jwt as pyjwt
76
import responses
87

98
from sentry.integrations.bitbucket.installed import BitbucketInstalledEndpoint
109
from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider, scopes
1110
from sentry.integrations.models.integration import Integration
12-
from sentry.integrations.utils.atlassian_connect import get_query_hash
1311
from sentry.models.project import Project
1412
from sentry.models.repository import Repository
1513
from sentry.organizations.services.organization.serial import serialize_rpc_organization
@@ -18,8 +16,6 @@
1816
from sentry.silo.base import SiloMode
1917
from sentry.testutils.cases import APITestCase
2018
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
21-
from sentry.utils.http import absolute_uri
22-
from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY
2319

2420

2521
class BitbucketPlugin(IssueTrackingPlugin2):
@@ -33,7 +29,6 @@ class BitbucketInstalledEndpointTest(APITestCase):
3329
def setUp(self) -> None:
3430
self.provider = "bitbucket"
3531
self.path = "/extensions/bitbucket/installed/"
36-
self.kid = "bitbucket-kid"
3732
self.username = "sentryuser"
3833
self.client_key = "connection:123"
3934
self.public_key = "123abcDEFg"
@@ -103,118 +98,27 @@ def tearDown(self) -> None:
10398
plugins.unregister(BitbucketPlugin)
10499
super().tearDown()
105100

106-
def jwt_token_cdn(self) -> str:
107-
return pyjwt.encode(
108-
{
109-
"iss": self.client_key,
110-
"aud": absolute_uri(),
111-
"qsh": get_query_hash(self.path, method="POST", query_params={}),
112-
},
113-
RS256_KEY,
114-
algorithm="RS256",
115-
headers={"kid": self.kid, "alg": "RS256"},
116-
)
117-
118-
def add_cdn_response(self) -> None:
119-
responses.add(
120-
responses.GET,
121-
f"https://connect-install-keys.atlassian.com/{self.kid}",
122-
body=RS256_PUB_KEY,
123-
)
124-
125101
def test_default_permissions(self) -> None:
126102
# Permissions must be empty so that it will be accessible to bitbucket.
127103
assert BitbucketInstalledEndpoint.authentication_classes == ()
128104
assert BitbucketInstalledEndpoint.permission_classes == ()
129105

130-
def test_missing_token(self) -> None:
131-
response = self.client.post(self.path, data=self.team_data_from_bitbucket)
132-
assert response.status_code == 400
133-
134-
def test_invalid_token(self) -> None:
135-
response = self.client.post(
136-
self.path,
137-
data=self.team_data_from_bitbucket,
138-
HTTP_AUTHORIZATION="invalid",
139-
)
140-
assert response.status_code == 400
141-
142-
@responses.activate
143-
def test_missing_key_id(self) -> None:
144-
token = pyjwt.encode(
145-
{
146-
"iss": self.client_key,
147-
"aud": absolute_uri(),
148-
"qsh": get_query_hash(self.path, method="POST", query_params={}),
149-
},
150-
RS256_KEY,
151-
algorithm="RS256",
152-
headers={"alg": "RS256"},
153-
)
154-
response = self.client.post(
155-
self.path,
156-
data=self.team_data_from_bitbucket,
157-
HTTP_AUTHORIZATION=f"JWT {token}",
158-
)
159-
assert response.status_code == 400
160-
161-
@responses.activate
162-
def test_invalid_key_id(self) -> None:
163-
token = pyjwt.encode(
164-
{
165-
"iss": self.client_key,
166-
"aud": absolute_uri(),
167-
"qsh": get_query_hash(self.path, method="POST", query_params={}),
168-
},
169-
RS256_KEY,
170-
algorithm="RS256",
171-
headers={"kid": "fake-kid", "alg": "RS256"},
172-
)
173-
response = self.client.post(
174-
self.path,
175-
data=self.team_data_from_bitbucket,
176-
HTTP_AUTHORIZATION=f"JWT {token}",
177-
)
178-
assert response.status_code == 400
179-
180-
@responses.activate
181-
def test_jwt_issuer_mismatch(self) -> None:
182-
self.add_cdn_response()
183-
response = self.client.post(
184-
self.path,
185-
data={**self.team_data_from_bitbucket, "clientKey": "different-client-key"},
186-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
187-
)
188-
assert response.status_code == 400
189-
190-
@responses.activate
191106
def test_installed_with_public_key(self) -> None:
192-
self.add_cdn_response()
193-
response = self.client.post(
194-
self.path,
195-
data=self.team_data_from_bitbucket,
196-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
197-
)
107+
response = self.client.post(self.path, data=self.team_data_from_bitbucket)
198108
assert response.status_code == 200
199109
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
200110
assert integration.name == self.username
201111
del integration.metadata["webhook_secret"]
202112
assert integration.metadata == self.metadata
203113

204-
@responses.activate
205114
def test_installed_without_public_key(self) -> None:
206115
integration, created = Integration.objects.get_or_create(
207116
provider=self.provider,
208117
external_id=self.client_key,
209118
defaults={"name": self.user_display_name, "metadata": self.user_metadata},
210119
)
211120
del self.user_data_from_bitbucket["principal"]["username"]
212-
self.add_cdn_response()
213-
response = self.client.post(
214-
self.path,
215-
data=self.user_data_from_bitbucket,
216-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
217-
)
121+
response = self.client.post(self.path, data=self.user_data_from_bitbucket)
218122
assert response.status_code == 200
219123

220124
# assert no changes have been made to the integration
@@ -225,34 +129,22 @@ def test_installed_without_public_key(self) -> None:
225129
del integration_after.metadata["webhook_secret"]
226130
assert integration.metadata == integration_after.metadata
227131

228-
@responses.activate
229132
def test_installed_without_username(self) -> None:
230133
"""Test a user (not team) installation where the user has hidden their username from public view"""
231134

232135
# Remove username to simulate privacy mode
233136
del self.user_data_from_bitbucket["principal"]["username"]
234137

235-
self.add_cdn_response()
236-
response = self.client.post(
237-
self.path,
238-
data=self.user_data_from_bitbucket,
239-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
240-
)
138+
response = self.client.post(self.path, data=self.user_data_from_bitbucket)
241139
assert response.status_code == 200
242140
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
243141
assert integration.name == self.user_display_name
244142
del integration.metadata["webhook_secret"]
245143
assert integration.metadata == self.user_metadata
246144

247-
@responses.activate
248145
@mock.patch("sentry.integrations.bitbucket.integration.generate_token", return_value="0" * 64)
249146
def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> None:
250-
self.add_cdn_response()
251-
response = self.client.post(
252-
self.path,
253-
data=self.team_data_from_bitbucket,
254-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
255-
)
147+
response = self.client.post(self.path, data=self.team_data_from_bitbucket)
256148
assert mock_generate_token.called
257149
assert response.status_code == 200
258150
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
@@ -280,12 +172,7 @@ def test_plugin_migration(self) -> None:
280172
config={"name": "otheruser/otherrepo"},
281173
)
282174

283-
self.add_cdn_response()
284-
self.client.post(
285-
self.path,
286-
data=self.team_data_from_bitbucket,
287-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
288-
)
175+
self.client.post(self.path, data=self.team_data_from_bitbucket)
289176

290177
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
291178

@@ -332,12 +219,7 @@ def test_disable_plugin_when_fully_migrated(self) -> None:
332219
config={"name": "sentryuser/repo"},
333220
)
334221

335-
self.add_cdn_response()
336-
self.client.post(
337-
self.path,
338-
data=self.team_data_from_bitbucket,
339-
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
340-
)
222+
self.client.post(self.path, data=self.team_data_from_bitbucket)
341223

342224
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
343225

tests/sentry/integrations/jira/test_installed.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class JiraInstalledTest(APITestCase):
3131
kid = "cudi"
3232
shared_secret = "garden"
3333
path = "/extensions/jira/installed/"
34+
client_key = "limepie"
3435

3536
def _jwt_token(
3637
self,
@@ -40,7 +41,7 @@ def _jwt_token(
4041
) -> str:
4142
return jwt.encode(
4243
{
43-
"iss": self.external_id,
44+
"iss": self.client_key,
4445
"aud": absolute_uri(),
4546
"qsh": get_query_hash(self.path, method="POST", query_params={}),
4647
},
@@ -61,7 +62,7 @@ def body(self) -> Mapping[str, Any]:
6162
"metadata": {},
6263
"external_id": self.external_id,
6364
},
64-
"clientKey": "limepie",
65+
"clientKey": self.client_key,
6566
"oauthClientId": "EFG",
6667
"publicKey": "yourCar",
6768
"sharedSecret": self.shared_secret,

0 commit comments

Comments
 (0)