authentik.providers.saml.tests.test_views_sp_slo

Test SP-initiated SAML Single Logout Views

  1"""Test SP-initiated SAML Single Logout Views"""
  2
  3from unittest.mock import MagicMock, patch
  4
  5from django.http import Http404
  6from django.test import RequestFactory, TestCase
  7from django.urls import reverse
  8
  9from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
 10from authentik.core.models import Application
 11from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
 12from authentik.flows.planner import FlowPlan
 13from authentik.flows.views.executor import SESSION_KEY_PLAN
 14from authentik.providers.saml.exceptions import CannotHandleAssertion
 15from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
 16from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
 17from authentik.providers.saml.views.flows import (
 18    PLAN_CONTEXT_SAML_RELAY_STATE,
 19)
 20from authentik.providers.saml.views.sp_slo import (
 21    SPInitiatedSLOBindingPOSTView,
 22    SPInitiatedSLOBindingRedirectView,
 23)
 24
 25
 26class TestSPInitiatedSLOViews(TestCase):
 27    """Test SP-initiated SAML Single Logout Views"""
 28
 29    def setUp(self):
 30        """Set up test fixtures"""
 31        self.factory = RequestFactory()
 32        self.brand = create_test_brand()
 33        self.flow = create_test_flow()
 34        self.invalidation_flow = create_test_flow()
 35
 36        # Create provider
 37        self.provider = SAMLProvider.objects.create(
 38            name="test-provider",
 39            authorization_flow=self.flow,
 40            invalidation_flow=self.invalidation_flow,
 41            acs_url="https://sp.example.com/acs",
 42            sls_url="https://sp.example.com/sls",
 43            issuer="https://idp.example.com",
 44            sp_binding="redirect",
 45            sls_binding="redirect",
 46        )
 47
 48        # Create application
 49        self.application = Application.objects.create(
 50            name="test-app",
 51            slug="test-app",
 52            provider=self.provider,
 53        )
 54
 55        # Create logout request processor for generating test requests
 56        self.processor = LogoutRequestProcessor(
 57            provider=self.provider,
 58            user=None,
 59            destination="https://idp.example.com/sls",
 60            name_id="test@example.com",
 61            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
 62            session_index="test-session-123",
 63            relay_state="https://sp.example.com/return",
 64        )
 65
 66    def test_redirect_view_handles_logout_request(self):
 67        """Test that redirect view properly handles a logout request"""
 68        # Generate encoded logout request
 69        encoded_request = self.processor.encode_redirect()
 70
 71        # Create request with SAML logout request
 72        request = self.factory.get(
 73            f"/slo/redirect/{self.application.slug}/",
 74            {
 75                "SAMLRequest": encoded_request,
 76                "RelayState": "https://sp.example.com/return",
 77            },
 78        )
 79        request.session = {}
 80        request.brand = self.brand
 81
 82        view = SPInitiatedSLOBindingRedirectView()
 83        view.setup(request, application_slug=self.application.slug)
 84        view.resolve_provider_application()
 85
 86        # Check that the SAML request is parsed correctly
 87        result = view.check_saml_request()
 88        self.assertIsNone(result)  # None means success
 89
 90        # Verify logout request was stored in plan context
 91        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
 92        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
 93        self.assertEqual(logout_request.issuer, self.provider.issuer)
 94        self.assertEqual(logout_request.session_index, "test-session-123")
 95
 96    def test_redirect_view_handles_logout_response_with_plan_context(self):
 97        """Test that redirect view always redirects to plan context URL, ignoring RelayState"""
 98        plan_relay_state = "https://idp.example.com/flow/return"
 99
100        # Create request with SAML logout response
101        request = self.factory.get(
102            f"/slo/redirect/{self.application.slug}/",
103            {
104                "SAMLResponse": "dummy-response",
105                "RelayState": "https://somewhere-else.example.com/return",
106            },
107        )
108        plan = FlowPlan(flow_pk="test-flow")
109        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
110        request.session = {SESSION_KEY_PLAN: plan}
111        request.brand = self.brand
112
113        view = SPInitiatedSLOBindingRedirectView()
114        view.setup(request, application_slug=self.application.slug)
115        response = view.dispatch(request, application_slug=self.application.slug)
116
117        # Should redirect to plan context URL, not the request's RelayState
118        self.assertEqual(response.status_code, 302)
119        self.assertEqual(response.url, plan_relay_state)
120
121    def test_redirect_view_ignores_relay_state_without_plan(self):
122        """Test that redirect view ignores RelayState and falls back to root when no plan context"""
123        relay_state = "https://sp.example.com/plain"
124
125        # Create request with SAML logout response
126        request = self.factory.get(
127            f"/slo/redirect/{self.application.slug}/",
128            {
129                "SAMLResponse": "dummy-response",
130                "RelayState": relay_state,
131            },
132        )
133        request.session = {}
134        request.brand = self.brand
135
136        view = SPInitiatedSLOBindingRedirectView()
137        view.setup(request, application_slug=self.application.slug)
138        response = view.dispatch(request, application_slug=self.application.slug)
139
140        # Should ignore relay_state and redirect to root (no plan context)
141        self.assertEqual(response.status_code, 302)
142        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
143
144    def test_redirect_view_handles_logout_response_no_relay_state_with_plan_context(self):
145        """Test that redirect view uses plan context fallback when no RelayState"""
146        relay_state = "https://idp.example.com/flow/plan-return"
147
148        # Create request with SAML logout response
149        request = self.factory.get(
150            f"/slo/redirect/{self.application.slug}/",
151            {
152                "SAMLResponse": "dummy-response",
153            },
154        )
155        # Create a flow plan with the return URL
156        plan = FlowPlan(flow_pk="test-flow")
157        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
158        request.session = {SESSION_KEY_PLAN: plan}
159        request.brand = self.brand
160
161        view = SPInitiatedSLOBindingRedirectView()
162        view.setup(request, application_slug=self.application.slug)
163        response = view.dispatch(request, application_slug=self.application.slug)
164
165        # Should redirect to plan context stored URL
166        self.assertEqual(response.status_code, 302)
167        self.assertEqual(response.url, relay_state)
168
169    def test_redirect_view_handles_logout_response_no_relay_state_no_session(self):
170        """Test that redirect view uses root redirect as final fallback"""
171        # Create request with SAML logout response
172        request = self.factory.get(
173            f"/slo/redirect/{self.application.slug}/",
174            {
175                "SAMLResponse": "dummy-response",
176            },
177        )
178        request.session = {}
179        request.brand = self.brand
180
181        view = SPInitiatedSLOBindingRedirectView()
182        view.setup(request, application_slug=self.application.slug)
183        response = view.dispatch(request, application_slug=self.application.slug)
184
185        # Should redirect to root-redirect
186        self.assertEqual(response.status_code, 302)
187        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
188
189    def test_redirect_view_missing_saml_request(self):
190        """Test redirect view when SAML request is missing"""
191        request = self.factory.get(f"/slo/redirect/{self.application.slug}/")
192        request.session = {}
193        request.brand = self.brand
194
195        view = SPInitiatedSLOBindingRedirectView()
196        view.setup(request, application_slug=self.application.slug)
197        view.resolve_provider_application()
198
199        # Should return error response
200        result = view.check_saml_request()
201        self.assertIsNotNone(result)
202        self.assertEqual(result.status_code, 400)
203
204    def test_post_view_handles_logout_request(self):
205        """Test that POST view properly handles a logout request"""
206        # Generate encoded logout request
207        encoded_request = self.processor.encode_post()
208
209        # Create POST request with SAML logout request
210        request = self.factory.post(
211            f"/slo/post/{self.application.slug}/",
212            {
213                "SAMLRequest": encoded_request,
214                "RelayState": "https://sp.example.com/return",
215            },
216        )
217        request.session = {}
218        request.brand = self.brand
219
220        view = SPInitiatedSLOBindingPOSTView()
221        view.setup(request, application_slug=self.application.slug)
222        view.resolve_provider_application()
223
224        # Check that the SAML request is parsed correctly
225        result = view.check_saml_request()
226        self.assertIsNone(result)  # None means success
227
228        # Verify logout request was stored in plan context
229        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
230        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
231        self.assertEqual(logout_request.issuer, self.provider.issuer)
232        self.assertEqual(logout_request.session_index, "test-session-123")
233
234    def test_post_view_handles_logout_response_with_plan_context(self):
235        """Test that POST view always redirects to plan context URL, ignoring RelayState"""
236        plan_relay_state = "https://idp.example.com/flow/return"
237
238        # Create POST request with SAML logout response
239        request = self.factory.post(
240            f"/slo/post/{self.application.slug}/",
241            {
242                "SAMLResponse": "dummy-response",
243                "RelayState": "https://somewhere-else.example.com/return",
244            },
245        )
246        plan = FlowPlan(flow_pk="test-flow")
247        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
248        request.session = {SESSION_KEY_PLAN: plan}
249        request.brand = self.brand
250
251        view = SPInitiatedSLOBindingPOSTView()
252        view.setup(request, application_slug=self.application.slug)
253        response = view.dispatch(request, application_slug=self.application.slug)
254
255        # Should redirect to plan context URL, not the request's RelayState
256        self.assertEqual(response.status_code, 302)
257        self.assertEqual(response.url, plan_relay_state)
258
259    def test_post_view_handles_logout_response_no_relay_state_with_plan_context(self):
260        """Test that POST view uses plan context fallback when no RelayState"""
261        relay_state = "https://idp.example.com/flow/plan-return"
262
263        # Create POST request with SAML logout response
264        request = self.factory.post(
265            f"/slo/post/{self.application.slug}/",
266            {
267                "SAMLResponse": "dummy-response",
268            },
269        )
270        # Create a flow plan with the return URL
271        plan = FlowPlan(flow_pk="test-flow")
272        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
273        request.session = {SESSION_KEY_PLAN: plan}
274        request.brand = self.brand
275
276        view = SPInitiatedSLOBindingPOSTView()
277        view.setup(request, application_slug=self.application.slug)
278        response = view.dispatch(request, application_slug=self.application.slug)
279
280        # Should redirect to plan context stored URL
281        self.assertEqual(response.status_code, 302)
282        self.assertEqual(response.url, relay_state)
283
284    def test_post_view_missing_saml_request(self):
285        """Test POST view when SAML request is missing"""
286        request = self.factory.post(f"/slo/post/{self.application.slug}/", {})
287        request.session = {}
288        request.brand = self.brand
289
290        view = SPInitiatedSLOBindingPOSTView()
291        view.setup(request, application_slug=self.application.slug)
292        view.resolve_provider_application()
293
294        # Should return error response
295        result = view.check_saml_request()
296        self.assertIsNotNone(result)
297        self.assertEqual(result.status_code, 400)
298
299    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
300    @patch("authentik.providers.saml.views.sp_slo.Event")
301    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
302    def test_redirect_view_handles_parser_exception(
303        self, mock_parser_class, mock_event_class, mock_logger
304    ):
305        """Test redirect view handles parser exception gracefully"""
306        # Mock Event.new to avoid the error
307        mock_event = MagicMock()
308        mock_event.save = MagicMock()
309        mock_event_class.new.return_value = mock_event
310
311        # Mock LOGGER.error to avoid the error
312        mock_logger.error = MagicMock()
313
314        # Make parser raise exception
315        mock_parser = MagicMock()
316        mock_parser.parse_detached.side_effect = CannotHandleAssertion("Invalid request")
317        mock_parser_class.return_value = mock_parser
318
319        # Create request with SAML logout request
320        request = self.factory.get(
321            f"/slo/redirect/{self.application.slug}/",
322            {
323                "SAMLRequest": "invalid-request",
324                "RelayState": "test",
325            },
326        )
327        request.session = {}
328        request.brand = self.brand
329
330        view = SPInitiatedSLOBindingRedirectView()
331        view.setup(request, application_slug=self.application.slug)
332        view.resolve_provider_application()
333
334        # Should return error response
335        result = view.check_saml_request()
336        self.assertIsNotNone(result)
337        self.assertEqual(result.status_code, 400)
338
339    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
340    @patch("authentik.providers.saml.views.sp_slo.Event")
341    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
342    def test_post_view_handles_parser_exception(
343        self, mock_parser_class, mock_event_class, mock_logger
344    ):
345        """Test POST view handles parser exception gracefully"""
346        # Mock Event.new to avoid the error
347        mock_event = MagicMock()
348        mock_event.save = MagicMock()
349        mock_event_class.new.return_value = mock_event
350
351        # Mock LOGGER.error to avoid the error
352        mock_logger.error = MagicMock()
353
354        # Make parser raise exception
355        mock_parser = MagicMock()
356        mock_parser.parse.side_effect = CannotHandleAssertion("Invalid request")
357        mock_parser_class.return_value = mock_parser
358
359        # Create POST request with SAML logout request
360        request = self.factory.post(
361            f"/slo/post/{self.application.slug}/",
362            {
363                "SAMLRequest": "invalid-request",
364                "RelayState": "test",
365            },
366        )
367        request.session = {}
368        request.brand = self.brand
369
370        view = SPInitiatedSLOBindingPOSTView()
371        view.setup(request, application_slug=self.application.slug)
372        view.resolve_provider_application()
373
374        # Should return error response
375        result = view.check_saml_request()
376        self.assertIsNotNone(result)
377        self.assertEqual(result.status_code, 400)
378
379    def test_application_not_found(self):
380        """Test handling when application doesn't exist"""
381        request = self.factory.get("/slo/redirect/non-existent/")
382        request.session = {}
383        request.brand = self.brand
384
385        view = SPInitiatedSLOBindingRedirectView()
386        view.setup(request, application_slug="non-existent")
387
388        with self.assertRaises(Http404):
389            view.resolve_provider_application()
390
391    def test_provider_without_invalidation_flow(self):
392        """Test handling when provider has no invalidation flow and brand has no default"""
393        # Create provider without invalidation flow
394        provider = SAMLProvider.objects.create(
395            name="no-flow-provider",
396            authorization_flow=self.flow,
397            acs_url="https://sp2.example.com/acs",
398            sls_url="https://sp2.example.com/sls",
399            issuer="https://idp2.example.com",
400            invalidation_flow=None,  # No invalidation flow
401        )
402
403        app = Application.objects.create(
404            name="no-flow-app",
405            slug="no-flow-app",
406            provider=provider,
407        )
408
409        # Brand with no flow_invalidation
410        self.brand.flow_invalidation = None
411        self.brand.save()
412
413        request = self.factory.get(f"/slo/redirect/{app.slug}/")
414        request.session = {}
415        request.brand = self.brand
416
417        view = SPInitiatedSLOBindingRedirectView()
418        view.setup(request, application_slug=app.slug)
419
420        with self.assertRaises(Http404):
421            view.resolve_provider_application()
422
423    def test_relay_state_decoding_failure(self):
424        """Test that arbitrary path RelayState is ignored and redirects to root"""
425        # Create request with relay state that is a path
426        request = self.factory.get(
427            f"/slo/redirect/{self.application.slug}/",
428            {
429                "SAMLResponse": "dummy-response",
430                "RelayState": "/some/invalid/path",  # Use a path that starts with /
431            },
432        )
433        request.session = {}
434        request.brand = self.brand
435
436        view = SPInitiatedSLOBindingRedirectView()
437        view.setup(request, application_slug=self.application.slug)
438        response = view.dispatch(request, application_slug=self.application.slug)
439
440        # Should ignore relay_state and redirect to root (no plan context)
441        self.assertEqual(response.status_code, 302)
442        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
443
444    def test_redirect_view_blocks_external_relay_state(self):
445        """Test that redirect view ignores external malicious URL and redirects to root"""
446        request = self.factory.get(
447            f"/slo/redirect/{self.application.slug}/",
448            {
449                "SAMLResponse": "dummy-response",
450                "RelayState": "https://evil.com/phishing",
451            },
452        )
453        request.session = {}
454        request.brand = self.brand
455
456        view = SPInitiatedSLOBindingRedirectView()
457        view.setup(request, application_slug=self.application.slug)
458        response = view.dispatch(request, application_slug=self.application.slug)
459
460        # Should ignore relay_state and redirect to root (no plan context)
461        self.assertEqual(response.status_code, 302)
462        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
463
464    def test_redirect_view_ignores_relay_state_uses_plan_context(self):
465        """Test that redirect view always uses plan context URL regardless of RelayState"""
466        plan_relay_state = "https://authentik.example.com/if/flow/logout/"
467
468        request = self.factory.get(
469            f"/slo/redirect/{self.application.slug}/",
470            {
471                "SAMLResponse": "dummy-response",
472                "RelayState": "https://evil.com/phishing",
473            },
474        )
475        plan = FlowPlan(flow_pk="test-flow")
476        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
477        request.session = {SESSION_KEY_PLAN: plan}
478        request.brand = self.brand
479
480        view = SPInitiatedSLOBindingRedirectView()
481        view.setup(request, application_slug=self.application.slug)
482        response = view.dispatch(request, application_slug=self.application.slug)
483
484        # Should always use plan context value, ignoring malicious RelayState
485        self.assertEqual(response.status_code, 302)
486        self.assertEqual(response.url, plan_relay_state)
487
488    def test_post_view_ignores_external_relay_state(self):
489        """Test that POST view ignores external RelayState and redirects to root"""
490        request = self.factory.post(
491            f"/slo/post/{self.application.slug}/",
492            {
493                "SAMLResponse": "dummy-response",
494                "RelayState": "https://evil.com/phishing",
495            },
496        )
497        request.session = {}
498        request.brand = self.brand
499
500        view = SPInitiatedSLOBindingPOSTView()
501        view.setup(request, application_slug=self.application.slug)
502        response = view.dispatch(request, application_slug=self.application.slug)
503
504        # Should ignore relay_state and redirect to root (no plan context)
505        self.assertEqual(response.status_code, 302)
506        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
507
508
509class TestSPInitiatedSLOLogoutMethods(TestCase):
510    """Test SP-initiated SAML SLO logout method branching"""
511
512    def setUp(self):
513        """Set up test fixtures"""
514        self.factory = RequestFactory()
515        self.brand = create_test_brand()
516        self.flow = create_test_flow()
517        self.invalidation_flow = create_test_flow()
518        self.cert = create_test_cert()
519
520        # Create provider with sls_url
521        self.provider = SAMLProvider.objects.create(
522            name="test-provider",
523            authorization_flow=self.flow,
524            invalidation_flow=self.invalidation_flow,
525            acs_url="https://sp.example.com/acs",
526            sls_url="https://sp.example.com/sls",
527            issuer="https://idp.example.com",
528            sp_binding="redirect",
529            sls_binding="redirect",
530            signing_kp=self.cert,
531        )
532
533        # Create application
534        self.application = Application.objects.create(
535            name="test-app",
536            slug="test-app-logout-methods",
537            provider=self.provider,
538        )
539
540        # Create logout request processor for generating test requests
541        self.processor = LogoutRequestProcessor(
542            provider=self.provider,
543            user=None,
544            destination="https://idp.example.com/sls",
545            name_id="test@example.com",
546            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
547            session_index="test-session-123",
548            relay_state="https://sp.example.com/return",
549        )
550
551    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
552    def test_frontchannel_native_post_binding(self, mock_auth_session):
553        """Test FRONTCHANNEL_NATIVE with POST binding parses request correctly"""
554        mock_auth_session.from_request.return_value = None
555
556        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
557        self.provider.sls_binding = SAMLBindings.POST
558        self.provider.save()
559
560        encoded_request = self.processor.encode_redirect()
561
562        request = self.factory.get(
563            f"/slo/redirect/{self.application.slug}/",
564            {
565                "SAMLRequest": encoded_request,
566                "RelayState": "https://sp.example.com/return",
567            },
568        )
569        request.session = {}
570        request.brand = self.brand
571        request.user = MagicMock()
572
573        view = SPInitiatedSLOBindingRedirectView()
574        view.setup(request, application_slug=self.application.slug)
575        view.resolve_provider_application()
576        view.check_saml_request()
577
578        # Verify the logout request was parsed and provider is configured correctly
579        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
580        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.FRONTCHANNEL_NATIVE)
581        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)
582
583    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
584    def test_frontchannel_native_redirect_binding(self, mock_auth_session):
585        """Test FRONTCHANNEL_NATIVE with REDIRECT binding creates redirect URL"""
586        mock_auth_session.from_request.return_value = None
587
588        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
589        self.provider.sls_binding = SAMLBindings.REDIRECT
590        self.provider.save()
591
592        encoded_request = self.processor.encode_redirect()
593
594        request = self.factory.get(
595            f"/slo/redirect/{self.application.slug}/",
596            {
597                "SAMLRequest": encoded_request,
598                "RelayState": "https://sp.example.com/return",
599            },
600        )
601        request.session = {}
602        request.brand = self.brand
603        request.user = MagicMock()
604
605        view = SPInitiatedSLOBindingRedirectView()
606        view.setup(request, application_slug=self.application.slug)
607        view.resolve_provider_application()
608        view.check_saml_request()
609
610        # Verify the logout request was parsed
611        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
612
613    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
614    def test_frontchannel_iframe_post_binding(self, mock_auth_session):
615        """Test FRONTCHANNEL_IFRAME with POST binding creates IframeLogoutStageView"""
616        mock_auth_session.from_request.return_value = None
617
618        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
619        self.provider.sls_binding = SAMLBindings.POST
620        self.provider.save()
621
622        encoded_request = self.processor.encode_redirect()
623
624        request = self.factory.get(
625            f"/slo/redirect/{self.application.slug}/",
626            {
627                "SAMLRequest": encoded_request,
628                "RelayState": "https://sp.example.com/return",
629            },
630        )
631        request.session = {}
632        request.brand = self.brand
633        request.user = MagicMock()
634
635        view = SPInitiatedSLOBindingRedirectView()
636        view.setup(request, application_slug=self.application.slug)
637        view.resolve_provider_application()
638        view.check_saml_request()
639
640        # Verify the logout request was parsed
641        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
642
643    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
644    def test_frontchannel_iframe_redirect_binding(self, mock_auth_session):
645        """Test FRONTCHANNEL_IFRAME with REDIRECT binding"""
646        mock_auth_session.from_request.return_value = None
647
648        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
649        self.provider.sls_binding = SAMLBindings.REDIRECT
650        self.provider.save()
651
652        encoded_request = self.processor.encode_redirect()
653
654        request = self.factory.get(
655            f"/slo/redirect/{self.application.slug}/",
656            {
657                "SAMLRequest": encoded_request,
658                "RelayState": "https://sp.example.com/return",
659            },
660        )
661        request.session = {}
662        request.brand = self.brand
663        request.user = MagicMock()
664
665        view = SPInitiatedSLOBindingRedirectView()
666        view.setup(request, application_slug=self.application.slug)
667        view.resolve_provider_application()
668        view.check_saml_request()
669
670        # Verify the logout request was parsed
671        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
672
673    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
674    def test_backchannel_parses_request(self, mock_auth_session):
675        """Test BACKCHANNEL mode parses request correctly"""
676        mock_auth_session.from_request.return_value = None
677
678        self.provider.logout_method = SAMLLogoutMethods.BACKCHANNEL
679        self.provider.sls_binding = SAMLBindings.POST
680        self.provider.save()
681
682        encoded_request = self.processor.encode_redirect()
683
684        request = self.factory.get(
685            f"/slo/redirect/{self.application.slug}/",
686            {
687                "SAMLRequest": encoded_request,
688                "RelayState": "https://sp.example.com/return",
689            },
690        )
691        request.session = {}
692        request.brand = self.brand
693        request.user = MagicMock()
694
695        view = SPInitiatedSLOBindingRedirectView()
696        view.setup(request, application_slug=self.application.slug)
697        view.resolve_provider_application()
698        view.check_saml_request()
699
700        # Verify the logout request was parsed and provider is configured correctly
701        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
702        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.BACKCHANNEL)
703        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)
704
705    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
706    def test_no_sls_url_only_session_end(self, mock_auth_session):
707        """Test that only SessionEndStage is appended when sls_url is empty"""
708        mock_auth_session.from_request.return_value = None
709
710        # Create provider without sls_url
711        provider_no_sls = SAMLProvider.objects.create(
712            name="no-sls-provider",
713            authorization_flow=self.flow,
714            invalidation_flow=self.invalidation_flow,
715            acs_url="https://sp.example.com/acs",
716            sls_url="",  # No SLS URL
717            issuer="https://idp.example.com",
718        )
719
720        app_no_sls = Application.objects.create(
721            name="no-sls-app",
722            slug="no-sls-app",
723            provider=provider_no_sls,
724        )
725
726        processor = LogoutRequestProcessor(
727            provider=provider_no_sls,
728            user=None,
729            destination="https://idp.example.com/sls",
730            name_id="test@example.com",
731            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
732            session_index="test-session-123",
733        )
734        encoded_request = processor.encode_redirect()
735
736        request = self.factory.get(
737            f"/slo/redirect/{app_no_sls.slug}/",
738            {
739                "SAMLRequest": encoded_request,
740            },
741        )
742        request.session = {}
743        request.brand = self.brand
744        request.user = MagicMock()
745
746        view = SPInitiatedSLOBindingRedirectView()
747        view.setup(request, application_slug=app_no_sls.slug)
748        view.resolve_provider_application()
749        view.check_saml_request()
750
751        # Verify the provider has no sls_url
752        self.assertEqual(view.provider.sls_url, "")
753
754    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
755    def test_relay_state_propagation(self, mock_auth_session):
756        """Test that relay state from logout request is passed through to response"""
757        mock_auth_session.from_request.return_value = None
758
759        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
760        self.provider.save()
761
762        expected_relay_state = "https://sp.example.com/custom-return"
763
764        processor = LogoutRequestProcessor(
765            provider=self.provider,
766            user=None,
767            destination="https://idp.example.com/sls",
768            name_id="test@example.com",
769            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
770            session_index="test-session-123",
771            relay_state=expected_relay_state,
772        )
773        encoded_request = processor.encode_redirect()
774
775        request = self.factory.get(
776            f"/slo/redirect/{self.application.slug}/",
777            {
778                "SAMLRequest": encoded_request,
779                "RelayState": expected_relay_state,
780            },
781        )
782        request.session = {}
783        request.brand = self.brand
784        request.user = MagicMock()
785
786        view = SPInitiatedSLOBindingRedirectView()
787        view.setup(request, application_slug=self.application.slug)
788        view.resolve_provider_application()
789        view.check_saml_request()
790
791        # Verify relay state was captured
792        logout_request = view.plan_context.get("authentik/providers/saml/logout_request")
793        self.assertEqual(logout_request.relay_state, expected_relay_state)
class TestSPInitiatedSLOViews(django.test.testcases.TestCase):
 27class TestSPInitiatedSLOViews(TestCase):
 28    """Test SP-initiated SAML Single Logout Views"""
 29
 30    def setUp(self):
 31        """Set up test fixtures"""
 32        self.factory = RequestFactory()
 33        self.brand = create_test_brand()
 34        self.flow = create_test_flow()
 35        self.invalidation_flow = create_test_flow()
 36
 37        # Create provider
 38        self.provider = SAMLProvider.objects.create(
 39            name="test-provider",
 40            authorization_flow=self.flow,
 41            invalidation_flow=self.invalidation_flow,
 42            acs_url="https://sp.example.com/acs",
 43            sls_url="https://sp.example.com/sls",
 44            issuer="https://idp.example.com",
 45            sp_binding="redirect",
 46            sls_binding="redirect",
 47        )
 48
 49        # Create application
 50        self.application = Application.objects.create(
 51            name="test-app",
 52            slug="test-app",
 53            provider=self.provider,
 54        )
 55
 56        # Create logout request processor for generating test requests
 57        self.processor = LogoutRequestProcessor(
 58            provider=self.provider,
 59            user=None,
 60            destination="https://idp.example.com/sls",
 61            name_id="test@example.com",
 62            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
 63            session_index="test-session-123",
 64            relay_state="https://sp.example.com/return",
 65        )
 66
 67    def test_redirect_view_handles_logout_request(self):
 68        """Test that redirect view properly handles a logout request"""
 69        # Generate encoded logout request
 70        encoded_request = self.processor.encode_redirect()
 71
 72        # Create request with SAML logout request
 73        request = self.factory.get(
 74            f"/slo/redirect/{self.application.slug}/",
 75            {
 76                "SAMLRequest": encoded_request,
 77                "RelayState": "https://sp.example.com/return",
 78            },
 79        )
 80        request.session = {}
 81        request.brand = self.brand
 82
 83        view = SPInitiatedSLOBindingRedirectView()
 84        view.setup(request, application_slug=self.application.slug)
 85        view.resolve_provider_application()
 86
 87        # Check that the SAML request is parsed correctly
 88        result = view.check_saml_request()
 89        self.assertIsNone(result)  # None means success
 90
 91        # Verify logout request was stored in plan context
 92        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
 93        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
 94        self.assertEqual(logout_request.issuer, self.provider.issuer)
 95        self.assertEqual(logout_request.session_index, "test-session-123")
 96
 97    def test_redirect_view_handles_logout_response_with_plan_context(self):
 98        """Test that redirect view always redirects to plan context URL, ignoring RelayState"""
 99        plan_relay_state = "https://idp.example.com/flow/return"
100
101        # Create request with SAML logout response
102        request = self.factory.get(
103            f"/slo/redirect/{self.application.slug}/",
104            {
105                "SAMLResponse": "dummy-response",
106                "RelayState": "https://somewhere-else.example.com/return",
107            },
108        )
109        plan = FlowPlan(flow_pk="test-flow")
110        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
111        request.session = {SESSION_KEY_PLAN: plan}
112        request.brand = self.brand
113
114        view = SPInitiatedSLOBindingRedirectView()
115        view.setup(request, application_slug=self.application.slug)
116        response = view.dispatch(request, application_slug=self.application.slug)
117
118        # Should redirect to plan context URL, not the request's RelayState
119        self.assertEqual(response.status_code, 302)
120        self.assertEqual(response.url, plan_relay_state)
121
122    def test_redirect_view_ignores_relay_state_without_plan(self):
123        """Test that redirect view ignores RelayState and falls back to root when no plan context"""
124        relay_state = "https://sp.example.com/plain"
125
126        # Create request with SAML logout response
127        request = self.factory.get(
128            f"/slo/redirect/{self.application.slug}/",
129            {
130                "SAMLResponse": "dummy-response",
131                "RelayState": relay_state,
132            },
133        )
134        request.session = {}
135        request.brand = self.brand
136
137        view = SPInitiatedSLOBindingRedirectView()
138        view.setup(request, application_slug=self.application.slug)
139        response = view.dispatch(request, application_slug=self.application.slug)
140
141        # Should ignore relay_state and redirect to root (no plan context)
142        self.assertEqual(response.status_code, 302)
143        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
144
145    def test_redirect_view_handles_logout_response_no_relay_state_with_plan_context(self):
146        """Test that redirect view uses plan context fallback when no RelayState"""
147        relay_state = "https://idp.example.com/flow/plan-return"
148
149        # Create request with SAML logout response
150        request = self.factory.get(
151            f"/slo/redirect/{self.application.slug}/",
152            {
153                "SAMLResponse": "dummy-response",
154            },
155        )
156        # Create a flow plan with the return URL
157        plan = FlowPlan(flow_pk="test-flow")
158        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
159        request.session = {SESSION_KEY_PLAN: plan}
160        request.brand = self.brand
161
162        view = SPInitiatedSLOBindingRedirectView()
163        view.setup(request, application_slug=self.application.slug)
164        response = view.dispatch(request, application_slug=self.application.slug)
165
166        # Should redirect to plan context stored URL
167        self.assertEqual(response.status_code, 302)
168        self.assertEqual(response.url, relay_state)
169
170    def test_redirect_view_handles_logout_response_no_relay_state_no_session(self):
171        """Test that redirect view uses root redirect as final fallback"""
172        # Create request with SAML logout response
173        request = self.factory.get(
174            f"/slo/redirect/{self.application.slug}/",
175            {
176                "SAMLResponse": "dummy-response",
177            },
178        )
179        request.session = {}
180        request.brand = self.brand
181
182        view = SPInitiatedSLOBindingRedirectView()
183        view.setup(request, application_slug=self.application.slug)
184        response = view.dispatch(request, application_slug=self.application.slug)
185
186        # Should redirect to root-redirect
187        self.assertEqual(response.status_code, 302)
188        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
189
190    def test_redirect_view_missing_saml_request(self):
191        """Test redirect view when SAML request is missing"""
192        request = self.factory.get(f"/slo/redirect/{self.application.slug}/")
193        request.session = {}
194        request.brand = self.brand
195
196        view = SPInitiatedSLOBindingRedirectView()
197        view.setup(request, application_slug=self.application.slug)
198        view.resolve_provider_application()
199
200        # Should return error response
201        result = view.check_saml_request()
202        self.assertIsNotNone(result)
203        self.assertEqual(result.status_code, 400)
204
205    def test_post_view_handles_logout_request(self):
206        """Test that POST view properly handles a logout request"""
207        # Generate encoded logout request
208        encoded_request = self.processor.encode_post()
209
210        # Create POST request with SAML logout request
211        request = self.factory.post(
212            f"/slo/post/{self.application.slug}/",
213            {
214                "SAMLRequest": encoded_request,
215                "RelayState": "https://sp.example.com/return",
216            },
217        )
218        request.session = {}
219        request.brand = self.brand
220
221        view = SPInitiatedSLOBindingPOSTView()
222        view.setup(request, application_slug=self.application.slug)
223        view.resolve_provider_application()
224
225        # Check that the SAML request is parsed correctly
226        result = view.check_saml_request()
227        self.assertIsNone(result)  # None means success
228
229        # Verify logout request was stored in plan context
230        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
231        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
232        self.assertEqual(logout_request.issuer, self.provider.issuer)
233        self.assertEqual(logout_request.session_index, "test-session-123")
234
235    def test_post_view_handles_logout_response_with_plan_context(self):
236        """Test that POST view always redirects to plan context URL, ignoring RelayState"""
237        plan_relay_state = "https://idp.example.com/flow/return"
238
239        # Create POST request with SAML logout response
240        request = self.factory.post(
241            f"/slo/post/{self.application.slug}/",
242            {
243                "SAMLResponse": "dummy-response",
244                "RelayState": "https://somewhere-else.example.com/return",
245            },
246        )
247        plan = FlowPlan(flow_pk="test-flow")
248        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
249        request.session = {SESSION_KEY_PLAN: plan}
250        request.brand = self.brand
251
252        view = SPInitiatedSLOBindingPOSTView()
253        view.setup(request, application_slug=self.application.slug)
254        response = view.dispatch(request, application_slug=self.application.slug)
255
256        # Should redirect to plan context URL, not the request's RelayState
257        self.assertEqual(response.status_code, 302)
258        self.assertEqual(response.url, plan_relay_state)
259
260    def test_post_view_handles_logout_response_no_relay_state_with_plan_context(self):
261        """Test that POST view uses plan context fallback when no RelayState"""
262        relay_state = "https://idp.example.com/flow/plan-return"
263
264        # Create POST request with SAML logout response
265        request = self.factory.post(
266            f"/slo/post/{self.application.slug}/",
267            {
268                "SAMLResponse": "dummy-response",
269            },
270        )
271        # Create a flow plan with the return URL
272        plan = FlowPlan(flow_pk="test-flow")
273        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
274        request.session = {SESSION_KEY_PLAN: plan}
275        request.brand = self.brand
276
277        view = SPInitiatedSLOBindingPOSTView()
278        view.setup(request, application_slug=self.application.slug)
279        response = view.dispatch(request, application_slug=self.application.slug)
280
281        # Should redirect to plan context stored URL
282        self.assertEqual(response.status_code, 302)
283        self.assertEqual(response.url, relay_state)
284
285    def test_post_view_missing_saml_request(self):
286        """Test POST view when SAML request is missing"""
287        request = self.factory.post(f"/slo/post/{self.application.slug}/", {})
288        request.session = {}
289        request.brand = self.brand
290
291        view = SPInitiatedSLOBindingPOSTView()
292        view.setup(request, application_slug=self.application.slug)
293        view.resolve_provider_application()
294
295        # Should return error response
296        result = view.check_saml_request()
297        self.assertIsNotNone(result)
298        self.assertEqual(result.status_code, 400)
299
300    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
301    @patch("authentik.providers.saml.views.sp_slo.Event")
302    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
303    def test_redirect_view_handles_parser_exception(
304        self, mock_parser_class, mock_event_class, mock_logger
305    ):
306        """Test redirect view handles parser exception gracefully"""
307        # Mock Event.new to avoid the error
308        mock_event = MagicMock()
309        mock_event.save = MagicMock()
310        mock_event_class.new.return_value = mock_event
311
312        # Mock LOGGER.error to avoid the error
313        mock_logger.error = MagicMock()
314
315        # Make parser raise exception
316        mock_parser = MagicMock()
317        mock_parser.parse_detached.side_effect = CannotHandleAssertion("Invalid request")
318        mock_parser_class.return_value = mock_parser
319
320        # Create request with SAML logout request
321        request = self.factory.get(
322            f"/slo/redirect/{self.application.slug}/",
323            {
324                "SAMLRequest": "invalid-request",
325                "RelayState": "test",
326            },
327        )
328        request.session = {}
329        request.brand = self.brand
330
331        view = SPInitiatedSLOBindingRedirectView()
332        view.setup(request, application_slug=self.application.slug)
333        view.resolve_provider_application()
334
335        # Should return error response
336        result = view.check_saml_request()
337        self.assertIsNotNone(result)
338        self.assertEqual(result.status_code, 400)
339
340    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
341    @patch("authentik.providers.saml.views.sp_slo.Event")
342    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
343    def test_post_view_handles_parser_exception(
344        self, mock_parser_class, mock_event_class, mock_logger
345    ):
346        """Test POST view handles parser exception gracefully"""
347        # Mock Event.new to avoid the error
348        mock_event = MagicMock()
349        mock_event.save = MagicMock()
350        mock_event_class.new.return_value = mock_event
351
352        # Mock LOGGER.error to avoid the error
353        mock_logger.error = MagicMock()
354
355        # Make parser raise exception
356        mock_parser = MagicMock()
357        mock_parser.parse.side_effect = CannotHandleAssertion("Invalid request")
358        mock_parser_class.return_value = mock_parser
359
360        # Create POST request with SAML logout request
361        request = self.factory.post(
362            f"/slo/post/{self.application.slug}/",
363            {
364                "SAMLRequest": "invalid-request",
365                "RelayState": "test",
366            },
367        )
368        request.session = {}
369        request.brand = self.brand
370
371        view = SPInitiatedSLOBindingPOSTView()
372        view.setup(request, application_slug=self.application.slug)
373        view.resolve_provider_application()
374
375        # Should return error response
376        result = view.check_saml_request()
377        self.assertIsNotNone(result)
378        self.assertEqual(result.status_code, 400)
379
380    def test_application_not_found(self):
381        """Test handling when application doesn't exist"""
382        request = self.factory.get("/slo/redirect/non-existent/")
383        request.session = {}
384        request.brand = self.brand
385
386        view = SPInitiatedSLOBindingRedirectView()
387        view.setup(request, application_slug="non-existent")
388
389        with self.assertRaises(Http404):
390            view.resolve_provider_application()
391
392    def test_provider_without_invalidation_flow(self):
393        """Test handling when provider has no invalidation flow and brand has no default"""
394        # Create provider without invalidation flow
395        provider = SAMLProvider.objects.create(
396            name="no-flow-provider",
397            authorization_flow=self.flow,
398            acs_url="https://sp2.example.com/acs",
399            sls_url="https://sp2.example.com/sls",
400            issuer="https://idp2.example.com",
401            invalidation_flow=None,  # No invalidation flow
402        )
403
404        app = Application.objects.create(
405            name="no-flow-app",
406            slug="no-flow-app",
407            provider=provider,
408        )
409
410        # Brand with no flow_invalidation
411        self.brand.flow_invalidation = None
412        self.brand.save()
413
414        request = self.factory.get(f"/slo/redirect/{app.slug}/")
415        request.session = {}
416        request.brand = self.brand
417
418        view = SPInitiatedSLOBindingRedirectView()
419        view.setup(request, application_slug=app.slug)
420
421        with self.assertRaises(Http404):
422            view.resolve_provider_application()
423
424    def test_relay_state_decoding_failure(self):
425        """Test that arbitrary path RelayState is ignored and redirects to root"""
426        # Create request with relay state that is a path
427        request = self.factory.get(
428            f"/slo/redirect/{self.application.slug}/",
429            {
430                "SAMLResponse": "dummy-response",
431                "RelayState": "/some/invalid/path",  # Use a path that starts with /
432            },
433        )
434        request.session = {}
435        request.brand = self.brand
436
437        view = SPInitiatedSLOBindingRedirectView()
438        view.setup(request, application_slug=self.application.slug)
439        response = view.dispatch(request, application_slug=self.application.slug)
440
441        # Should ignore relay_state and redirect to root (no plan context)
442        self.assertEqual(response.status_code, 302)
443        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
444
445    def test_redirect_view_blocks_external_relay_state(self):
446        """Test that redirect view ignores external malicious URL and redirects to root"""
447        request = self.factory.get(
448            f"/slo/redirect/{self.application.slug}/",
449            {
450                "SAMLResponse": "dummy-response",
451                "RelayState": "https://evil.com/phishing",
452            },
453        )
454        request.session = {}
455        request.brand = self.brand
456
457        view = SPInitiatedSLOBindingRedirectView()
458        view.setup(request, application_slug=self.application.slug)
459        response = view.dispatch(request, application_slug=self.application.slug)
460
461        # Should ignore relay_state and redirect to root (no plan context)
462        self.assertEqual(response.status_code, 302)
463        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
464
465    def test_redirect_view_ignores_relay_state_uses_plan_context(self):
466        """Test that redirect view always uses plan context URL regardless of RelayState"""
467        plan_relay_state = "https://authentik.example.com/if/flow/logout/"
468
469        request = self.factory.get(
470            f"/slo/redirect/{self.application.slug}/",
471            {
472                "SAMLResponse": "dummy-response",
473                "RelayState": "https://evil.com/phishing",
474            },
475        )
476        plan = FlowPlan(flow_pk="test-flow")
477        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
478        request.session = {SESSION_KEY_PLAN: plan}
479        request.brand = self.brand
480
481        view = SPInitiatedSLOBindingRedirectView()
482        view.setup(request, application_slug=self.application.slug)
483        response = view.dispatch(request, application_slug=self.application.slug)
484
485        # Should always use plan context value, ignoring malicious RelayState
486        self.assertEqual(response.status_code, 302)
487        self.assertEqual(response.url, plan_relay_state)
488
489    def test_post_view_ignores_external_relay_state(self):
490        """Test that POST view ignores external RelayState and redirects to root"""
491        request = self.factory.post(
492            f"/slo/post/{self.application.slug}/",
493            {
494                "SAMLResponse": "dummy-response",
495                "RelayState": "https://evil.com/phishing",
496            },
497        )
498        request.session = {}
499        request.brand = self.brand
500
501        view = SPInitiatedSLOBindingPOSTView()
502        view.setup(request, application_slug=self.application.slug)
503        response = view.dispatch(request, application_slug=self.application.slug)
504
505        # Should ignore relay_state and redirect to root (no plan context)
506        self.assertEqual(response.status_code, 302)
507        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test SP-initiated SAML Single Logout Views

def setUp(self):
30    def setUp(self):
31        """Set up test fixtures"""
32        self.factory = RequestFactory()
33        self.brand = create_test_brand()
34        self.flow = create_test_flow()
35        self.invalidation_flow = create_test_flow()
36
37        # Create provider
38        self.provider = SAMLProvider.objects.create(
39            name="test-provider",
40            authorization_flow=self.flow,
41            invalidation_flow=self.invalidation_flow,
42            acs_url="https://sp.example.com/acs",
43            sls_url="https://sp.example.com/sls",
44            issuer="https://idp.example.com",
45            sp_binding="redirect",
46            sls_binding="redirect",
47        )
48
49        # Create application
50        self.application = Application.objects.create(
51            name="test-app",
52            slug="test-app",
53            provider=self.provider,
54        )
55
56        # Create logout request processor for generating test requests
57        self.processor = LogoutRequestProcessor(
58            provider=self.provider,
59            user=None,
60            destination="https://idp.example.com/sls",
61            name_id="test@example.com",
62            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
63            session_index="test-session-123",
64            relay_state="https://sp.example.com/return",
65        )

Set up test fixtures

def test_redirect_view_handles_logout_request(self):
67    def test_redirect_view_handles_logout_request(self):
68        """Test that redirect view properly handles a logout request"""
69        # Generate encoded logout request
70        encoded_request = self.processor.encode_redirect()
71
72        # Create request with SAML logout request
73        request = self.factory.get(
74            f"/slo/redirect/{self.application.slug}/",
75            {
76                "SAMLRequest": encoded_request,
77                "RelayState": "https://sp.example.com/return",
78            },
79        )
80        request.session = {}
81        request.brand = self.brand
82
83        view = SPInitiatedSLOBindingRedirectView()
84        view.setup(request, application_slug=self.application.slug)
85        view.resolve_provider_application()
86
87        # Check that the SAML request is parsed correctly
88        result = view.check_saml_request()
89        self.assertIsNone(result)  # None means success
90
91        # Verify logout request was stored in plan context
92        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
93        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
94        self.assertEqual(logout_request.issuer, self.provider.issuer)
95        self.assertEqual(logout_request.session_index, "test-session-123")

Test that redirect view properly handles a logout request

def test_redirect_view_handles_logout_response_with_plan_context(self):
 97    def test_redirect_view_handles_logout_response_with_plan_context(self):
 98        """Test that redirect view always redirects to plan context URL, ignoring RelayState"""
 99        plan_relay_state = "https://idp.example.com/flow/return"
100
101        # Create request with SAML logout response
102        request = self.factory.get(
103            f"/slo/redirect/{self.application.slug}/",
104            {
105                "SAMLResponse": "dummy-response",
106                "RelayState": "https://somewhere-else.example.com/return",
107            },
108        )
109        plan = FlowPlan(flow_pk="test-flow")
110        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
111        request.session = {SESSION_KEY_PLAN: plan}
112        request.brand = self.brand
113
114        view = SPInitiatedSLOBindingRedirectView()
115        view.setup(request, application_slug=self.application.slug)
116        response = view.dispatch(request, application_slug=self.application.slug)
117
118        # Should redirect to plan context URL, not the request's RelayState
119        self.assertEqual(response.status_code, 302)
120        self.assertEqual(response.url, plan_relay_state)

Test that redirect view always redirects to plan context URL, ignoring RelayState

def test_redirect_view_ignores_relay_state_without_plan(self):
122    def test_redirect_view_ignores_relay_state_without_plan(self):
123        """Test that redirect view ignores RelayState and falls back to root when no plan context"""
124        relay_state = "https://sp.example.com/plain"
125
126        # Create request with SAML logout response
127        request = self.factory.get(
128            f"/slo/redirect/{self.application.slug}/",
129            {
130                "SAMLResponse": "dummy-response",
131                "RelayState": relay_state,
132            },
133        )
134        request.session = {}
135        request.brand = self.brand
136
137        view = SPInitiatedSLOBindingRedirectView()
138        view.setup(request, application_slug=self.application.slug)
139        response = view.dispatch(request, application_slug=self.application.slug)
140
141        # Should ignore relay_state and redirect to root (no plan context)
142        self.assertEqual(response.status_code, 302)
143        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test that redirect view ignores RelayState and falls back to root when no plan context

def test_redirect_view_handles_logout_response_no_relay_state_with_plan_context(self):
145    def test_redirect_view_handles_logout_response_no_relay_state_with_plan_context(self):
146        """Test that redirect view uses plan context fallback when no RelayState"""
147        relay_state = "https://idp.example.com/flow/plan-return"
148
149        # Create request with SAML logout response
150        request = self.factory.get(
151            f"/slo/redirect/{self.application.slug}/",
152            {
153                "SAMLResponse": "dummy-response",
154            },
155        )
156        # Create a flow plan with the return URL
157        plan = FlowPlan(flow_pk="test-flow")
158        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
159        request.session = {SESSION_KEY_PLAN: plan}
160        request.brand = self.brand
161
162        view = SPInitiatedSLOBindingRedirectView()
163        view.setup(request, application_slug=self.application.slug)
164        response = view.dispatch(request, application_slug=self.application.slug)
165
166        # Should redirect to plan context stored URL
167        self.assertEqual(response.status_code, 302)
168        self.assertEqual(response.url, relay_state)

Test that redirect view uses plan context fallback when no RelayState

def test_redirect_view_handles_logout_response_no_relay_state_no_session(self):
170    def test_redirect_view_handles_logout_response_no_relay_state_no_session(self):
171        """Test that redirect view uses root redirect as final fallback"""
172        # Create request with SAML logout response
173        request = self.factory.get(
174            f"/slo/redirect/{self.application.slug}/",
175            {
176                "SAMLResponse": "dummy-response",
177            },
178        )
179        request.session = {}
180        request.brand = self.brand
181
182        view = SPInitiatedSLOBindingRedirectView()
183        view.setup(request, application_slug=self.application.slug)
184        response = view.dispatch(request, application_slug=self.application.slug)
185
186        # Should redirect to root-redirect
187        self.assertEqual(response.status_code, 302)
188        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test that redirect view uses root redirect as final fallback

def test_redirect_view_missing_saml_request(self):
190    def test_redirect_view_missing_saml_request(self):
191        """Test redirect view when SAML request is missing"""
192        request = self.factory.get(f"/slo/redirect/{self.application.slug}/")
193        request.session = {}
194        request.brand = self.brand
195
196        view = SPInitiatedSLOBindingRedirectView()
197        view.setup(request, application_slug=self.application.slug)
198        view.resolve_provider_application()
199
200        # Should return error response
201        result = view.check_saml_request()
202        self.assertIsNotNone(result)
203        self.assertEqual(result.status_code, 400)

Test redirect view when SAML request is missing

def test_post_view_handles_logout_request(self):
205    def test_post_view_handles_logout_request(self):
206        """Test that POST view properly handles a logout request"""
207        # Generate encoded logout request
208        encoded_request = self.processor.encode_post()
209
210        # Create POST request with SAML logout request
211        request = self.factory.post(
212            f"/slo/post/{self.application.slug}/",
213            {
214                "SAMLRequest": encoded_request,
215                "RelayState": "https://sp.example.com/return",
216            },
217        )
218        request.session = {}
219        request.brand = self.brand
220
221        view = SPInitiatedSLOBindingPOSTView()
222        view.setup(request, application_slug=self.application.slug)
223        view.resolve_provider_application()
224
225        # Check that the SAML request is parsed correctly
226        result = view.check_saml_request()
227        self.assertIsNone(result)  # None means success
228
229        # Verify logout request was stored in plan context
230        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
231        logout_request = view.plan_context["authentik/providers/saml/logout_request"]
232        self.assertEqual(logout_request.issuer, self.provider.issuer)
233        self.assertEqual(logout_request.session_index, "test-session-123")

Test that POST view properly handles a logout request

def test_post_view_handles_logout_response_with_plan_context(self):
235    def test_post_view_handles_logout_response_with_plan_context(self):
236        """Test that POST view always redirects to plan context URL, ignoring RelayState"""
237        plan_relay_state = "https://idp.example.com/flow/return"
238
239        # Create POST request with SAML logout response
240        request = self.factory.post(
241            f"/slo/post/{self.application.slug}/",
242            {
243                "SAMLResponse": "dummy-response",
244                "RelayState": "https://somewhere-else.example.com/return",
245            },
246        )
247        plan = FlowPlan(flow_pk="test-flow")
248        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
249        request.session = {SESSION_KEY_PLAN: plan}
250        request.brand = self.brand
251
252        view = SPInitiatedSLOBindingPOSTView()
253        view.setup(request, application_slug=self.application.slug)
254        response = view.dispatch(request, application_slug=self.application.slug)
255
256        # Should redirect to plan context URL, not the request's RelayState
257        self.assertEqual(response.status_code, 302)
258        self.assertEqual(response.url, plan_relay_state)

Test that POST view always redirects to plan context URL, ignoring RelayState

def test_post_view_handles_logout_response_no_relay_state_with_plan_context(self):
260    def test_post_view_handles_logout_response_no_relay_state_with_plan_context(self):
261        """Test that POST view uses plan context fallback when no RelayState"""
262        relay_state = "https://idp.example.com/flow/plan-return"
263
264        # Create POST request with SAML logout response
265        request = self.factory.post(
266            f"/slo/post/{self.application.slug}/",
267            {
268                "SAMLResponse": "dummy-response",
269            },
270        )
271        # Create a flow plan with the return URL
272        plan = FlowPlan(flow_pk="test-flow")
273        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
274        request.session = {SESSION_KEY_PLAN: plan}
275        request.brand = self.brand
276
277        view = SPInitiatedSLOBindingPOSTView()
278        view.setup(request, application_slug=self.application.slug)
279        response = view.dispatch(request, application_slug=self.application.slug)
280
281        # Should redirect to plan context stored URL
282        self.assertEqual(response.status_code, 302)
283        self.assertEqual(response.url, relay_state)

Test that POST view uses plan context fallback when no RelayState

def test_post_view_missing_saml_request(self):
285    def test_post_view_missing_saml_request(self):
286        """Test POST view when SAML request is missing"""
287        request = self.factory.post(f"/slo/post/{self.application.slug}/", {})
288        request.session = {}
289        request.brand = self.brand
290
291        view = SPInitiatedSLOBindingPOSTView()
292        view.setup(request, application_slug=self.application.slug)
293        view.resolve_provider_application()
294
295        # Should return error response
296        result = view.check_saml_request()
297        self.assertIsNotNone(result)
298        self.assertEqual(result.status_code, 400)

Test POST view when SAML request is missing

@patch('authentik.providers.saml.views.sp_slo.LOGGER')
@patch('authentik.providers.saml.views.sp_slo.Event')
@patch('authentik.providers.saml.views.sp_slo.LogoutRequestParser')
def test_redirect_view_handles_parser_exception(self, mock_parser_class, mock_event_class, mock_logger):
300    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
301    @patch("authentik.providers.saml.views.sp_slo.Event")
302    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
303    def test_redirect_view_handles_parser_exception(
304        self, mock_parser_class, mock_event_class, mock_logger
305    ):
306        """Test redirect view handles parser exception gracefully"""
307        # Mock Event.new to avoid the error
308        mock_event = MagicMock()
309        mock_event.save = MagicMock()
310        mock_event_class.new.return_value = mock_event
311
312        # Mock LOGGER.error to avoid the error
313        mock_logger.error = MagicMock()
314
315        # Make parser raise exception
316        mock_parser = MagicMock()
317        mock_parser.parse_detached.side_effect = CannotHandleAssertion("Invalid request")
318        mock_parser_class.return_value = mock_parser
319
320        # Create request with SAML logout request
321        request = self.factory.get(
322            f"/slo/redirect/{self.application.slug}/",
323            {
324                "SAMLRequest": "invalid-request",
325                "RelayState": "test",
326            },
327        )
328        request.session = {}
329        request.brand = self.brand
330
331        view = SPInitiatedSLOBindingRedirectView()
332        view.setup(request, application_slug=self.application.slug)
333        view.resolve_provider_application()
334
335        # Should return error response
336        result = view.check_saml_request()
337        self.assertIsNotNone(result)
338        self.assertEqual(result.status_code, 400)

Test redirect view handles parser exception gracefully

@patch('authentik.providers.saml.views.sp_slo.LOGGER')
@patch('authentik.providers.saml.views.sp_slo.Event')
@patch('authentik.providers.saml.views.sp_slo.LogoutRequestParser')
def test_post_view_handles_parser_exception(self, mock_parser_class, mock_event_class, mock_logger):
340    @patch("authentik.providers.saml.views.sp_slo.LOGGER")
341    @patch("authentik.providers.saml.views.sp_slo.Event")
342    @patch("authentik.providers.saml.views.sp_slo.LogoutRequestParser")
343    def test_post_view_handles_parser_exception(
344        self, mock_parser_class, mock_event_class, mock_logger
345    ):
346        """Test POST view handles parser exception gracefully"""
347        # Mock Event.new to avoid the error
348        mock_event = MagicMock()
349        mock_event.save = MagicMock()
350        mock_event_class.new.return_value = mock_event
351
352        # Mock LOGGER.error to avoid the error
353        mock_logger.error = MagicMock()
354
355        # Make parser raise exception
356        mock_parser = MagicMock()
357        mock_parser.parse.side_effect = CannotHandleAssertion("Invalid request")
358        mock_parser_class.return_value = mock_parser
359
360        # Create POST request with SAML logout request
361        request = self.factory.post(
362            f"/slo/post/{self.application.slug}/",
363            {
364                "SAMLRequest": "invalid-request",
365                "RelayState": "test",
366            },
367        )
368        request.session = {}
369        request.brand = self.brand
370
371        view = SPInitiatedSLOBindingPOSTView()
372        view.setup(request, application_slug=self.application.slug)
373        view.resolve_provider_application()
374
375        # Should return error response
376        result = view.check_saml_request()
377        self.assertIsNotNone(result)
378        self.assertEqual(result.status_code, 400)

Test POST view handles parser exception gracefully

def test_application_not_found(self):
380    def test_application_not_found(self):
381        """Test handling when application doesn't exist"""
382        request = self.factory.get("/slo/redirect/non-existent/")
383        request.session = {}
384        request.brand = self.brand
385
386        view = SPInitiatedSLOBindingRedirectView()
387        view.setup(request, application_slug="non-existent")
388
389        with self.assertRaises(Http404):
390            view.resolve_provider_application()

Test handling when application doesn't exist

def test_provider_without_invalidation_flow(self):
392    def test_provider_without_invalidation_flow(self):
393        """Test handling when provider has no invalidation flow and brand has no default"""
394        # Create provider without invalidation flow
395        provider = SAMLProvider.objects.create(
396            name="no-flow-provider",
397            authorization_flow=self.flow,
398            acs_url="https://sp2.example.com/acs",
399            sls_url="https://sp2.example.com/sls",
400            issuer="https://idp2.example.com",
401            invalidation_flow=None,  # No invalidation flow
402        )
403
404        app = Application.objects.create(
405            name="no-flow-app",
406            slug="no-flow-app",
407            provider=provider,
408        )
409
410        # Brand with no flow_invalidation
411        self.brand.flow_invalidation = None
412        self.brand.save()
413
414        request = self.factory.get(f"/slo/redirect/{app.slug}/")
415        request.session = {}
416        request.brand = self.brand
417
418        view = SPInitiatedSLOBindingRedirectView()
419        view.setup(request, application_slug=app.slug)
420
421        with self.assertRaises(Http404):
422            view.resolve_provider_application()

Test handling when provider has no invalidation flow and brand has no default

def test_relay_state_decoding_failure(self):
424    def test_relay_state_decoding_failure(self):
425        """Test that arbitrary path RelayState is ignored and redirects to root"""
426        # Create request with relay state that is a path
427        request = self.factory.get(
428            f"/slo/redirect/{self.application.slug}/",
429            {
430                "SAMLResponse": "dummy-response",
431                "RelayState": "/some/invalid/path",  # Use a path that starts with /
432            },
433        )
434        request.session = {}
435        request.brand = self.brand
436
437        view = SPInitiatedSLOBindingRedirectView()
438        view.setup(request, application_slug=self.application.slug)
439        response = view.dispatch(request, application_slug=self.application.slug)
440
441        # Should ignore relay_state and redirect to root (no plan context)
442        self.assertEqual(response.status_code, 302)
443        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test that arbitrary path RelayState is ignored and redirects to root

def test_redirect_view_blocks_external_relay_state(self):
445    def test_redirect_view_blocks_external_relay_state(self):
446        """Test that redirect view ignores external malicious URL and redirects to root"""
447        request = self.factory.get(
448            f"/slo/redirect/{self.application.slug}/",
449            {
450                "SAMLResponse": "dummy-response",
451                "RelayState": "https://evil.com/phishing",
452            },
453        )
454        request.session = {}
455        request.brand = self.brand
456
457        view = SPInitiatedSLOBindingRedirectView()
458        view.setup(request, application_slug=self.application.slug)
459        response = view.dispatch(request, application_slug=self.application.slug)
460
461        # Should ignore relay_state and redirect to root (no plan context)
462        self.assertEqual(response.status_code, 302)
463        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test that redirect view ignores external malicious URL and redirects to root

def test_redirect_view_ignores_relay_state_uses_plan_context(self):
465    def test_redirect_view_ignores_relay_state_uses_plan_context(self):
466        """Test that redirect view always uses plan context URL regardless of RelayState"""
467        plan_relay_state = "https://authentik.example.com/if/flow/logout/"
468
469        request = self.factory.get(
470            f"/slo/redirect/{self.application.slug}/",
471            {
472                "SAMLResponse": "dummy-response",
473                "RelayState": "https://evil.com/phishing",
474            },
475        )
476        plan = FlowPlan(flow_pk="test-flow")
477        plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
478        request.session = {SESSION_KEY_PLAN: plan}
479        request.brand = self.brand
480
481        view = SPInitiatedSLOBindingRedirectView()
482        view.setup(request, application_slug=self.application.slug)
483        response = view.dispatch(request, application_slug=self.application.slug)
484
485        # Should always use plan context value, ignoring malicious RelayState
486        self.assertEqual(response.status_code, 302)
487        self.assertEqual(response.url, plan_relay_state)

Test that redirect view always uses plan context URL regardless of RelayState

def test_post_view_ignores_external_relay_state(self):
489    def test_post_view_ignores_external_relay_state(self):
490        """Test that POST view ignores external RelayState and redirects to root"""
491        request = self.factory.post(
492            f"/slo/post/{self.application.slug}/",
493            {
494                "SAMLResponse": "dummy-response",
495                "RelayState": "https://evil.com/phishing",
496            },
497        )
498        request.session = {}
499        request.brand = self.brand
500
501        view = SPInitiatedSLOBindingPOSTView()
502        view.setup(request, application_slug=self.application.slug)
503        response = view.dispatch(request, application_slug=self.application.slug)
504
505        # Should ignore relay_state and redirect to root (no plan context)
506        self.assertEqual(response.status_code, 302)
507        self.assertEqual(response.url, reverse("authentik_core:root-redirect"))

Test that POST view ignores external RelayState and redirects to root

class TestSPInitiatedSLOLogoutMethods(django.test.testcases.TestCase):
510class TestSPInitiatedSLOLogoutMethods(TestCase):
511    """Test SP-initiated SAML SLO logout method branching"""
512
513    def setUp(self):
514        """Set up test fixtures"""
515        self.factory = RequestFactory()
516        self.brand = create_test_brand()
517        self.flow = create_test_flow()
518        self.invalidation_flow = create_test_flow()
519        self.cert = create_test_cert()
520
521        # Create provider with sls_url
522        self.provider = SAMLProvider.objects.create(
523            name="test-provider",
524            authorization_flow=self.flow,
525            invalidation_flow=self.invalidation_flow,
526            acs_url="https://sp.example.com/acs",
527            sls_url="https://sp.example.com/sls",
528            issuer="https://idp.example.com",
529            sp_binding="redirect",
530            sls_binding="redirect",
531            signing_kp=self.cert,
532        )
533
534        # Create application
535        self.application = Application.objects.create(
536            name="test-app",
537            slug="test-app-logout-methods",
538            provider=self.provider,
539        )
540
541        # Create logout request processor for generating test requests
542        self.processor = LogoutRequestProcessor(
543            provider=self.provider,
544            user=None,
545            destination="https://idp.example.com/sls",
546            name_id="test@example.com",
547            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
548            session_index="test-session-123",
549            relay_state="https://sp.example.com/return",
550        )
551
552    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
553    def test_frontchannel_native_post_binding(self, mock_auth_session):
554        """Test FRONTCHANNEL_NATIVE with POST binding parses request correctly"""
555        mock_auth_session.from_request.return_value = None
556
557        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
558        self.provider.sls_binding = SAMLBindings.POST
559        self.provider.save()
560
561        encoded_request = self.processor.encode_redirect()
562
563        request = self.factory.get(
564            f"/slo/redirect/{self.application.slug}/",
565            {
566                "SAMLRequest": encoded_request,
567                "RelayState": "https://sp.example.com/return",
568            },
569        )
570        request.session = {}
571        request.brand = self.brand
572        request.user = MagicMock()
573
574        view = SPInitiatedSLOBindingRedirectView()
575        view.setup(request, application_slug=self.application.slug)
576        view.resolve_provider_application()
577        view.check_saml_request()
578
579        # Verify the logout request was parsed and provider is configured correctly
580        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
581        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.FRONTCHANNEL_NATIVE)
582        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)
583
584    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
585    def test_frontchannel_native_redirect_binding(self, mock_auth_session):
586        """Test FRONTCHANNEL_NATIVE with REDIRECT binding creates redirect URL"""
587        mock_auth_session.from_request.return_value = None
588
589        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
590        self.provider.sls_binding = SAMLBindings.REDIRECT
591        self.provider.save()
592
593        encoded_request = self.processor.encode_redirect()
594
595        request = self.factory.get(
596            f"/slo/redirect/{self.application.slug}/",
597            {
598                "SAMLRequest": encoded_request,
599                "RelayState": "https://sp.example.com/return",
600            },
601        )
602        request.session = {}
603        request.brand = self.brand
604        request.user = MagicMock()
605
606        view = SPInitiatedSLOBindingRedirectView()
607        view.setup(request, application_slug=self.application.slug)
608        view.resolve_provider_application()
609        view.check_saml_request()
610
611        # Verify the logout request was parsed
612        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
613
614    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
615    def test_frontchannel_iframe_post_binding(self, mock_auth_session):
616        """Test FRONTCHANNEL_IFRAME with POST binding creates IframeLogoutStageView"""
617        mock_auth_session.from_request.return_value = None
618
619        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
620        self.provider.sls_binding = SAMLBindings.POST
621        self.provider.save()
622
623        encoded_request = self.processor.encode_redirect()
624
625        request = self.factory.get(
626            f"/slo/redirect/{self.application.slug}/",
627            {
628                "SAMLRequest": encoded_request,
629                "RelayState": "https://sp.example.com/return",
630            },
631        )
632        request.session = {}
633        request.brand = self.brand
634        request.user = MagicMock()
635
636        view = SPInitiatedSLOBindingRedirectView()
637        view.setup(request, application_slug=self.application.slug)
638        view.resolve_provider_application()
639        view.check_saml_request()
640
641        # Verify the logout request was parsed
642        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
643
644    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
645    def test_frontchannel_iframe_redirect_binding(self, mock_auth_session):
646        """Test FRONTCHANNEL_IFRAME with REDIRECT binding"""
647        mock_auth_session.from_request.return_value = None
648
649        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
650        self.provider.sls_binding = SAMLBindings.REDIRECT
651        self.provider.save()
652
653        encoded_request = self.processor.encode_redirect()
654
655        request = self.factory.get(
656            f"/slo/redirect/{self.application.slug}/",
657            {
658                "SAMLRequest": encoded_request,
659                "RelayState": "https://sp.example.com/return",
660            },
661        )
662        request.session = {}
663        request.brand = self.brand
664        request.user = MagicMock()
665
666        view = SPInitiatedSLOBindingRedirectView()
667        view.setup(request, application_slug=self.application.slug)
668        view.resolve_provider_application()
669        view.check_saml_request()
670
671        # Verify the logout request was parsed
672        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
673
674    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
675    def test_backchannel_parses_request(self, mock_auth_session):
676        """Test BACKCHANNEL mode parses request correctly"""
677        mock_auth_session.from_request.return_value = None
678
679        self.provider.logout_method = SAMLLogoutMethods.BACKCHANNEL
680        self.provider.sls_binding = SAMLBindings.POST
681        self.provider.save()
682
683        encoded_request = self.processor.encode_redirect()
684
685        request = self.factory.get(
686            f"/slo/redirect/{self.application.slug}/",
687            {
688                "SAMLRequest": encoded_request,
689                "RelayState": "https://sp.example.com/return",
690            },
691        )
692        request.session = {}
693        request.brand = self.brand
694        request.user = MagicMock()
695
696        view = SPInitiatedSLOBindingRedirectView()
697        view.setup(request, application_slug=self.application.slug)
698        view.resolve_provider_application()
699        view.check_saml_request()
700
701        # Verify the logout request was parsed and provider is configured correctly
702        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
703        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.BACKCHANNEL)
704        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)
705
706    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
707    def test_no_sls_url_only_session_end(self, mock_auth_session):
708        """Test that only SessionEndStage is appended when sls_url is empty"""
709        mock_auth_session.from_request.return_value = None
710
711        # Create provider without sls_url
712        provider_no_sls = SAMLProvider.objects.create(
713            name="no-sls-provider",
714            authorization_flow=self.flow,
715            invalidation_flow=self.invalidation_flow,
716            acs_url="https://sp.example.com/acs",
717            sls_url="",  # No SLS URL
718            issuer="https://idp.example.com",
719        )
720
721        app_no_sls = Application.objects.create(
722            name="no-sls-app",
723            slug="no-sls-app",
724            provider=provider_no_sls,
725        )
726
727        processor = LogoutRequestProcessor(
728            provider=provider_no_sls,
729            user=None,
730            destination="https://idp.example.com/sls",
731            name_id="test@example.com",
732            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
733            session_index="test-session-123",
734        )
735        encoded_request = processor.encode_redirect()
736
737        request = self.factory.get(
738            f"/slo/redirect/{app_no_sls.slug}/",
739            {
740                "SAMLRequest": encoded_request,
741            },
742        )
743        request.session = {}
744        request.brand = self.brand
745        request.user = MagicMock()
746
747        view = SPInitiatedSLOBindingRedirectView()
748        view.setup(request, application_slug=app_no_sls.slug)
749        view.resolve_provider_application()
750        view.check_saml_request()
751
752        # Verify the provider has no sls_url
753        self.assertEqual(view.provider.sls_url, "")
754
755    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
756    def test_relay_state_propagation(self, mock_auth_session):
757        """Test that relay state from logout request is passed through to response"""
758        mock_auth_session.from_request.return_value = None
759
760        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
761        self.provider.save()
762
763        expected_relay_state = "https://sp.example.com/custom-return"
764
765        processor = LogoutRequestProcessor(
766            provider=self.provider,
767            user=None,
768            destination="https://idp.example.com/sls",
769            name_id="test@example.com",
770            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
771            session_index="test-session-123",
772            relay_state=expected_relay_state,
773        )
774        encoded_request = processor.encode_redirect()
775
776        request = self.factory.get(
777            f"/slo/redirect/{self.application.slug}/",
778            {
779                "SAMLRequest": encoded_request,
780                "RelayState": expected_relay_state,
781            },
782        )
783        request.session = {}
784        request.brand = self.brand
785        request.user = MagicMock()
786
787        view = SPInitiatedSLOBindingRedirectView()
788        view.setup(request, application_slug=self.application.slug)
789        view.resolve_provider_application()
790        view.check_saml_request()
791
792        # Verify relay state was captured
793        logout_request = view.plan_context.get("authentik/providers/saml/logout_request")
794        self.assertEqual(logout_request.relay_state, expected_relay_state)

Test SP-initiated SAML SLO logout method branching

def setUp(self):
513    def setUp(self):
514        """Set up test fixtures"""
515        self.factory = RequestFactory()
516        self.brand = create_test_brand()
517        self.flow = create_test_flow()
518        self.invalidation_flow = create_test_flow()
519        self.cert = create_test_cert()
520
521        # Create provider with sls_url
522        self.provider = SAMLProvider.objects.create(
523            name="test-provider",
524            authorization_flow=self.flow,
525            invalidation_flow=self.invalidation_flow,
526            acs_url="https://sp.example.com/acs",
527            sls_url="https://sp.example.com/sls",
528            issuer="https://idp.example.com",
529            sp_binding="redirect",
530            sls_binding="redirect",
531            signing_kp=self.cert,
532        )
533
534        # Create application
535        self.application = Application.objects.create(
536            name="test-app",
537            slug="test-app-logout-methods",
538            provider=self.provider,
539        )
540
541        # Create logout request processor for generating test requests
542        self.processor = LogoutRequestProcessor(
543            provider=self.provider,
544            user=None,
545            destination="https://idp.example.com/sls",
546            name_id="test@example.com",
547            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
548            session_index="test-session-123",
549            relay_state="https://sp.example.com/return",
550        )

Set up test fixtures

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_frontchannel_native_post_binding(self, mock_auth_session):
552    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
553    def test_frontchannel_native_post_binding(self, mock_auth_session):
554        """Test FRONTCHANNEL_NATIVE with POST binding parses request correctly"""
555        mock_auth_session.from_request.return_value = None
556
557        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
558        self.provider.sls_binding = SAMLBindings.POST
559        self.provider.save()
560
561        encoded_request = self.processor.encode_redirect()
562
563        request = self.factory.get(
564            f"/slo/redirect/{self.application.slug}/",
565            {
566                "SAMLRequest": encoded_request,
567                "RelayState": "https://sp.example.com/return",
568            },
569        )
570        request.session = {}
571        request.brand = self.brand
572        request.user = MagicMock()
573
574        view = SPInitiatedSLOBindingRedirectView()
575        view.setup(request, application_slug=self.application.slug)
576        view.resolve_provider_application()
577        view.check_saml_request()
578
579        # Verify the logout request was parsed and provider is configured correctly
580        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
581        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.FRONTCHANNEL_NATIVE)
582        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)

Test FRONTCHANNEL_NATIVE with POST binding parses request correctly

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_frontchannel_native_redirect_binding(self, mock_auth_session):
584    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
585    def test_frontchannel_native_redirect_binding(self, mock_auth_session):
586        """Test FRONTCHANNEL_NATIVE with REDIRECT binding creates redirect URL"""
587        mock_auth_session.from_request.return_value = None
588
589        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE
590        self.provider.sls_binding = SAMLBindings.REDIRECT
591        self.provider.save()
592
593        encoded_request = self.processor.encode_redirect()
594
595        request = self.factory.get(
596            f"/slo/redirect/{self.application.slug}/",
597            {
598                "SAMLRequest": encoded_request,
599                "RelayState": "https://sp.example.com/return",
600            },
601        )
602        request.session = {}
603        request.brand = self.brand
604        request.user = MagicMock()
605
606        view = SPInitiatedSLOBindingRedirectView()
607        view.setup(request, application_slug=self.application.slug)
608        view.resolve_provider_application()
609        view.check_saml_request()
610
611        # Verify the logout request was parsed
612        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)

Test FRONTCHANNEL_NATIVE with REDIRECT binding creates redirect URL

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_frontchannel_iframe_post_binding(self, mock_auth_session):
614    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
615    def test_frontchannel_iframe_post_binding(self, mock_auth_session):
616        """Test FRONTCHANNEL_IFRAME with POST binding creates IframeLogoutStageView"""
617        mock_auth_session.from_request.return_value = None
618
619        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
620        self.provider.sls_binding = SAMLBindings.POST
621        self.provider.save()
622
623        encoded_request = self.processor.encode_redirect()
624
625        request = self.factory.get(
626            f"/slo/redirect/{self.application.slug}/",
627            {
628                "SAMLRequest": encoded_request,
629                "RelayState": "https://sp.example.com/return",
630            },
631        )
632        request.session = {}
633        request.brand = self.brand
634        request.user = MagicMock()
635
636        view = SPInitiatedSLOBindingRedirectView()
637        view.setup(request, application_slug=self.application.slug)
638        view.resolve_provider_application()
639        view.check_saml_request()
640
641        # Verify the logout request was parsed
642        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)

Test FRONTCHANNEL_IFRAME with POST binding creates IframeLogoutStageView

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_frontchannel_iframe_redirect_binding(self, mock_auth_session):
644    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
645    def test_frontchannel_iframe_redirect_binding(self, mock_auth_session):
646        """Test FRONTCHANNEL_IFRAME with REDIRECT binding"""
647        mock_auth_session.from_request.return_value = None
648
649        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
650        self.provider.sls_binding = SAMLBindings.REDIRECT
651        self.provider.save()
652
653        encoded_request = self.processor.encode_redirect()
654
655        request = self.factory.get(
656            f"/slo/redirect/{self.application.slug}/",
657            {
658                "SAMLRequest": encoded_request,
659                "RelayState": "https://sp.example.com/return",
660            },
661        )
662        request.session = {}
663        request.brand = self.brand
664        request.user = MagicMock()
665
666        view = SPInitiatedSLOBindingRedirectView()
667        view.setup(request, application_slug=self.application.slug)
668        view.resolve_provider_application()
669        view.check_saml_request()
670
671        # Verify the logout request was parsed
672        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)

Test FRONTCHANNEL_IFRAME with REDIRECT binding

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_backchannel_parses_request(self, mock_auth_session):
674    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
675    def test_backchannel_parses_request(self, mock_auth_session):
676        """Test BACKCHANNEL mode parses request correctly"""
677        mock_auth_session.from_request.return_value = None
678
679        self.provider.logout_method = SAMLLogoutMethods.BACKCHANNEL
680        self.provider.sls_binding = SAMLBindings.POST
681        self.provider.save()
682
683        encoded_request = self.processor.encode_redirect()
684
685        request = self.factory.get(
686            f"/slo/redirect/{self.application.slug}/",
687            {
688                "SAMLRequest": encoded_request,
689                "RelayState": "https://sp.example.com/return",
690            },
691        )
692        request.session = {}
693        request.brand = self.brand
694        request.user = MagicMock()
695
696        view = SPInitiatedSLOBindingRedirectView()
697        view.setup(request, application_slug=self.application.slug)
698        view.resolve_provider_application()
699        view.check_saml_request()
700
701        # Verify the logout request was parsed and provider is configured correctly
702        self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
703        self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.BACKCHANNEL)
704        self.assertEqual(view.provider.sls_binding, SAMLBindings.POST)

Test BACKCHANNEL mode parses request correctly

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_no_sls_url_only_session_end(self, mock_auth_session):
706    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
707    def test_no_sls_url_only_session_end(self, mock_auth_session):
708        """Test that only SessionEndStage is appended when sls_url is empty"""
709        mock_auth_session.from_request.return_value = None
710
711        # Create provider without sls_url
712        provider_no_sls = SAMLProvider.objects.create(
713            name="no-sls-provider",
714            authorization_flow=self.flow,
715            invalidation_flow=self.invalidation_flow,
716            acs_url="https://sp.example.com/acs",
717            sls_url="",  # No SLS URL
718            issuer="https://idp.example.com",
719        )
720
721        app_no_sls = Application.objects.create(
722            name="no-sls-app",
723            slug="no-sls-app",
724            provider=provider_no_sls,
725        )
726
727        processor = LogoutRequestProcessor(
728            provider=provider_no_sls,
729            user=None,
730            destination="https://idp.example.com/sls",
731            name_id="test@example.com",
732            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
733            session_index="test-session-123",
734        )
735        encoded_request = processor.encode_redirect()
736
737        request = self.factory.get(
738            f"/slo/redirect/{app_no_sls.slug}/",
739            {
740                "SAMLRequest": encoded_request,
741            },
742        )
743        request.session = {}
744        request.brand = self.brand
745        request.user = MagicMock()
746
747        view = SPInitiatedSLOBindingRedirectView()
748        view.setup(request, application_slug=app_no_sls.slug)
749        view.resolve_provider_application()
750        view.check_saml_request()
751
752        # Verify the provider has no sls_url
753        self.assertEqual(view.provider.sls_url, "")

Test that only SessionEndStage is appended when sls_url is empty

@patch('authentik.providers.saml.views.sp_slo.AuthenticatedSession')
def test_relay_state_propagation(self, mock_auth_session):
755    @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession")
756    def test_relay_state_propagation(self, mock_auth_session):
757        """Test that relay state from logout request is passed through to response"""
758        mock_auth_session.from_request.return_value = None
759
760        self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME
761        self.provider.save()
762
763        expected_relay_state = "https://sp.example.com/custom-return"
764
765        processor = LogoutRequestProcessor(
766            provider=self.provider,
767            user=None,
768            destination="https://idp.example.com/sls",
769            name_id="test@example.com",
770            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
771            session_index="test-session-123",
772            relay_state=expected_relay_state,
773        )
774        encoded_request = processor.encode_redirect()
775
776        request = self.factory.get(
777            f"/slo/redirect/{self.application.slug}/",
778            {
779                "SAMLRequest": encoded_request,
780                "RelayState": expected_relay_state,
781            },
782        )
783        request.session = {}
784        request.brand = self.brand
785        request.user = MagicMock()
786
787        view = SPInitiatedSLOBindingRedirectView()
788        view.setup(request, application_slug=self.application.slug)
789        view.resolve_provider_application()
790        view.check_saml_request()
791
792        # Verify relay state was captured
793        logout_request = view.plan_context.get("authentik/providers/saml/logout_request")
794        self.assertEqual(logout_request.relay_state, expected_relay_state)

Test that relay state from logout request is passed through to response