authentik.providers.saml.tests.test_idp_logout
Test IdP Logout Stages
1"""Test IdP Logout Stages""" 2 3import base64 4from unittest.mock import Mock 5 6from django.test import RequestFactory, TestCase 7 8from authentik.common.saml.constants import ( 9 RSA_SHA256, 10 SAML_NAME_ID_FORMAT_EMAIL, 11) 12from authentik.core.tests.utils import create_test_flow 13from authentik.flows.planner import FlowPlan 14from authentik.flows.tests import FlowTestCase 15from authentik.flows.views.executor import FlowExecutorView 16from authentik.providers.iframe_logout import ( 17 IframeLogoutChallenge, 18 IframeLogoutStageView, 19) 20from authentik.providers.saml.models import SAMLLogoutMethods, SAMLProvider 21from authentik.providers.saml.native_logout import ( 22 NativeLogoutChallenge, 23 NativeLogoutStageView, 24) 25from authentik.providers.saml.views.flows import ( 26 PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS, 27 PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS, 28) 29 30 31class TestNativeLogoutStageView(TestCase): 32 """Test NativeLogoutStageView (redirect chain logout)""" 33 34 def setUp(self): 35 """Set up test fixtures""" 36 self.factory = RequestFactory() 37 self.flow = create_test_flow() 38 39 # Create test providers 40 self.provider1 = SAMLProvider.objects.create( 41 name="test-provider-1", 42 authorization_flow=self.flow, 43 acs_url="https://sp1.example.com/acs", 44 sls_url="https://sp1.example.com/sls", 45 issuer="https://idp.example.com", 46 sp_binding="redirect", 47 sls_binding="redirect", 48 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 49 ) 50 51 self.provider2 = SAMLProvider.objects.create( 52 name="test-provider-2", 53 authorization_flow=self.flow, 54 acs_url="https://sp2.example.com/acs", 55 sls_url="https://sp2.example.com/sls", 56 issuer="https://idp.example.com", 57 sp_binding="post", 58 sls_binding="post", 59 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 60 ) 61 62 def test_get_challenge_with_pending_providers_redirect(self): 63 """Test get_challenge when there are pending providers with redirect binding""" 64 request = self.factory.get("/") 65 request.session = {} 66 67 plan = FlowPlan(flow_pk=self.flow.pk.hex) 68 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 69 { 70 "redirect_url": "https://sp1.example.com/sls?SAMLRequest=encoded", 71 "provider_name": "test-provider-1", 72 "saml_binding": "redirect", 73 } 74 ] 75 stage_view = NativeLogoutStageView( 76 FlowExecutorView( 77 request=request, 78 flow=self.flow, 79 plan=plan, 80 ), 81 request=request, 82 ) 83 84 challenge = stage_view.get_challenge() 85 86 # Should return a NativeLogoutChallenge 87 self.assertIsInstance(challenge, NativeLogoutChallenge) 88 self.assertEqual(challenge.initial_data["saml_binding"], "redirect") 89 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-1") 90 self.assertIn("redirect_url", challenge.initial_data) 91 92 # Should have removed the provider from pending list 93 self.assertEqual(len(plan.context.get(PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS, [])), 0) 94 95 def test_get_challenge_with_pending_providers_post(self): 96 """Test get_challenge when there are pending providers with POST binding""" 97 request = self.factory.get("/") 98 request.session = {} 99 100 plan = FlowPlan(flow_pk=self.flow.pk.hex) 101 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 102 { 103 "post_url": "https://sp2.example.com/sls", 104 "saml_request": "encoded_saml_request", 105 "saml_relay_state": "https://idp.example.com/flow/test-flow", 106 "provider_name": "test-provider-2", 107 "saml_binding": "post", 108 } 109 ] 110 stage_view = NativeLogoutStageView( 111 FlowExecutorView( 112 request=request, 113 flow=self.flow, 114 plan=plan, 115 ), 116 request=request, 117 ) 118 119 challenge = stage_view.get_challenge() 120 121 # Should return a NativeLogoutChallenge 122 self.assertIsInstance(challenge, NativeLogoutChallenge) 123 self.assertEqual(challenge.initial_data["saml_binding"], "post") 124 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-2") 125 self.assertEqual(challenge.initial_data["post_url"], "https://sp2.example.com/sls") 126 self.assertIn("saml_request", challenge.initial_data) 127 self.assertIn("saml_relay_state", challenge.initial_data) 128 129 def test_get_challenge_all_complete(self): 130 """Test get_challenge when all providers are done""" 131 request = self.factory.get("/") 132 request.session = {} 133 134 plan = FlowPlan(flow_pk=self.flow.pk.hex) 135 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] # No pending providers 136 stage_view = NativeLogoutStageView( 137 FlowExecutorView( 138 request=request, 139 flow=self.flow, 140 plan=plan, 141 ), 142 request=request, 143 ) 144 145 challenge = stage_view.get_challenge() 146 147 # Should return completion challenge 148 self.assertIsInstance(challenge, NativeLogoutChallenge) 149 self.assertEqual(challenge.initial_data["is_complete"], True) 150 151 def test_get_challenge_with_empty_sessions(self): 152 """Test get_challenge when sessions list is empty""" 153 request = self.factory.get("/") 154 request.session = {} 155 156 plan = FlowPlan(flow_pk=self.flow.pk.hex) 157 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 158 stage_view = NativeLogoutStageView( 159 FlowExecutorView( 160 request=request, 161 flow=self.flow, 162 plan=plan, 163 ), 164 request=request, 165 ) 166 167 challenge = stage_view.get_challenge() 168 169 # Should return completion challenge 170 self.assertIsInstance(challenge, NativeLogoutChallenge) 171 self.assertEqual(challenge.initial_data["is_complete"], True) 172 173 def test_challenge_valid_continues_flow(self): 174 """Test challenge_valid continues to next provider or completes""" 175 request = self.factory.post("/") 176 request.session = {} 177 178 plan = FlowPlan(flow_pk=self.flow.pk.hex) 179 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 180 executor = FlowExecutorView( 181 request=request, 182 flow=self.flow, 183 plan=plan, 184 ) 185 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 186 187 stage_view = NativeLogoutStageView(executor, request=request) 188 189 # Mock get_challenge to return completion 190 stage_view.get_challenge = Mock() 191 completion_challenge = NativeLogoutChallenge(data={"is_complete": True}) 192 completion_challenge.is_valid() 193 stage_view.get_challenge.return_value = completion_challenge 194 195 response = Mock() 196 stage_view.challenge_valid(response) 197 198 # Should call stage_ok when complete and return its value 199 executor.stage_ok.assert_called_once() 200 201 202class TestIframeLogoutStageView(TestCase): 203 """Test IframeLogoutStageView (parallel iframe logout)""" 204 205 def setUp(self): 206 """Set up test fixtures""" 207 self.factory = RequestFactory() 208 self.flow = create_test_flow() 209 210 # Create test providers 211 self.provider1 = SAMLProvider.objects.create( 212 name="test-provider-1", 213 authorization_flow=self.flow, 214 acs_url="https://sp1.example.com/acs", 215 sls_url="https://sp1.example.com/sls", 216 issuer="https://idp.example.com", 217 sp_binding="redirect", 218 sls_binding="redirect", 219 logout_method="frontchannel_iframe", 220 ) 221 222 self.provider2 = SAMLProvider.objects.create( 223 name="test-provider-2", 224 authorization_flow=self.flow, 225 acs_url="https://sp2.example.com/acs", 226 sls_url="https://sp2.example.com/sls", 227 issuer="https://idp.example.com", 228 sp_binding="post", 229 sls_binding="post", 230 logout_method="frontchannel_iframe", 231 ) 232 233 def test_get_challenge_with_multiple_providers(self): 234 """Test get_challenge generates logout URLs for all providers""" 235 request = self.factory.get("/") 236 request.session = {} 237 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 238 239 plan = FlowPlan(flow_pk=self.flow.pk.hex) 240 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 241 { 242 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 243 "provider_name": "test-provider-1", 244 "binding": "redirect", 245 }, 246 { 247 "url": "https://sp2.example.com/sls", 248 "saml_request": "encoded2", 249 "provider_name": "test-provider-2", 250 "binding": "post", 251 }, 252 ] 253 stage_view = IframeLogoutStageView( 254 FlowExecutorView( 255 request=request, 256 flow=self.flow, 257 plan=plan, 258 ), 259 request=request, 260 ) 261 262 challenge = stage_view.get_challenge() 263 264 # Should return iframe challenge with logout URLs 265 self.assertIsInstance(challenge, IframeLogoutChallenge) 266 logout_urls = challenge.initial_data["logout_urls"] 267 268 # Should have 2 logout URLs 269 self.assertEqual(len(logout_urls), 2) 270 271 # Check first provider (redirect binding) 272 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 273 self.assertEqual(logout_urls[0]["binding"], "redirect") 274 self.assertIn("url", logout_urls[0]) 275 276 # Check second provider (post binding) 277 self.assertEqual(logout_urls[1]["provider_name"], "test-provider-2") 278 self.assertEqual(logout_urls[1]["binding"], "post") 279 self.assertEqual(logout_urls[1]["url"], self.provider2.sls_url) 280 self.assertIn("saml_request", logout_urls[1]) 281 282 def test_get_challenge_with_mixed_sessions(self): 283 """Test get_challenge with both SAML and OIDC sessions""" 284 request = self.factory.get("/") 285 request.session = {} 286 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 287 288 plan = FlowPlan(flow_pk=self.flow.pk.hex) 289 # SAML sessions (pre-processed) 290 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 291 { 292 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 293 "provider_name": "test-provider-1", 294 "binding": "redirect", 295 }, 296 ] 297 # OIDC sessions (pre-processed) 298 from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS 299 300 plan.context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = [ 301 { 302 "url": "https://oidc.example.com/logout?iss=authentik&sid=abc123", 303 "provider_name": "oidc-provider", 304 "binding": "redirect", 305 "provider_type": "oidc", 306 }, 307 ] 308 stage_view = IframeLogoutStageView( 309 FlowExecutorView( 310 request=request, 311 flow=self.flow, 312 plan=plan, 313 ), 314 request=request, 315 ) 316 317 challenge = stage_view.get_challenge() 318 319 # Should return iframe challenge with logout URLs from both SAML and OIDC 320 logout_urls = challenge.initial_data["logout_urls"] 321 self.assertEqual(len(logout_urls), 2) # 1 SAML + 1 OIDC 322 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 323 self.assertEqual(logout_urls[1]["provider_name"], "oidc-provider") 324 325 def test_challenge_valid_completes_stage(self): 326 """Test challenge_valid completes the stage""" 327 request = self.factory.post("/") 328 request.session = {} 329 330 plan = FlowPlan(flow_pk=self.flow.pk.hex) 331 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [] 332 executor = FlowExecutorView( 333 request=request, 334 flow=self.flow, 335 plan=plan, 336 ) 337 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 338 339 stage_view = IframeLogoutStageView(executor, request=request) 340 341 response = Mock() 342 stage_view.challenge_valid(response) 343 344 # Should call stage_ok 345 executor.stage_ok.assert_called_once() 346 347 # Session should remain empty (no session storage anymore) 348 self.assertEqual(request.session, {}) 349 350 351class TestIdPLogoutIntegration(FlowTestCase): 352 """Integration tests for IdP logout flow""" 353 354 def setUp(self): 355 """Set up test fixtures""" 356 super().setUp() 357 self.factory = RequestFactory() 358 self.flow = create_test_flow() 359 360 # Create test provider with signing 361 from authentik.core.tests.utils import create_test_cert 362 363 self.keypair = create_test_cert() 364 365 self.provider = SAMLProvider.objects.create( 366 name="test-provider", 367 authorization_flow=self.flow, 368 acs_url="https://sp.example.com/acs", 369 sls_url="https://sp.example.com/sls", 370 issuer="https://idp.example.com", 371 sp_binding="redirect", 372 sls_binding="redirect", 373 signing_kp=self.keypair, 374 sign_logout_request=True, 375 signature_algorithm=RSA_SHA256, 376 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 377 ) 378 379 def test_signed_logout_request_generation(self): 380 """Test that signed logout requests are generated correctly""" 381 from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor 382 383 processor = LogoutRequestProcessor( 384 provider=self.provider, 385 user=None, 386 destination=self.provider.sls_url, 387 name_id="test@example.com", 388 name_id_format=SAML_NAME_ID_FORMAT_EMAIL, 389 session_index="test-session", 390 relay_state=base64.urlsafe_b64encode(b"https://idp.example.com/return").decode(), 391 ) 392 393 # Test redirect URL includes signature 394 redirect_url = processor.get_redirect_url() 395 self.assertIn("Signature=", redirect_url) 396 self.assertIn("SigAlg=", redirect_url) 397 398 # Test POST data is signed 399 post_data = processor.get_post_form_data() 400 self.assertIn("SAMLRequest", post_data) 401 402 # Decode and check for signature in XML 403 from lxml import etree 404 405 decoded = base64.b64decode(post_data["SAMLRequest"]) 406 root = etree.fromstring(decoded) 407 signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature") 408 self.assertIsNotNone(signature)
class
TestNativeLogoutStageView(django.test.testcases.TestCase):
32class TestNativeLogoutStageView(TestCase): 33 """Test NativeLogoutStageView (redirect chain logout)""" 34 35 def setUp(self): 36 """Set up test fixtures""" 37 self.factory = RequestFactory() 38 self.flow = create_test_flow() 39 40 # Create test providers 41 self.provider1 = SAMLProvider.objects.create( 42 name="test-provider-1", 43 authorization_flow=self.flow, 44 acs_url="https://sp1.example.com/acs", 45 sls_url="https://sp1.example.com/sls", 46 issuer="https://idp.example.com", 47 sp_binding="redirect", 48 sls_binding="redirect", 49 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 50 ) 51 52 self.provider2 = SAMLProvider.objects.create( 53 name="test-provider-2", 54 authorization_flow=self.flow, 55 acs_url="https://sp2.example.com/acs", 56 sls_url="https://sp2.example.com/sls", 57 issuer="https://idp.example.com", 58 sp_binding="post", 59 sls_binding="post", 60 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 61 ) 62 63 def test_get_challenge_with_pending_providers_redirect(self): 64 """Test get_challenge when there are pending providers with redirect binding""" 65 request = self.factory.get("/") 66 request.session = {} 67 68 plan = FlowPlan(flow_pk=self.flow.pk.hex) 69 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 70 { 71 "redirect_url": "https://sp1.example.com/sls?SAMLRequest=encoded", 72 "provider_name": "test-provider-1", 73 "saml_binding": "redirect", 74 } 75 ] 76 stage_view = NativeLogoutStageView( 77 FlowExecutorView( 78 request=request, 79 flow=self.flow, 80 plan=plan, 81 ), 82 request=request, 83 ) 84 85 challenge = stage_view.get_challenge() 86 87 # Should return a NativeLogoutChallenge 88 self.assertIsInstance(challenge, NativeLogoutChallenge) 89 self.assertEqual(challenge.initial_data["saml_binding"], "redirect") 90 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-1") 91 self.assertIn("redirect_url", challenge.initial_data) 92 93 # Should have removed the provider from pending list 94 self.assertEqual(len(plan.context.get(PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS, [])), 0) 95 96 def test_get_challenge_with_pending_providers_post(self): 97 """Test get_challenge when there are pending providers with POST binding""" 98 request = self.factory.get("/") 99 request.session = {} 100 101 plan = FlowPlan(flow_pk=self.flow.pk.hex) 102 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 103 { 104 "post_url": "https://sp2.example.com/sls", 105 "saml_request": "encoded_saml_request", 106 "saml_relay_state": "https://idp.example.com/flow/test-flow", 107 "provider_name": "test-provider-2", 108 "saml_binding": "post", 109 } 110 ] 111 stage_view = NativeLogoutStageView( 112 FlowExecutorView( 113 request=request, 114 flow=self.flow, 115 plan=plan, 116 ), 117 request=request, 118 ) 119 120 challenge = stage_view.get_challenge() 121 122 # Should return a NativeLogoutChallenge 123 self.assertIsInstance(challenge, NativeLogoutChallenge) 124 self.assertEqual(challenge.initial_data["saml_binding"], "post") 125 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-2") 126 self.assertEqual(challenge.initial_data["post_url"], "https://sp2.example.com/sls") 127 self.assertIn("saml_request", challenge.initial_data) 128 self.assertIn("saml_relay_state", challenge.initial_data) 129 130 def test_get_challenge_all_complete(self): 131 """Test get_challenge when all providers are done""" 132 request = self.factory.get("/") 133 request.session = {} 134 135 plan = FlowPlan(flow_pk=self.flow.pk.hex) 136 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] # No pending providers 137 stage_view = NativeLogoutStageView( 138 FlowExecutorView( 139 request=request, 140 flow=self.flow, 141 plan=plan, 142 ), 143 request=request, 144 ) 145 146 challenge = stage_view.get_challenge() 147 148 # Should return completion challenge 149 self.assertIsInstance(challenge, NativeLogoutChallenge) 150 self.assertEqual(challenge.initial_data["is_complete"], True) 151 152 def test_get_challenge_with_empty_sessions(self): 153 """Test get_challenge when sessions list is empty""" 154 request = self.factory.get("/") 155 request.session = {} 156 157 plan = FlowPlan(flow_pk=self.flow.pk.hex) 158 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 159 stage_view = NativeLogoutStageView( 160 FlowExecutorView( 161 request=request, 162 flow=self.flow, 163 plan=plan, 164 ), 165 request=request, 166 ) 167 168 challenge = stage_view.get_challenge() 169 170 # Should return completion challenge 171 self.assertIsInstance(challenge, NativeLogoutChallenge) 172 self.assertEqual(challenge.initial_data["is_complete"], True) 173 174 def test_challenge_valid_continues_flow(self): 175 """Test challenge_valid continues to next provider or completes""" 176 request = self.factory.post("/") 177 request.session = {} 178 179 plan = FlowPlan(flow_pk=self.flow.pk.hex) 180 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 181 executor = FlowExecutorView( 182 request=request, 183 flow=self.flow, 184 plan=plan, 185 ) 186 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 187 188 stage_view = NativeLogoutStageView(executor, request=request) 189 190 # Mock get_challenge to return completion 191 stage_view.get_challenge = Mock() 192 completion_challenge = NativeLogoutChallenge(data={"is_complete": True}) 193 completion_challenge.is_valid() 194 stage_view.get_challenge.return_value = completion_challenge 195 196 response = Mock() 197 stage_view.challenge_valid(response) 198 199 # Should call stage_ok when complete and return its value 200 executor.stage_ok.assert_called_once()
Test NativeLogoutStageView (redirect chain logout)
def
setUp(self):
35 def setUp(self): 36 """Set up test fixtures""" 37 self.factory = RequestFactory() 38 self.flow = create_test_flow() 39 40 # Create test providers 41 self.provider1 = SAMLProvider.objects.create( 42 name="test-provider-1", 43 authorization_flow=self.flow, 44 acs_url="https://sp1.example.com/acs", 45 sls_url="https://sp1.example.com/sls", 46 issuer="https://idp.example.com", 47 sp_binding="redirect", 48 sls_binding="redirect", 49 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 50 ) 51 52 self.provider2 = SAMLProvider.objects.create( 53 name="test-provider-2", 54 authorization_flow=self.flow, 55 acs_url="https://sp2.example.com/acs", 56 sls_url="https://sp2.example.com/sls", 57 issuer="https://idp.example.com", 58 sp_binding="post", 59 sls_binding="post", 60 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 61 )
Set up test fixtures
def
test_get_challenge_with_pending_providers_redirect(self):
63 def test_get_challenge_with_pending_providers_redirect(self): 64 """Test get_challenge when there are pending providers with redirect binding""" 65 request = self.factory.get("/") 66 request.session = {} 67 68 plan = FlowPlan(flow_pk=self.flow.pk.hex) 69 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 70 { 71 "redirect_url": "https://sp1.example.com/sls?SAMLRequest=encoded", 72 "provider_name": "test-provider-1", 73 "saml_binding": "redirect", 74 } 75 ] 76 stage_view = NativeLogoutStageView( 77 FlowExecutorView( 78 request=request, 79 flow=self.flow, 80 plan=plan, 81 ), 82 request=request, 83 ) 84 85 challenge = stage_view.get_challenge() 86 87 # Should return a NativeLogoutChallenge 88 self.assertIsInstance(challenge, NativeLogoutChallenge) 89 self.assertEqual(challenge.initial_data["saml_binding"], "redirect") 90 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-1") 91 self.assertIn("redirect_url", challenge.initial_data) 92 93 # Should have removed the provider from pending list 94 self.assertEqual(len(plan.context.get(PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS, [])), 0)
Test get_challenge when there are pending providers with redirect binding
def
test_get_challenge_with_pending_providers_post(self):
96 def test_get_challenge_with_pending_providers_post(self): 97 """Test get_challenge when there are pending providers with POST binding""" 98 request = self.factory.get("/") 99 request.session = {} 100 101 plan = FlowPlan(flow_pk=self.flow.pk.hex) 102 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [ 103 { 104 "post_url": "https://sp2.example.com/sls", 105 "saml_request": "encoded_saml_request", 106 "saml_relay_state": "https://idp.example.com/flow/test-flow", 107 "provider_name": "test-provider-2", 108 "saml_binding": "post", 109 } 110 ] 111 stage_view = NativeLogoutStageView( 112 FlowExecutorView( 113 request=request, 114 flow=self.flow, 115 plan=plan, 116 ), 117 request=request, 118 ) 119 120 challenge = stage_view.get_challenge() 121 122 # Should return a NativeLogoutChallenge 123 self.assertIsInstance(challenge, NativeLogoutChallenge) 124 self.assertEqual(challenge.initial_data["saml_binding"], "post") 125 self.assertEqual(challenge.initial_data["provider_name"], "test-provider-2") 126 self.assertEqual(challenge.initial_data["post_url"], "https://sp2.example.com/sls") 127 self.assertIn("saml_request", challenge.initial_data) 128 self.assertIn("saml_relay_state", challenge.initial_data)
Test get_challenge when there are pending providers with POST binding
def
test_get_challenge_all_complete(self):
130 def test_get_challenge_all_complete(self): 131 """Test get_challenge when all providers are done""" 132 request = self.factory.get("/") 133 request.session = {} 134 135 plan = FlowPlan(flow_pk=self.flow.pk.hex) 136 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] # No pending providers 137 stage_view = NativeLogoutStageView( 138 FlowExecutorView( 139 request=request, 140 flow=self.flow, 141 plan=plan, 142 ), 143 request=request, 144 ) 145 146 challenge = stage_view.get_challenge() 147 148 # Should return completion challenge 149 self.assertIsInstance(challenge, NativeLogoutChallenge) 150 self.assertEqual(challenge.initial_data["is_complete"], True)
Test get_challenge when all providers are done
def
test_get_challenge_with_empty_sessions(self):
152 def test_get_challenge_with_empty_sessions(self): 153 """Test get_challenge when sessions list is empty""" 154 request = self.factory.get("/") 155 request.session = {} 156 157 plan = FlowPlan(flow_pk=self.flow.pk.hex) 158 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 159 stage_view = NativeLogoutStageView( 160 FlowExecutorView( 161 request=request, 162 flow=self.flow, 163 plan=plan, 164 ), 165 request=request, 166 ) 167 168 challenge = stage_view.get_challenge() 169 170 # Should return completion challenge 171 self.assertIsInstance(challenge, NativeLogoutChallenge) 172 self.assertEqual(challenge.initial_data["is_complete"], True)
Test get_challenge when sessions list is empty
def
test_challenge_valid_continues_flow(self):
174 def test_challenge_valid_continues_flow(self): 175 """Test challenge_valid continues to next provider or completes""" 176 request = self.factory.post("/") 177 request.session = {} 178 179 plan = FlowPlan(flow_pk=self.flow.pk.hex) 180 plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [] 181 executor = FlowExecutorView( 182 request=request, 183 flow=self.flow, 184 plan=plan, 185 ) 186 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 187 188 stage_view = NativeLogoutStageView(executor, request=request) 189 190 # Mock get_challenge to return completion 191 stage_view.get_challenge = Mock() 192 completion_challenge = NativeLogoutChallenge(data={"is_complete": True}) 193 completion_challenge.is_valid() 194 stage_view.get_challenge.return_value = completion_challenge 195 196 response = Mock() 197 stage_view.challenge_valid(response) 198 199 # Should call stage_ok when complete and return its value 200 executor.stage_ok.assert_called_once()
Test challenge_valid continues to next provider or completes
class
TestIframeLogoutStageView(django.test.testcases.TestCase):
203class TestIframeLogoutStageView(TestCase): 204 """Test IframeLogoutStageView (parallel iframe logout)""" 205 206 def setUp(self): 207 """Set up test fixtures""" 208 self.factory = RequestFactory() 209 self.flow = create_test_flow() 210 211 # Create test providers 212 self.provider1 = SAMLProvider.objects.create( 213 name="test-provider-1", 214 authorization_flow=self.flow, 215 acs_url="https://sp1.example.com/acs", 216 sls_url="https://sp1.example.com/sls", 217 issuer="https://idp.example.com", 218 sp_binding="redirect", 219 sls_binding="redirect", 220 logout_method="frontchannel_iframe", 221 ) 222 223 self.provider2 = SAMLProvider.objects.create( 224 name="test-provider-2", 225 authorization_flow=self.flow, 226 acs_url="https://sp2.example.com/acs", 227 sls_url="https://sp2.example.com/sls", 228 issuer="https://idp.example.com", 229 sp_binding="post", 230 sls_binding="post", 231 logout_method="frontchannel_iframe", 232 ) 233 234 def test_get_challenge_with_multiple_providers(self): 235 """Test get_challenge generates logout URLs for all providers""" 236 request = self.factory.get("/") 237 request.session = {} 238 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 239 240 plan = FlowPlan(flow_pk=self.flow.pk.hex) 241 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 242 { 243 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 244 "provider_name": "test-provider-1", 245 "binding": "redirect", 246 }, 247 { 248 "url": "https://sp2.example.com/sls", 249 "saml_request": "encoded2", 250 "provider_name": "test-provider-2", 251 "binding": "post", 252 }, 253 ] 254 stage_view = IframeLogoutStageView( 255 FlowExecutorView( 256 request=request, 257 flow=self.flow, 258 plan=plan, 259 ), 260 request=request, 261 ) 262 263 challenge = stage_view.get_challenge() 264 265 # Should return iframe challenge with logout URLs 266 self.assertIsInstance(challenge, IframeLogoutChallenge) 267 logout_urls = challenge.initial_data["logout_urls"] 268 269 # Should have 2 logout URLs 270 self.assertEqual(len(logout_urls), 2) 271 272 # Check first provider (redirect binding) 273 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 274 self.assertEqual(logout_urls[0]["binding"], "redirect") 275 self.assertIn("url", logout_urls[0]) 276 277 # Check second provider (post binding) 278 self.assertEqual(logout_urls[1]["provider_name"], "test-provider-2") 279 self.assertEqual(logout_urls[1]["binding"], "post") 280 self.assertEqual(logout_urls[1]["url"], self.provider2.sls_url) 281 self.assertIn("saml_request", logout_urls[1]) 282 283 def test_get_challenge_with_mixed_sessions(self): 284 """Test get_challenge with both SAML and OIDC sessions""" 285 request = self.factory.get("/") 286 request.session = {} 287 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 288 289 plan = FlowPlan(flow_pk=self.flow.pk.hex) 290 # SAML sessions (pre-processed) 291 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 292 { 293 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 294 "provider_name": "test-provider-1", 295 "binding": "redirect", 296 }, 297 ] 298 # OIDC sessions (pre-processed) 299 from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS 300 301 plan.context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = [ 302 { 303 "url": "https://oidc.example.com/logout?iss=authentik&sid=abc123", 304 "provider_name": "oidc-provider", 305 "binding": "redirect", 306 "provider_type": "oidc", 307 }, 308 ] 309 stage_view = IframeLogoutStageView( 310 FlowExecutorView( 311 request=request, 312 flow=self.flow, 313 plan=plan, 314 ), 315 request=request, 316 ) 317 318 challenge = stage_view.get_challenge() 319 320 # Should return iframe challenge with logout URLs from both SAML and OIDC 321 logout_urls = challenge.initial_data["logout_urls"] 322 self.assertEqual(len(logout_urls), 2) # 1 SAML + 1 OIDC 323 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 324 self.assertEqual(logout_urls[1]["provider_name"], "oidc-provider") 325 326 def test_challenge_valid_completes_stage(self): 327 """Test challenge_valid completes the stage""" 328 request = self.factory.post("/") 329 request.session = {} 330 331 plan = FlowPlan(flow_pk=self.flow.pk.hex) 332 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [] 333 executor = FlowExecutorView( 334 request=request, 335 flow=self.flow, 336 plan=plan, 337 ) 338 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 339 340 stage_view = IframeLogoutStageView(executor, request=request) 341 342 response = Mock() 343 stage_view.challenge_valid(response) 344 345 # Should call stage_ok 346 executor.stage_ok.assert_called_once() 347 348 # Session should remain empty (no session storage anymore) 349 self.assertEqual(request.session, {})
Test IframeLogoutStageView (parallel iframe logout)
def
setUp(self):
206 def setUp(self): 207 """Set up test fixtures""" 208 self.factory = RequestFactory() 209 self.flow = create_test_flow() 210 211 # Create test providers 212 self.provider1 = SAMLProvider.objects.create( 213 name="test-provider-1", 214 authorization_flow=self.flow, 215 acs_url="https://sp1.example.com/acs", 216 sls_url="https://sp1.example.com/sls", 217 issuer="https://idp.example.com", 218 sp_binding="redirect", 219 sls_binding="redirect", 220 logout_method="frontchannel_iframe", 221 ) 222 223 self.provider2 = SAMLProvider.objects.create( 224 name="test-provider-2", 225 authorization_flow=self.flow, 226 acs_url="https://sp2.example.com/acs", 227 sls_url="https://sp2.example.com/sls", 228 issuer="https://idp.example.com", 229 sp_binding="post", 230 sls_binding="post", 231 logout_method="frontchannel_iframe", 232 )
Set up test fixtures
def
test_get_challenge_with_multiple_providers(self):
234 def test_get_challenge_with_multiple_providers(self): 235 """Test get_challenge generates logout URLs for all providers""" 236 request = self.factory.get("/") 237 request.session = {} 238 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 239 240 plan = FlowPlan(flow_pk=self.flow.pk.hex) 241 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 242 { 243 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 244 "provider_name": "test-provider-1", 245 "binding": "redirect", 246 }, 247 { 248 "url": "https://sp2.example.com/sls", 249 "saml_request": "encoded2", 250 "provider_name": "test-provider-2", 251 "binding": "post", 252 }, 253 ] 254 stage_view = IframeLogoutStageView( 255 FlowExecutorView( 256 request=request, 257 flow=self.flow, 258 plan=plan, 259 ), 260 request=request, 261 ) 262 263 challenge = stage_view.get_challenge() 264 265 # Should return iframe challenge with logout URLs 266 self.assertIsInstance(challenge, IframeLogoutChallenge) 267 logout_urls = challenge.initial_data["logout_urls"] 268 269 # Should have 2 logout URLs 270 self.assertEqual(len(logout_urls), 2) 271 272 # Check first provider (redirect binding) 273 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 274 self.assertEqual(logout_urls[0]["binding"], "redirect") 275 self.assertIn("url", logout_urls[0]) 276 277 # Check second provider (post binding) 278 self.assertEqual(logout_urls[1]["provider_name"], "test-provider-2") 279 self.assertEqual(logout_urls[1]["binding"], "post") 280 self.assertEqual(logout_urls[1]["url"], self.provider2.sls_url) 281 self.assertIn("saml_request", logout_urls[1])
Test get_challenge generates logout URLs for all providers
def
test_get_challenge_with_mixed_sessions(self):
283 def test_get_challenge_with_mixed_sessions(self): 284 """Test get_challenge with both SAML and OIDC sessions""" 285 request = self.factory.get("/") 286 request.session = {} 287 request.build_absolute_uri = Mock(return_value="https://idp.example.com/flow/test-flow") 288 289 plan = FlowPlan(flow_pk=self.flow.pk.hex) 290 # SAML sessions (pre-processed) 291 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [ 292 { 293 "url": "https://sp1.example.com/sls?SAMLRequest=encoded1", 294 "provider_name": "test-provider-1", 295 "binding": "redirect", 296 }, 297 ] 298 # OIDC sessions (pre-processed) 299 from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS 300 301 plan.context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = [ 302 { 303 "url": "https://oidc.example.com/logout?iss=authentik&sid=abc123", 304 "provider_name": "oidc-provider", 305 "binding": "redirect", 306 "provider_type": "oidc", 307 }, 308 ] 309 stage_view = IframeLogoutStageView( 310 FlowExecutorView( 311 request=request, 312 flow=self.flow, 313 plan=plan, 314 ), 315 request=request, 316 ) 317 318 challenge = stage_view.get_challenge() 319 320 # Should return iframe challenge with logout URLs from both SAML and OIDC 321 logout_urls = challenge.initial_data["logout_urls"] 322 self.assertEqual(len(logout_urls), 2) # 1 SAML + 1 OIDC 323 self.assertEqual(logout_urls[0]["provider_name"], "test-provider-1") 324 self.assertEqual(logout_urls[1]["provider_name"], "oidc-provider")
Test get_challenge with both SAML and OIDC sessions
def
test_challenge_valid_completes_stage(self):
326 def test_challenge_valid_completes_stage(self): 327 """Test challenge_valid completes the stage""" 328 request = self.factory.post("/") 329 request.session = {} 330 331 plan = FlowPlan(flow_pk=self.flow.pk.hex) 332 plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [] 333 executor = FlowExecutorView( 334 request=request, 335 flow=self.flow, 336 plan=plan, 337 ) 338 executor.stage_ok = Mock(return_value=Mock(status_code=200)) 339 340 stage_view = IframeLogoutStageView(executor, request=request) 341 342 response = Mock() 343 stage_view.challenge_valid(response) 344 345 # Should call stage_ok 346 executor.stage_ok.assert_called_once() 347 348 # Session should remain empty (no session storage anymore) 349 self.assertEqual(request.session, {})
Test challenge_valid completes the stage
352class TestIdPLogoutIntegration(FlowTestCase): 353 """Integration tests for IdP logout flow""" 354 355 def setUp(self): 356 """Set up test fixtures""" 357 super().setUp() 358 self.factory = RequestFactory() 359 self.flow = create_test_flow() 360 361 # Create test provider with signing 362 from authentik.core.tests.utils import create_test_cert 363 364 self.keypair = create_test_cert() 365 366 self.provider = SAMLProvider.objects.create( 367 name="test-provider", 368 authorization_flow=self.flow, 369 acs_url="https://sp.example.com/acs", 370 sls_url="https://sp.example.com/sls", 371 issuer="https://idp.example.com", 372 sp_binding="redirect", 373 sls_binding="redirect", 374 signing_kp=self.keypair, 375 sign_logout_request=True, 376 signature_algorithm=RSA_SHA256, 377 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 378 ) 379 380 def test_signed_logout_request_generation(self): 381 """Test that signed logout requests are generated correctly""" 382 from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor 383 384 processor = LogoutRequestProcessor( 385 provider=self.provider, 386 user=None, 387 destination=self.provider.sls_url, 388 name_id="test@example.com", 389 name_id_format=SAML_NAME_ID_FORMAT_EMAIL, 390 session_index="test-session", 391 relay_state=base64.urlsafe_b64encode(b"https://idp.example.com/return").decode(), 392 ) 393 394 # Test redirect URL includes signature 395 redirect_url = processor.get_redirect_url() 396 self.assertIn("Signature=", redirect_url) 397 self.assertIn("SigAlg=", redirect_url) 398 399 # Test POST data is signed 400 post_data = processor.get_post_form_data() 401 self.assertIn("SAMLRequest", post_data) 402 403 # Decode and check for signature in XML 404 from lxml import etree 405 406 decoded = base64.b64decode(post_data["SAMLRequest"]) 407 root = etree.fromstring(decoded) 408 signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature") 409 self.assertIsNotNone(signature)
Integration tests for IdP logout flow
def
setUp(self):
355 def setUp(self): 356 """Set up test fixtures""" 357 super().setUp() 358 self.factory = RequestFactory() 359 self.flow = create_test_flow() 360 361 # Create test provider with signing 362 from authentik.core.tests.utils import create_test_cert 363 364 self.keypair = create_test_cert() 365 366 self.provider = SAMLProvider.objects.create( 367 name="test-provider", 368 authorization_flow=self.flow, 369 acs_url="https://sp.example.com/acs", 370 sls_url="https://sp.example.com/sls", 371 issuer="https://idp.example.com", 372 sp_binding="redirect", 373 sls_binding="redirect", 374 signing_kp=self.keypair, 375 sign_logout_request=True, 376 signature_algorithm=RSA_SHA256, 377 logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE, 378 )
Set up test fixtures
def
test_signed_logout_request_generation(self):
380 def test_signed_logout_request_generation(self): 381 """Test that signed logout requests are generated correctly""" 382 from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor 383 384 processor = LogoutRequestProcessor( 385 provider=self.provider, 386 user=None, 387 destination=self.provider.sls_url, 388 name_id="test@example.com", 389 name_id_format=SAML_NAME_ID_FORMAT_EMAIL, 390 session_index="test-session", 391 relay_state=base64.urlsafe_b64encode(b"https://idp.example.com/return").decode(), 392 ) 393 394 # Test redirect URL includes signature 395 redirect_url = processor.get_redirect_url() 396 self.assertIn("Signature=", redirect_url) 397 self.assertIn("SigAlg=", redirect_url) 398 399 # Test POST data is signed 400 post_data = processor.get_post_form_data() 401 self.assertIn("SAMLRequest", post_data) 402 403 # Decode and check for signature in XML 404 from lxml import etree 405 406 decoded = base64.b64decode(post_data["SAMLRequest"]) 407 root = etree.fromstring(decoded) 408 signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature") 409 self.assertIsNotNone(signature)
Test that signed logout requests are generated correctly