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