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

class TestIdPLogoutIntegration(authentik.flows.tests.FlowTestCase):
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