@@ -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