Skip to content

Commit 57f0129

Browse files
mdtroandrewshie-sentry
authored andcommitted
fix(oauth): add locking to prevent race conditions in grant exchange (#85570)
Pulled from #82052 to break out these changes into smaller PRs. Use locks to ensure an `ApiGrant` cannot be used twice during a race condition that would result in multiple access/refresh token pairs.
1 parent 8ce756b commit 57f0129

File tree

6 files changed

+108
-19
lines changed

6 files changed

+108
-19
lines changed

src/sentry/models/apigrant.py

Lines changed: 12 additions & 0 deletions
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

Lines changed: 30 additions & 12 deletions
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
@@ -276,17 +278,33 @@ def handle_async_deletion(
276278

277279
@classmethod
278280
def from_grant(cls, grant: ApiGrant):
279-
with transaction.atomic(router.db_for_write(cls)):
280-
api_token = cls.objects.create(
281-
application=grant.application,
282-
user=grant.user,
283-
scope_list=grant.get_scopes(),
284-
scoping_organization_id=grant.organization_id,
285-
)
286-
287-
# remove the ApiGrant from the database to prevent reuse of the same
288-
# authorization code
289-
grant.delete()
281+
if grant.application.status != ApiApplicationStatus.active:
282+
raise InvalidGrantError()
283+
284+
if grant.is_expired():
285+
raise ExpiredGrantError()
286+
287+
lock = locks.get(
288+
ApiGrant.get_lock_key(grant.id),
289+
duration=10,
290+
name="api_grant",
291+
)
292+
293+
# we use a lock to prevent race conditions when creating the ApiToken
294+
# an attacker could send two requests to create an access/refresh token pair
295+
# at the same time, using the same grant, and get two different tokens
296+
with lock.acquire():
297+
with transaction.atomic(router.db_for_write(cls)):
298+
api_token = cls.objects.create(
299+
application=grant.application,
300+
user=grant.user,
301+
scope_list=grant.get_scopes(),
302+
scoping_organization_id=grant.organization_id,
303+
)
304+
305+
# remove the ApiGrant from the database to prevent reuse of the same
306+
# authorization code
307+
grant.delete()
290308

291309
return api_token
292310

src/sentry/sentry_apps/token_exchange/grant_exchanger.py

Lines changed: 16 additions & 5 deletions
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
@@ -35,12 +36,22 @@ class GrantExchanger:
3536
def run(self):
3637
with transaction.atomic(using=router.db_for_write(ApiToken)):
3738
try:
38-
self._validate()
39-
token = self._create_token()
39+
lock = locks.get(
40+
ApiGrant.get_lock_key(self.grant.id),
41+
duration=10,
42+
name="api_grant",
43+
)
44+
45+
# we use a lock to prevent race conditions when creating the ApiToken
46+
# an attacker could send two requests to create an access/refresh token pair
47+
# at the same time, using the same grant, and get two different tokens
48+
with lock.acquire():
49+
self._validate()
50+
token = self._create_token()
4051

41-
# Once it's exchanged it's no longer valid and should not be
42-
# exchangeable, so we delete it.
43-
self._delete_grant()
52+
# Once it's exchanged it's no longer valid and should not be
53+
# exchangeable, so we delete it.
54+
self._delete_grant()
4455
except SentryAppIntegratorError:
4556
logger.info(
4657
"grant-exchanger.context",

src/sentry/web/frontend/oauth_token.py

Lines changed: 11 additions & 2 deletions
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

Lines changed: 16 additions & 0 deletions
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

Lines changed: 23 additions & 0 deletions
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)