Skip to content

Commit c9445b0

Browse files
committed
fix: add locking to grants to avoid race conditions
1 parent f7d3e36 commit c9445b0

File tree

8 files changed

+139
-35
lines changed

8 files changed

+139
-35
lines changed

src/sentry/api/endpoints/api_authorizations.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sentry.hybridcloud.models.outbox import outbox_context
1313
from sentry.models.apiapplication import ApiApplicationStatus
1414
from sentry.models.apiauthorization import ApiAuthorization
15+
from sentry.models.apigrant import ApiGrant
1516
from sentry.models.apitoken import ApiToken
1617

1718

@@ -44,18 +45,33 @@ def delete(self, request: Request) -> Response:
4445
return Response({"authorization": ""}, status=400)
4546

4647
try:
47-
auth = ApiAuthorization.objects.get(user_id=request.user.id, id=authorization)
48+
api_authorization = ApiAuthorization.objects.get(
49+
user_id=request.user.id, id=authorization
50+
)
4851
except ApiAuthorization.DoesNotExist:
4952
return Response(status=404)
5053

5154
with outbox_context(transaction.atomic(using=router.db_for_write(ApiToken)), flush=False):
5255
for token in ApiToken.objects.filter(
5356
user_id=request.user.id,
54-
application=auth.application_id,
55-
scoping_organization_id=auth.organization_id,
57+
application=api_authorization.application_id,
58+
scoping_organization_id=api_authorization.organization_id,
5659
):
5760
token.delete()
5861

59-
auth.delete()
62+
# remove any grants that were created from this authorization
63+
# that may not have been exchanged for a token yet
64+
with outbox_context(transaction.atomic(using=router.db_for_write(ApiGrant)), flush=False):
65+
for grant in ApiGrant.objects.filter(
66+
user_id=request.user.id,
67+
application=api_authorization.application_id,
68+
organization_id=api_authorization.organization_id,
69+
):
70+
grant.delete()
71+
72+
with outbox_context(
73+
transaction.atomic(using=router.db_for_write(ApiAuthorization)), flush=False
74+
):
75+
api_authorization.delete()
6076

6177
return Response(status=204)

src/sentry/models/apigrant.py

+12
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
DEFAULT_EXPIRATION = timedelta(minutes=10)
1616

1717

18+
class InvalidGrantError(Exception):
19+
pass
20+
21+
22+
class ExpiredGrantError(Exception):
23+
pass
24+
25+
1826
def default_expiration():
1927
return timezone.now() + DEFAULT_EXPIRATION
2028

@@ -97,6 +105,10 @@ def is_expired(self):
97105
def redirect_uri_allowed(self, uri):
98106
return uri == self.redirect_uri
99107

108+
@classmethod
109+
def get_lock_key(cls, grant_id):
110+
return f"api_grant:{grant_id}"
111+
100112
@classmethod
101113
def sanitize_relocation_json(
102114
cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None

src/sentry/models/apitoken.py

+30-12
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
2020
from sentry.hybridcloud.outbox.base import ControlOutboxProducingManager, ReplicatedControlModel
2121
from sentry.hybridcloud.outbox.category import OutboxCategory
22-
from sentry.models.apigrant import ApiGrant
22+
from sentry.locks import locks
23+
from sentry.models.apiapplication import ApiApplicationStatus
24+
from sentry.models.apigrant import ApiGrant, ExpiredGrantError, InvalidGrantError
2325
from sentry.models.apiscopes import HasApiScopes
2426
from sentry.types.region import find_all_region_names
2527
from sentry.types.token import AuthTokenType
@@ -261,17 +263,33 @@ def handle_async_replication(self, region_name: str, shard_identifier: int) -> N
261263

262264
@classmethod
263265
def from_grant(cls, grant: ApiGrant):
264-
with transaction.atomic(router.db_for_write(cls)):
265-
api_token = cls.objects.create(
266-
application=grant.application,
267-
user=grant.user,
268-
scope_list=grant.get_scopes(),
269-
scoping_organization_id=grant.organization_id,
270-
)
271-
272-
# remove the ApiGrant from the database to prevent reuse of the same
273-
# authorization code
274-
grant.delete()
266+
if grant.application.status != ApiApplicationStatus.active:
267+
raise InvalidGrantError()
268+
269+
if grant.is_expired():
270+
raise ExpiredGrantError()
271+
272+
lock = locks.get(
273+
ApiGrant.get_lock_key(grant.id),
274+
duration=10,
275+
name="api_grant",
276+
)
277+
278+
# we use a lock to prevent race conditions when creating the ApiToken
279+
# an attacker could send two requests to create an access/refresh token pair
280+
# at the same time, using the same grant, and get two different tokens
281+
with lock.acquire():
282+
with transaction.atomic(router.db_for_write(cls)):
283+
api_token = cls.objects.create(
284+
application=grant.application,
285+
user=grant.user,
286+
scope_list=grant.get_scopes(),
287+
scoping_organization_id=grant.organization_id,
288+
)
289+
290+
# remove the ApiGrant from the database to prevent reuse of the same
291+
# authorization code
292+
grant.delete()
275293

276294
return api_token
277295

src/sentry/sentry_apps/api/endpoints/sentry_app_authorizations.py

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from sentry.sentry_apps.token_exchange.grant_exchanger import GrantExchanger
1515
from sentry.sentry_apps.token_exchange.refresher import Refresher
1616
from sentry.sentry_apps.token_exchange.util import GrantTypes
17+
from sentry.utils.locking import UnableToAcquireLock
1718
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError
1819

1920
logger = logging.getLogger(__name__)
@@ -86,6 +87,8 @@ def post(self, request: Request, installation) -> Response:
8687
},
8788
)
8889
raise
90+
except UnableToAcquireLock:
91+
return Response({"error": "invalid_grant"}, status=409)
8992

9093
attrs = {"state": request.data.get("state"), "application": None}
9194

src/sentry/sentry_apps/token_exchange/grant_exchanger.py

+24-17
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.utils.functional import cached_property
77

88
from sentry import analytics
9+
from sentry.locks import locks
910
from sentry.models.apiapplication import ApiApplication
1011
from sentry.models.apigrant import ApiGrant
1112
from sentry.models.apitoken import ApiToken
@@ -33,23 +34,29 @@ class GrantExchanger:
3334
user: User
3435

3536
def run(self):
36-
with transaction.atomic(using=router.db_for_write(ApiToken)):
37-
try:
38-
self._validate()
39-
token = self._create_token()
40-
41-
# Once it's exchanged it's no longer valid and should not be
42-
# exchangeable, so we delete it.
43-
self._delete_grant()
44-
except SentryAppIntegratorError:
45-
logger.info(
46-
"grant-exchanger.context",
47-
extra={
48-
"application_id": self.application.id,
49-
"grant_id": self.grant.id,
50-
},
51-
)
52-
raise
37+
lock = locks.get(
38+
ApiGrant.get_lock_key(self.grant.id),
39+
duration=10,
40+
name="api_grant",
41+
)
42+
with lock.acquire():
43+
with transaction.atomic(using=router.db_for_write(ApiToken)):
44+
try:
45+
self._validate()
46+
token = self._create_token()
47+
48+
# Once it's exchanged it's no longer valid and should not be
49+
# exchangeable, so we delete it.
50+
self._delete_grant()
51+
except SentryAppIntegratorError:
52+
logger.info(
53+
"grant-exchanger.context",
54+
extra={
55+
"application_id": self.application.id,
56+
"grant_id": self.grant.id,
57+
},
58+
)
59+
raise
5360
self.record_analytics()
5461

5562
return token

src/sentry/web/frontend/oauth_token.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sentry.models.apitoken import ApiToken
1919
from sentry.sentry_apps.token_exchange.util import GrantTypes
2020
from sentry.utils import json, metrics
21+
from sentry.utils.locking import UnableToAcquireLock
2122
from sentry.web.frontend.base import control_silo_view
2223
from sentry.web.frontend.openidtoken import OpenIDToken
2324

@@ -128,7 +129,9 @@ def post(self, request: Request) -> HttpResponse:
128129
def get_access_tokens(self, request: Request, application: ApiApplication) -> dict:
129130
code = request.POST.get("code")
130131
try:
131-
grant = ApiGrant.objects.get(application=application, code=code)
132+
grant = ApiGrant.objects.get(
133+
application=application, application__status=ApiApplicationStatus.active, code=code
134+
)
132135
except ApiGrant.DoesNotExist:
133136
return {"error": "invalid_grant", "reason": "invalid grant"}
134137

@@ -141,7 +144,12 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di
141144
elif grant.redirect_uri != redirect_uri:
142145
return {"error": "invalid_grant", "reason": "invalid redirect URI"}
143146

144-
token_data = {"token": ApiToken.from_grant(grant=grant)}
147+
try:
148+
token_data = {"token": ApiToken.from_grant(grant=grant)}
149+
except UnableToAcquireLock:
150+
# TODO(mdtro): we should return a 409 status code here
151+
return {"error": "invalid_grant", "reason": "invalid grant"}
152+
145153
if grant.has_scope("openid") and options.get("codecov.signing_secret"):
146154
open_id_token = OpenIDToken(
147155
request.POST.get("client_id"),
@@ -150,6 +158,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di
150158
nonce=request.POST.get("nonce"),
151159
)
152160
token_data["id_token"] = open_id_token.get_signed_id_token(grant=grant)
161+
153162
return token_data
154163

155164
def get_refresh_token(self, request: Request, application: ApiApplication) -> dict:

tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py

+16
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ def test_deletes_grant_on_successful_exchange(self):
104104
self.grant_exchanger.run()
105105
assert not ApiGrant.objects.filter(id=grant_id)
106106

107+
def test_race_condition_on_grant_exchange(self):
108+
from sentry.locks import locks
109+
from sentry.utils.locking import UnableToAcquireLock
110+
111+
# simulate a race condition on the grant exchange
112+
grant_id = self.orm_install.api_grant_id
113+
lock = locks.get(
114+
ApiGrant.get_lock_key(grant_id),
115+
duration=10,
116+
name="api_grant",
117+
)
118+
lock.acquire()
119+
120+
with pytest.raises(UnableToAcquireLock):
121+
self.grant_exchanger.run()
122+
107123
@patch("sentry.analytics.record")
108124
def test_records_analytics(self, record):
109125
GrantExchanger(

tests/sentry/web/frontend/test_oauth_token.py

+23
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.utils import timezone
44

5+
from sentry.locks import locks
56
from sentry.models.apiapplication import ApiApplication
67
from sentry.models.apigrant import ApiGrant
78
from sentry.models.apitoken import ApiToken
@@ -203,6 +204,28 @@ def test_one_time_use_grant(self):
203204
)
204205
assert resp.status_code == 400
205206

207+
def test_grant_lock(self):
208+
self.login_as(self.user)
209+
210+
# Simulate a concurrent request by using an existing grant
211+
# that has its grant lock taken out.
212+
lock = locks.get(ApiGrant.get_lock_key(self.grant.id), duration=10, name="api_grant")
213+
lock.acquire()
214+
215+
# Attempt to create a token with the same grant
216+
# This should fail because the lock is held by the previous request
217+
resp = self.client.post(
218+
self.path,
219+
{
220+
"grant_type": "authorization_code",
221+
"code": self.grant.code,
222+
"client_id": self.application.client_id,
223+
"client_secret": self.client_secret,
224+
},
225+
)
226+
assert resp.status_code == 400
227+
assert resp.json() == {"error": "invalid_grant"}
228+
206229
def test_invalid_redirect_uri(self):
207230
self.login_as(self.user)
208231

0 commit comments

Comments
 (0)