Skip to content

Commit c793d3e

Browse files
test(auth): Cover organization SSO flows (#122342) by
Adds browser-level coverage for organization SSO, SSO followed by TOTP, password-only organizations, required-SSO access boundaries, fallback to a password-capable membership, and switching an authenticated session into organization-scoped SSO. It also covers organizations whose configured provider allows unlinked password authentication, verifying that password login remains available and returns the user to the selected organization.
1 parent 3f86c71 commit c793d3e

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

tests/acceptance/test_auth_react.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,55 @@ def submit_second_factor(self, code: str) -> None:
7171
code
7272
)
7373

74+
def locate_organization_sso(self, organization_slug: str) -> None:
75+
self.open_login()
76+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='Organization SSO']")
77+
self.browser.element('[aria-label="Organization Slug"]').send_keys(organization_slug)
78+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='Locate']")
79+
80+
def select_organization_sso(self, organization_slug: str) -> None:
81+
self.locate_organization_sso(organization_slug)
82+
self.browser.wait_until(
83+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
84+
)
85+
86+
def begin_organization_sso(self, organization_slug: str) -> None:
87+
self.select_organization_sso(organization_slug)
88+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
89+
90+
# The dummy provider renders its email challenge inline as the initiation response.
91+
# A real provider redirects to its identity service before returning to Sentry.
92+
self.browser.wait_until('form > input[type="email"][name="email"]:only-child')
93+
94+
def complete_dummy_sso(self, email: str) -> None:
95+
csrf_cookie = self.browser.driver.get_cookie(settings.CSRF_COOKIE_NAME)
96+
assert csrf_cookie is not None
97+
98+
# Simulate the provider callback by posting its identity to the shared SSO continuation
99+
# endpoint. Include the browser's CSRF token because this remains a browser-driven POST.
100+
self.browser.driver.execute_script(
101+
"""
102+
const form = document.createElement('form');
103+
form.method = 'POST';
104+
form.action = '/auth/sso/';
105+
106+
const input = document.createElement('input');
107+
input.name = 'email';
108+
input.value = arguments[0];
109+
form.appendChild(input);
110+
111+
const csrfInput = document.createElement('input');
112+
csrfInput.name = 'csrfmiddlewaretoken';
113+
csrfInput.value = arguments[1];
114+
form.appendChild(csrfInput);
115+
116+
document.body.appendChild(form);
117+
form.submit();
118+
""",
119+
email,
120+
csrf_cookie["value"],
121+
)
122+
74123
def wait_for_authenticated_organization(self, organization_slug: str) -> None:
75124
expected_path = f"/organizations/{organization_slug}/issues/"
76125
self.browser.wait_until_script_execution(
@@ -169,3 +218,175 @@ def test_multi_organization_login(self) -> None:
169218
self.clear_session_authentication()
170219
self.submit_credentials(user.email, PASSWORD, org_b.slug)
171220
self.wait_for_authenticated_organization(org_b.slug)
221+
222+
def test_organization_sso(self) -> None:
223+
user = self.create_login_user("sso-org")
224+
auth_provider = self.create_auth_provider(
225+
organization_id=self.organization.id, provider="dummy"
226+
)
227+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
228+
229+
# The organization lookup shows that SSO is required and starts its provider flow.
230+
self.begin_organization_sso(self.organization.slug)
231+
232+
# The dummy provider callback authenticates the linked user.
233+
self.complete_dummy_sso(user.email)
234+
self.wait_for_authenticated_organization(self.organization.slug)
235+
236+
def test_organization_sso_with_totp(self) -> None:
237+
user = self.create_login_user("sso-totp-org")
238+
totp = TotpInterface()
239+
totp.enroll(user)
240+
auth_provider = self.create_auth_provider(
241+
organization_id=self.organization.id, provider="dummy"
242+
)
243+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
244+
245+
# SSO identifies the user but leaves authentication pending on their second factor.
246+
self.begin_organization_sso(self.organization.slug)
247+
self.complete_dummy_sso(user.email)
248+
self.browser.wait_until_script_execution(
249+
"return window.location.pathname === '/auth/login/'"
250+
)
251+
self.browser.wait_until('[aria-label="One-time password"]')
252+
253+
assert not self.browser.element_exists('[aria-label="Email"]')
254+
assert not self.browser.element_exists('[aria-label="Password"]')
255+
self.submit_second_factor(totp.make_otp().generate_otp())
256+
self.wait_for_authenticated_organization(self.organization.slug)
257+
258+
def test_organization_without_sso(self) -> None:
259+
user = self.create_login_user("password-only-org")
260+
261+
# The organization lookup explains that members use password authentication.
262+
self.locate_organization_sso(self.organization.slug)
263+
self.browser.wait_until(
264+
xpath="//*[contains(normalize-space(.), 'Members sign in with email and password')]"
265+
)
266+
sso_button = self.browser.element(xpath="//button[normalize-space(.)='SSO']")
267+
assert not sso_button.is_enabled()
268+
269+
# Password authentication remains available for the selected organization.
270+
self.submit_visible_credentials(user.email, PASSWORD)
271+
self.wait_for_authenticated_organization(self.organization.slug)
272+
273+
def test_password_authentication_with_optional_organization_sso(self) -> None:
274+
user = self.create_login_user("optional-sso-org")
275+
auth_provider = self.create_auth_provider(
276+
organization_id=self.organization.id, provider="dummy"
277+
)
278+
auth_provider.flags.allow_unlinked = True
279+
auth_provider.save()
280+
281+
self.locate_organization_sso(self.organization.slug)
282+
self.browser.wait_until(
283+
xpath="//*[contains(normalize-space(.), 'Members sign in with Dummy')]"
284+
)
285+
assert self.browser.element_exists('[aria-label="Email"]')
286+
assert self.browser.element_exists('[aria-label="Password"]')
287+
self.submit_visible_credentials(user.email, PASSWORD)
288+
289+
self.wait_for_authenticated_organization(self.organization.slug)
290+
291+
def test_password_login_cannot_access_sso_required_organization(self) -> None:
292+
user = self.create_user(email="sso-password@example.com")
293+
user.set_password(PASSWORD)
294+
user.save()
295+
organization = self.create_organization(slug="sso-password-org")
296+
self.create_member(organization=organization, user=user)
297+
self.create_auth_provider(organization_id=organization.id, provider="dummy")
298+
299+
# Selecting an SSO-required organization does not force the user to start SSO.
300+
self.select_organization_sso(organization.slug)
301+
self.submit_visible_credentials(user.email, PASSWORD)
302+
303+
# Password authentication succeeds, but it does not grant access to the organization.
304+
self.browser.wait_until_script_execution(
305+
"return window.location.pathname === '/settings/account/'"
306+
)
307+
308+
def test_password_login_uses_organization_without_sso(self) -> None:
309+
user = self.create_user(email="multi-org-password@example.com")
310+
user.set_password(PASSWORD)
311+
user.save()
312+
sso_organization = self.create_organization(slug="sso-required-org")
313+
self.create_member(organization=sso_organization, user=user)
314+
self.create_auth_provider(organization_id=sso_organization.id, provider="dummy")
315+
password_organization = self.create_organization(owner=user, slug="password-org")
316+
317+
# Password login cannot enter the selected organization because it requires SSO.
318+
self.select_organization_sso(sso_organization.slug)
319+
self.submit_visible_credentials(user.email, PASSWORD)
320+
321+
# The authenticated user lands in an accessible organization instead.
322+
self.wait_for_authenticated_organization(password_organization.slug)
323+
324+
def test_password_login_preserves_sso_organization_destination(self) -> None:
325+
user = self.create_user(email="preserved-sso-destination@example.com")
326+
user.set_password(PASSWORD)
327+
user.save()
328+
sso_organization = self.create_organization(slug="preserved-sso-org")
329+
self.create_member(organization=sso_organization, user=user)
330+
auth_provider = self.create_auth_provider(
331+
organization_id=sso_organization.id,
332+
provider="dummy",
333+
)
334+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
335+
self.create_organization(owner=user, slug="password-fallback-org")
336+
337+
self.save_cookie(
338+
name="sentry_react_auth",
339+
value="1",
340+
expires="Tue, 20 Jun 2035 19:07:44 GMT",
341+
)
342+
self.browser.get(f"/organizations/{sso_organization.slug}/issues/")
343+
self.browser.wait_until('[aria-label="Email"]')
344+
345+
# Password authentication establishes the account session, but the protected
346+
# destination takes precedence over the password-capable fallback organization.
347+
self.submit_visible_credentials(user.email, PASSWORD)
348+
self.browser.wait_until_script_execution(
349+
f"return window.location.pathname === '/auth/login/{sso_organization.slug}/'"
350+
)
351+
self.browser.wait_until(
352+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
353+
)
354+
355+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
356+
self.browser.wait_until('form > input[type="email"][name="email"]:only-child')
357+
self.complete_dummy_sso(user.email)
358+
self.wait_for_authenticated_organization(sso_organization.slug)
359+
360+
def test_switch_to_sso_required_organization(self) -> None:
361+
user = self.create_login_user("org-a")
362+
password_organization = self.organization
363+
sso_organization = self.create_organization(owner=user, slug="org-b")
364+
auth_provider = self.create_auth_provider(
365+
organization_id=sso_organization.id, provider="dummy"
366+
)
367+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
368+
369+
# The user first authenticates into an organization that accepts password login.
370+
self.submit_credentials(user.email, PASSWORD)
371+
self.wait_for_authenticated_organization(password_organization.slug)
372+
373+
# Navigating to an organization that requires SSO starts an organization-scoped login.
374+
self.browser.get(f"/organizations/{sso_organization.slug}/issues/")
375+
self.browser.wait_until_script_execution(
376+
f"return window.location.pathname === '/auth/login/{sso_organization.slug}/'"
377+
)
378+
379+
# The current session cannot access the selected organization until SSO completes, so
380+
# the login page keeps the organization in focus and offers no alternative auth method.
381+
self.browser.wait_until(
382+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
383+
)
384+
assert not self.browser.element_exists('[aria-label="Email"]')
385+
assert not self.browser.element_exists('[aria-label="Password"]')
386+
assert not self.browser.element_exists('[aria-label="Clear organization login context"]')
387+
388+
# SSO authenticates the linked identity and returns the user to the protected org.
389+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
390+
self.browser.wait_until('input[name="email"]')
391+
self.complete_dummy_sso(user.email)
392+
self.wait_for_authenticated_organization(sso_organization.slug)

0 commit comments

Comments
 (0)