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

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