Skip to content

Commit 78eef34

Browse files
feat(auth): Gate OAuth React entry points behind an option (#128288)
OAuth authorization needs a controlled cutover to the React flows after the frontend and API changes are deployed. Register `auth.oauth-react-enabled`, defaulting to false. When enabled, browser authorization and device verification GET requests serve the React application, including unauthenticated device requests so the flow can handle login. Existing POST handling remains available. Keep both OAuth entry points on the primary domain in multi-region deployments, matching login. Customer-domain requests redirect to the primary host with their query strings preserved, and an active organization does not move primary-domain requests to an organization host.
1 parent 4dbd263 commit 78eef34

6 files changed

Lines changed: 129 additions & 3 deletions

File tree

‎src/sentry/options/defaults.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@
283283
default=False,
284284
flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_REQUIRED,
285285
)
286+
register(
287+
"auth.oauth-react-enabled",
288+
default=False,
289+
flags=FLAG_AUTOMATOR_MODIFIABLE,
290+
)
286291
register(
287292
"auth.email-verification-at-signup.rollout-rate",
288293
type=Float,

‎src/sentry/web/frontend/oauth_authorize.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from django.utils.safestring import mark_safe
1313
from django.views.decorators.cache import never_cache
1414

15-
from sentry import analytics
15+
from sentry import analytics, options
1616
from sentry.analytics.events.oauth_consent import OAuthConsentEvent
1717
from sentry.api.helpers.oauth_authorize.approval import approve_authorization
1818
from sentry.api.helpers.oauth_authorize.request import build_authorization_request
@@ -37,13 +37,14 @@
3737
from sentry.utils import auth
3838
from sentry.web.client_config import get_client_config
3939
from sentry.web.frontend.base import BaseView, control_silo_view
40+
from sentry.web.frontend.react_page import ReactMixin
4041

4142
logger = logging.getLogger("sentry.oauth")
4243

4344

4445
@control_silo_view
4546
@method_decorator(never_cache, name="dispatch")
46-
class OAuthAuthorizeView(BaseView):
47+
class OAuthAuthorizeView(BaseView, ReactMixin):
4748
enforce_rate_limit = True
4849
rate_limits = RateLimitConfig(
4950
limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=20, window=1)}}
@@ -114,6 +115,9 @@ def error(
114115
return self.redirect_response(response_type, redirect_uri, {"error": name, "state": state})
115116

116117
def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase:
118+
if options.get("auth.oauth-react-enabled"):
119+
return self.handle_react(request)
120+
117121
try:
118122
serializer = OAuthAuthorizeQuerySerializer(data=request.GET)
119123
if not serializer.is_valid():

‎src/sentry/web/frontend/oauth_device.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

3+
from typing import Any
4+
35
from django.contrib.auth.views import redirect_to_login
46
from django.http import HttpRequest, HttpResponse
57
from django.http.response import HttpResponseBase
68
from django.urls import reverse
79
from django.utils.decorators import method_decorator
810
from django.views.decorators.cache import never_cache
911

12+
from sentry import options
1013
from sentry.api.helpers.oauth_consent import (
1114
InvalidOrganizationSelection,
1215
get_organization_options,
@@ -28,6 +31,7 @@
2831
from sentry.utils import auth
2932
from sentry.web.client_config import get_client_config
3033
from sentry.web.frontend.base import BaseView, control_silo_view
34+
from sentry.web.frontend.react_page import ReactMixin
3135

3236
# Standardized error messages
3337
ERR_NO_ORG_ACCESS = (
@@ -40,7 +44,7 @@
4044

4145
@control_silo_view
4246
@method_decorator(never_cache, name="dispatch")
43-
class OAuthDeviceView(BaseView):
47+
class OAuthDeviceView(BaseView, ReactMixin):
4448
"""
4549
Device verification page for OAuth 2.0 Device Flow (RFC 8628 §3.3).
4650
@@ -72,6 +76,12 @@ class OAuthDeviceView(BaseView):
7276
limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=20, window=1)}}
7377
)
7478

79+
def is_auth_required(self, request: HttpRequest, *args: Any, **kwargs: Any) -> bool:
80+
if request.method == "GET" and options.get("auth.oauth-react-enabled"):
81+
return False
82+
83+
return super().is_auth_required(request, *args, **kwargs)
84+
7585
def handle_auth_required(self, request: HttpRequest, **kwargs) -> HttpResponse:
7686
auth.initiate_login(request, next_url=request.get_full_path())
7787
return redirect_to_login(request.get_full_path(), reverse("sentry-login"))
@@ -91,6 +101,9 @@ def _error_response(self, request: HttpRequest, error: str) -> HttpResponseBase:
91101
return self.respond("sentry/oauth-device.html", context)
92102

93103
def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase:
104+
if options.get("auth.oauth-react-enabled"):
105+
return self.handle_react(request)
106+
94107
# RFC 8628 §3.3.1 allows `verification_uri_complete` to carry the user code.
95108
# We validate it here and advance to the approval step, but the user must
96109
# still explicitly approve or deny the request per §3.3 and the

‎src/sentry/web/frontend/react_page.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
MULTI_REGION_NON_CUSTOMER_DOMAIN_URL_NAMES = [
3939
"sentry-login",
4040
"sentry-auth-organization",
41+
"sentry-oauth-authorize",
42+
"sentry-oauth-device",
4143
]
4244

4345

‎tests/sentry/web/frontend/test_oauth_authorize.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from sentry.analytics.events.oauth_consent import OAuthConsentEvent
99
from sentry.api.helpers.oauth_authorize.transactions import OAUTH_AUTHORIZE_SESSION_TTL
10+
from sentry.api.helpers.oauth_consent import persist_authorization
1011
from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus
1112
from sentry.models.apiauthorization import ApiAuthorization
1213
from sentry.models.apigrant import ApiGrant
@@ -29,6 +30,58 @@ def setUp(self) -> None:
2930
owner=self.user, redirect_uris="https://example.com"
3031
)
3132

33+
@override_options({"auth.oauth-react-enabled": True})
34+
def test_react_stays_on_primary_domain_with_active_organization(self) -> None:
35+
organization = self.create_organization(owner=self.user)
36+
self.login_as(self.user)
37+
session = self.client.session
38+
session["activeorg"] = organization.slug
39+
session.save()
40+
path = f"{self.path}?client_id=example-client&state=example-state"
41+
42+
with self.feature({"system:multi-region": True}):
43+
response = self.client.get(path, HTTP_HOST="testserver", follow=True)
44+
45+
assert response.status_code == 200
46+
assert response.redirect_chain == []
47+
self.assertTemplateUsed(response, "sentry/base-react.html")
48+
49+
@override_options({"auth.oauth-react-enabled": True})
50+
def test_react_customer_domain_redirects_to_primary_domain(self) -> None:
51+
organization = self.create_organization(owner=self.user)
52+
self.login_as(self.user)
53+
session = self.client.session
54+
session["activeorg"] = organization.slug
55+
session.save()
56+
path = f"{self.path}?client_id=example-client&state=example-state"
57+
58+
with self.feature({"system:multi-region": True}):
59+
response = self.client.get(
60+
path, HTTP_HOST=f"{organization.slug}.testserver", follow=True
61+
)
62+
63+
assert response.status_code == 200
64+
assert response.redirect_chain == [(f"http://testserver{path}", 302)]
65+
self.assertTemplateUsed(response, "sentry/base-react.html")
66+
67+
@override_options({"auth.oauth-react-enabled": True})
68+
def test_react_authorization_is_public(self) -> None:
69+
response = self.client.get(self.path, {"client_id": self.application.client_id})
70+
assert response.status_code == 200
71+
self.assertTemplateUsed(response, "sentry/base-react.html")
72+
73+
@override_options({"auth.oauth-react-enabled": True})
74+
def test_react_authorization_does_not_issue_existing_consent_grant(self) -> None:
75+
self.login_as(self.user)
76+
persist_authorization(self.application, self.user.id, [], None)
77+
response = self.client.get(
78+
self.path,
79+
{"client_id": self.application.client_id, "response_type": "code"},
80+
)
81+
assert response.status_code == 200
82+
self.assertTemplateUsed(response, "sentry/base-react.html")
83+
assert not ApiGrant.objects.filter(application=self.application).exists()
84+
3285
def test_blank_pkce_challenge_returns_oauth_error(self) -> None:
3386
response = self.client.get(
3487
self.path,

‎tests/sentry/web/frontend/test_oauth_device.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sentry.models.apiauthorization import ApiAuthorization
1111
from sentry.models.apidevicecode import ApiDeviceCode, DeviceCodeStatus
1212
from sentry.testutils.cases import TestCase
13+
from sentry.testutils.helpers import override_options
1314
from sentry.testutils.silo import control_silo_test
1415

1516

@@ -39,6 +40,54 @@ def test_get_unauthenticated_redirects_to_login(self) -> None:
3940
fetch_redirect_response=False,
4041
)
4142

43+
@override_options({"auth.oauth-react-enabled": True})
44+
def test_react_stays_on_primary_domain_with_active_organization(self) -> None:
45+
organization = self.create_organization(owner=self.user)
46+
self.login_as(self.user)
47+
session = self.client.session
48+
session["activeorg"] = organization.slug
49+
session.save()
50+
path = f"{self.path}?user_code=ABCD-EFGH"
51+
52+
with self.feature({"system:multi-region": True}):
53+
response = self.client.get(path, HTTP_HOST="testserver", follow=True)
54+
55+
assert response.status_code == 200
56+
assert response.redirect_chain == []
57+
self.assertTemplateUsed(response, "sentry/base-react.html")
58+
59+
@override_options({"auth.oauth-react-enabled": True})
60+
def test_react_customer_domain_redirects_to_primary_domain(self) -> None:
61+
organization = self.create_organization(owner=self.user)
62+
self.login_as(self.user)
63+
session = self.client.session
64+
session["activeorg"] = organization.slug
65+
session.save()
66+
path = f"{self.path}?user_code=ABCD-EFGH"
67+
68+
with self.feature({"system:multi-region": True}):
69+
response = self.client.get(
70+
path, HTTP_HOST=f"{organization.slug}.testserver", follow=True
71+
)
72+
73+
assert response.status_code == 200
74+
assert response.redirect_chain == [(f"http://testserver{path}", 302)]
75+
self.assertTemplateUsed(response, "sentry/base-react.html")
76+
77+
@override_options({"auth.oauth-react-enabled": True})
78+
def test_react_code_entry_is_public(self) -> None:
79+
response = self.client.get(self.path)
80+
assert response.status_code == 200
81+
self.assertTemplateUsed(response, "sentry/base-react.html")
82+
83+
@override_options({"auth.oauth-react-enabled": True})
84+
def test_react_verification_is_public(self) -> None:
85+
response = self.client.get(self.path, {"user_code": self.device_code.user_code})
86+
assert response.status_code == 200
87+
self.assertTemplateUsed(response, "sentry/base-react.html")
88+
self.device_code.refresh_from_db()
89+
assert self.device_code.status == DeviceCodeStatus.PENDING
90+
4291
def test_get_preserves_user_code_in_login_destination(self) -> None:
4392
full_path = f"{self.path}?user_code={self.device_code.user_code}"
4493
resp = self.client.get(full_path)

0 commit comments

Comments
 (0)