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