authentik.providers.saml.tests.test_logout_request_processor

Test LogoutRequestProcessor - Unit Tests

  1"""Test LogoutRequestProcessor - Unit Tests"""
  2
  3import base64
  4import zlib
  5from urllib.parse import parse_qs, urlparse
  6
  7from django.test import TestCase
  8from lxml import etree
  9
 10from authentik.common.saml.constants import (
 11    NS_MAP,
 12    NS_SAML_ASSERTION,
 13    NS_SAML_PROTOCOL,
 14    RSA_SHA256,
 15    SAML_NAME_ID_FORMAT_EMAIL,
 16)
 17from authentik.core.tests.utils import create_test_cert, create_test_flow
 18from authentik.providers.saml.models import SAMLProvider
 19from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
 20
 21
 22class TestLogoutRequestProcessor(TestCase):
 23    """Unit tests for LogoutRequestProcessor - no external dependencies"""
 24
 25    def setUp(self):
 26        """Set up test fixtures"""
 27        self.flow = create_test_flow()
 28
 29        # Create a signing keypair
 30        self.keypair = create_test_cert()
 31
 32        # Create provider without signing first
 33        self.provider = SAMLProvider.objects.create(
 34            name="test-provider",
 35            authorization_flow=self.flow,
 36            acs_url="https://sp.example.com/acs",
 37            sls_url="https://sp.example.com/sls",
 38            issuer="https://idp.example.com",
 39            sp_binding="redirect",
 40            sls_binding="redirect",
 41            signature_algorithm=RSA_SHA256,
 42        )
 43
 44        # Create processor
 45        self.processor = LogoutRequestProcessor(
 46            provider=self.provider,
 47            user=None,
 48            destination="https://sp.example.com/sls",
 49            name_id="test@example.com",
 50            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
 51            session_index="test-session-123",
 52            relay_state="https://idp.example.com/flow/return",
 53        )
 54
 55    def test_build_creates_valid_logout_request_xml(self):
 56        """Test that build() creates valid LogoutRequest XML"""
 57        logout_request = self.processor.build()
 58
 59        # Check it's an Element
 60        self.assertIsNotNone(logout_request)
 61        self.assertEqual(logout_request.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
 62
 63        # Check required attributes
 64        self.assertIn("ID", logout_request.attrib)
 65        self.assertIn("Version", logout_request.attrib)
 66        self.assertEqual(logout_request.attrib["Version"], "2.0")
 67        self.assertIn("IssueInstant", logout_request.attrib)
 68        self.assertEqual(logout_request.attrib["Destination"], "https://sp.example.com/sls")
 69
 70        # Check Issuer element
 71        issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
 72        self.assertIsNotNone(issuer)
 73        self.assertEqual(issuer.text, "https://idp.example.com")
 74
 75        # Check NameID element
 76        name_id = logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
 77        self.assertIsNotNone(name_id)
 78        self.assertEqual(name_id.text, "test@example.com")
 79        self.assertEqual(name_id.attrib["Format"], SAML_NAME_ID_FORMAT_EMAIL)
 80
 81        # Check SessionIndex element
 82        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
 83        self.assertIsNotNone(session_index)
 84        self.assertEqual(session_index.text, "test-session-123")
 85
 86    def test_encode_post_without_signing(self):
 87        """Test encode_post() without signing"""
 88        # Provider has no signing_kp, so it shouldn't sign
 89        encoded = self.processor.encode_post()
 90
 91        # Should be base64 encoded
 92        self.assertIsInstance(encoded, str)
 93
 94        # Decode and check it's valid XML
 95        decoded_xml = base64.b64decode(encoded)
 96        root = etree.fromstring(decoded_xml)
 97
 98        # Verify root element
 99        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
100
101        # Should not have a Signature element
102        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
103        self.assertIsNone(signature)
104
105        # Verify content matches what we set
106        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
107        self.assertEqual(issuer.text, "https://idp.example.com")
108
109        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
110        self.assertEqual(name_id.text, "test@example.com")
111
112    def test_encode_post_with_signing(self):
113        """Test encode_post() with signing enabled"""
114        # Enable signing
115        self.provider.signing_kp = self.keypair
116        self.provider.sign_logout_request = True
117        self.provider.save()
118
119        # Create new processor with signing provider
120        processor = LogoutRequestProcessor(
121            provider=self.provider,
122            user=None,
123            destination="https://sp.example.com/sls",
124            name_id="test@example.com",
125            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
126            session_index="test-session-123",
127            relay_state="https://idp.example.com/flow/return",
128        )
129
130        encoded = processor.encode_post()
131
132        # Decode and check it has a signature
133        decoded_xml = base64.b64decode(encoded)
134        root = etree.fromstring(decoded_xml)
135
136        # Should have a Signature element
137        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
138        self.assertIsNotNone(signature)
139
140        # Check signature is after Issuer element (SAML spec requirement)
141        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
142        issuer_index = list(root).index(issuer)
143        sig_index = list(root).index(signature)
144        self.assertGreater(sig_index, issuer_index)
145
146    def test_encode_redirect_creates_deflated_encoded_request(self):
147        """Test encode_redirect() creates properly deflated and encoded request"""
148        encoded = self.processor.encode_redirect()
149
150        # Should be base64 encoded string
151        self.assertIsInstance(encoded, str)
152
153        # Try to decode
154        decoded = base64.b64decode(encoded)
155        # -15 for raw deflate
156        inflated = zlib.decompress(decoded, -15)
157
158        # Should be valid XML with declaration
159        self.assertTrue(inflated.startswith(b"<?xml"))
160
161        # Parse the XML
162        root = etree.fromstring(inflated)
163        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
164
165        # Verify it has the expected content
166        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
167        self.assertEqual(issuer.text, "https://idp.example.com")
168
169        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
170        self.assertEqual(name_id.text, "test@example.com")
171
172    def test_get_redirect_url_without_signing(self):
173        """Test get_redirect_url() without signing"""
174        url = self.processor.get_redirect_url()
175
176        # Parse the URL
177        parsed_url = urlparse(url)
178        self.assertEqual(parsed_url.scheme, "https")
179        self.assertEqual(parsed_url.netloc, "sp.example.com")
180        self.assertEqual(parsed_url.path, "/sls")
181
182        # Parse query parameters
183        params = parse_qs(parsed_url.query)
184
185        # Should have SAMLRequest and RelayState
186        self.assertIn("SAMLRequest", params)
187        self.assertIn("RelayState", params)
188
189        # Should NOT have signature parameters
190        self.assertNotIn("SigAlg", params)
191        self.assertNotIn("Signature", params)
192
193        # RelayState should match
194        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
195
196        # Verify SAMLRequest is properly encoded
197        saml_request = params["SAMLRequest"][0]
198        # Should be able to decode it
199        decoded = base64.b64decode(saml_request)
200        inflated = zlib.decompress(decoded, -15)
201        root = etree.fromstring(inflated)
202        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
203
204    def test_get_redirect_url_with_signing(self):
205        """Test get_redirect_url() with signing enabled"""
206        # Enable signing
207        self.provider.signing_kp = self.keypair
208        self.provider.sign_logout_request = True
209        self.provider.save()
210
211        # Create new processor with signing provider
212        processor = LogoutRequestProcessor(
213            provider=self.provider,
214            user=None,
215            destination="https://sp.example.com/sls",
216            name_id="test@example.com",
217            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
218            session_index="test-session-123",
219            relay_state="https://idp.example.com/flow/return",
220        )
221
222        url = processor.get_redirect_url()
223
224        # Parse the URL
225        parsed_url = urlparse(url)
226
227        # Check the raw query string order - Signature should be last
228        query_string = parsed_url.query
229        query_parts = query_string.split("&")
230
231        # Verify Signature parameter comes last
232        self.assertTrue(
233            query_parts[-1].startswith("Signature="),
234            f"Signature should be last parameter, got: {query_parts[-1]}",
235        )
236
237        # Verify order of signed parameters (everything except Signature)
238        signed_params = "&".join(query_parts[:-1])
239        # Should be SAMLRequest, RelayState, then SigAlg
240        self.assertTrue(signed_params.startswith("SAMLRequest="))
241        self.assertIn("&RelayState=", signed_params)
242        self.assertIn("&SigAlg=", signed_params)
243
244        # Verify correct order: SAMLRequest comes before RelayState, RelayState before SigAlg
245        saml_index = signed_params.index("SAMLRequest=")
246        relay_index = signed_params.index("&RelayState=")
247        sigalg_index = signed_params.index("&SigAlg=")
248        self.assertLess(saml_index, relay_index, "SAMLRequest should come before RelayState")
249        self.assertLess(relay_index, sigalg_index, "RelayState should come before SigAlg")
250
251        # Parse for detailed checks
252        params = parse_qs(parsed_url.query)
253
254        # Should have exactly these parameters
255        self.assertEqual(set(params.keys()), {"SAMLRequest", "RelayState", "SigAlg", "Signature"})
256
257        # Check signature algorithm
258        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
259
260        # RelayState should match
261        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
262
263        # Signature should be base64 encoded
264        signature = params["Signature"][0]
265        try:
266            decoded_sig = base64.b64decode(signature)
267            self.assertIsNotNone(decoded_sig)
268            self.assertGreater(len(decoded_sig), 0)
269        except ValueError, TypeError:
270            self.fail("Signature is not valid base64")
271
272    def test_signature_parameter_ordering(self):
273        """Test that signature is computed with correct parameter ordering"""
274        # Enable signing
275        self.provider.signing_kp = self.keypair
276        self.provider.sign_logout_request = True
277        self.provider.save()
278
279        processor = LogoutRequestProcessor(
280            provider=self.provider,
281            user=None,
282            destination="https://sp.example.com/sls",
283            name_id="test@example.com",
284            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
285            session_index="test-session-123",
286            relay_state="test-relay-state",
287        )
288
289        # Build the signable query string
290        params = {
291            "SAMLRequest": processor.encode_redirect(),
292            "RelayState": "test-relay-state",
293            "SigAlg": RSA_SHA256,
294        }
295
296        query_string = processor._build_signable_query_string(params)
297
298        # Check order is correct (SAMLRequest, RelayState, SigAlg)
299        parts = query_string.split("&")
300        self.assertEqual(len(parts), 3)
301        self.assertTrue(parts[0].startswith("SAMLRequest="))
302        self.assertTrue(parts[1].startswith("RelayState="))
303        self.assertTrue(parts[2].startswith("SigAlg="))
304
305    def test_url_encoding_in_signatures(self):
306        """Test that URL encoding is handled correctly in signatures"""
307        # Enable signing
308        self.provider.signing_kp = self.keypair
309        self.provider.sign_logout_request = True
310        self.provider.save()
311
312        # Use a relay state with special characters that need encoding
313        processor = LogoutRequestProcessor(
314            provider=self.provider,
315            user=None,
316            destination="https://sp.example.com/sls",
317            name_id="test@example.com",
318            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
319            session_index="test-session-123",
320            relay_state="https://idp.example.com/flow?param=value&other=test+space",
321        )
322
323        url = processor.get_redirect_url()
324
325        # Parse the URL
326        parsed_url = urlparse(url)
327        params = parse_qs(parsed_url.query)
328
329        # RelayState should be properly encoded in URL
330        self.assertIn("RelayState", params)
331        # parse_qs decodes it, so we should get the original value
332        self.assertEqual(
333            params["RelayState"][0], "https://idp.example.com/flow?param=value&other=test+space"
334        )
335
336        # Should have signature
337        self.assertIn("Signature", params)
338
339    def test_get_post_form_data(self):
340        """Test get_post_form_data() returns correct form fields"""
341        form_data = self.processor.get_post_form_data()
342
343        # Should have SAMLRequest and RelayState
344        self.assertIn("SAMLRequest", form_data)
345        self.assertIn("RelayState", form_data)
346
347        # SAMLRequest should be base64 encoded
348        self.assertIsInstance(form_data["SAMLRequest"], str)
349        decoded = base64.b64decode(form_data["SAMLRequest"])
350        root = etree.fromstring(decoded)
351        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
352
353        # RelayState should match
354        self.assertEqual(form_data["RelayState"], "https://idp.example.com/flow/return")
355
356    def test_processor_without_session_index(self):
357        """Test processor works without session_index"""
358        processor = LogoutRequestProcessor(
359            provider=self.provider,
360            user=None,
361            destination="https://sp.example.com/sls",
362            name_id="test@example.com",
363            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
364            session_index=None,  # No session index
365            relay_state="https://idp.example.com/flow/return",
366        )
367
368        logout_request = processor.build()
369
370        # Should not have SessionIndex element
371        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
372        self.assertIsNone(session_index)
373
374        # Should still have other required elements
375        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP))
376        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP))
377
378    def test_processor_without_relay_state(self):
379        """Test processor works without relay_state"""
380        processor = LogoutRequestProcessor(
381            provider=self.provider,
382            user=None,
383            destination="https://sp.example.com/sls",
384            name_id="test@example.com",
385            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
386            session_index="test-session-123",
387            relay_state=None,  # No relay state
388        )
389
390        url = processor.get_redirect_url()
391
392        # Parse the URL
393        parsed_url = urlparse(url)
394        params = parse_qs(parsed_url.query)
395
396        # Should have SAMLRequest but no RelayState
397        self.assertIn("SAMLRequest", params)
398        self.assertNotIn("RelayState", params)
399
400        # Form data should have empty RelayState
401        form_data = processor.get_post_form_data()
402        self.assertEqual(form_data["RelayState"], "")
403
404    def test_signed_redirect_url_without_relay_state(self):
405        """Test signed redirect URL without RelayState - signature must be computed correctly"""
406        # Enable signing
407        self.provider.signing_kp = self.keypair
408        self.provider.sign_logout_request = True
409        self.provider.save()
410
411        # Create processor without relay_state
412        processor = LogoutRequestProcessor(
413            provider=self.provider,
414            user=None,
415            destination="https://sp.example.com/sls",
416            name_id="test@example.com",
417            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
418            session_index="test-session-123",
419            relay_state=None,  # No relay state
420        )
421
422        url = processor.get_redirect_url()
423
424        # Parse the URL
425        parsed_url = urlparse(url)
426
427        # Check the raw query string order - Signature should be last
428        query_string = parsed_url.query
429        query_parts = query_string.split("&")
430
431        # Verify Signature parameter comes last
432        self.assertTrue(
433            query_parts[-1].startswith("Signature="),
434            f"Signature should be last parameter, got: {query_parts[-1]}",
435        )
436
437        # Verify order of signed parameters (everything except Signature)
438        signed_params = "&".join(query_parts[:-1])
439        # Should be SAMLRequest, then SigAlg (no RelayState)
440        self.assertTrue(signed_params.startswith("SAMLRequest="))
441        self.assertIn("&SigAlg=", signed_params)
442        self.assertNotIn("&RelayState=", signed_params)
443
444        # Parse for detailed checks
445        params = parse_qs(parsed_url.query)
446
447        # Should have exactly these parameters
448        self.assertEqual(set(params.keys()), {"SAMLRequest", "SigAlg", "Signature"})
449
450        # Verify signature algorithm
451        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
452
453        # Build the expected signable string (without RelayState)
454        test_params = {
455            "SAMLRequest": params["SAMLRequest"][0],
456            "SigAlg": params["SigAlg"][0],
457        }
458
459        # The signable string should only contain SAMLRequest and SigAlg
460        signable_string = processor._build_signable_query_string(test_params)
461
462        # Should only have 2 parts (no RelayState)
463        parts = signable_string.split("&")
464        self.assertEqual(len(parts), 2)
465        self.assertTrue(parts[0].startswith("SAMLRequest="))
466        self.assertTrue(parts[1].startswith("SigAlg="))
467
468        # Signature should be valid base64
469        signature = params["Signature"][0]
470        try:
471            decoded_sig = base64.b64decode(signature)
472            self.assertIsNotNone(decoded_sig)
473            self.assertGreater(len(decoded_sig), 0)
474        except ValueError, TypeError:
475            self.fail("Signature is not valid base64")
class TestLogoutRequestProcessor(django.test.testcases.TestCase):
 23class TestLogoutRequestProcessor(TestCase):
 24    """Unit tests for LogoutRequestProcessor - no external dependencies"""
 25
 26    def setUp(self):
 27        """Set up test fixtures"""
 28        self.flow = create_test_flow()
 29
 30        # Create a signing keypair
 31        self.keypair = create_test_cert()
 32
 33        # Create provider without signing first
 34        self.provider = SAMLProvider.objects.create(
 35            name="test-provider",
 36            authorization_flow=self.flow,
 37            acs_url="https://sp.example.com/acs",
 38            sls_url="https://sp.example.com/sls",
 39            issuer="https://idp.example.com",
 40            sp_binding="redirect",
 41            sls_binding="redirect",
 42            signature_algorithm=RSA_SHA256,
 43        )
 44
 45        # Create processor
 46        self.processor = LogoutRequestProcessor(
 47            provider=self.provider,
 48            user=None,
 49            destination="https://sp.example.com/sls",
 50            name_id="test@example.com",
 51            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
 52            session_index="test-session-123",
 53            relay_state="https://idp.example.com/flow/return",
 54        )
 55
 56    def test_build_creates_valid_logout_request_xml(self):
 57        """Test that build() creates valid LogoutRequest XML"""
 58        logout_request = self.processor.build()
 59
 60        # Check it's an Element
 61        self.assertIsNotNone(logout_request)
 62        self.assertEqual(logout_request.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
 63
 64        # Check required attributes
 65        self.assertIn("ID", logout_request.attrib)
 66        self.assertIn("Version", logout_request.attrib)
 67        self.assertEqual(logout_request.attrib["Version"], "2.0")
 68        self.assertIn("IssueInstant", logout_request.attrib)
 69        self.assertEqual(logout_request.attrib["Destination"], "https://sp.example.com/sls")
 70
 71        # Check Issuer element
 72        issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
 73        self.assertIsNotNone(issuer)
 74        self.assertEqual(issuer.text, "https://idp.example.com")
 75
 76        # Check NameID element
 77        name_id = logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
 78        self.assertIsNotNone(name_id)
 79        self.assertEqual(name_id.text, "test@example.com")
 80        self.assertEqual(name_id.attrib["Format"], SAML_NAME_ID_FORMAT_EMAIL)
 81
 82        # Check SessionIndex element
 83        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
 84        self.assertIsNotNone(session_index)
 85        self.assertEqual(session_index.text, "test-session-123")
 86
 87    def test_encode_post_without_signing(self):
 88        """Test encode_post() without signing"""
 89        # Provider has no signing_kp, so it shouldn't sign
 90        encoded = self.processor.encode_post()
 91
 92        # Should be base64 encoded
 93        self.assertIsInstance(encoded, str)
 94
 95        # Decode and check it's valid XML
 96        decoded_xml = base64.b64decode(encoded)
 97        root = etree.fromstring(decoded_xml)
 98
 99        # Verify root element
100        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
101
102        # Should not have a Signature element
103        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
104        self.assertIsNone(signature)
105
106        # Verify content matches what we set
107        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
108        self.assertEqual(issuer.text, "https://idp.example.com")
109
110        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
111        self.assertEqual(name_id.text, "test@example.com")
112
113    def test_encode_post_with_signing(self):
114        """Test encode_post() with signing enabled"""
115        # Enable signing
116        self.provider.signing_kp = self.keypair
117        self.provider.sign_logout_request = True
118        self.provider.save()
119
120        # Create new processor with signing provider
121        processor = LogoutRequestProcessor(
122            provider=self.provider,
123            user=None,
124            destination="https://sp.example.com/sls",
125            name_id="test@example.com",
126            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
127            session_index="test-session-123",
128            relay_state="https://idp.example.com/flow/return",
129        )
130
131        encoded = processor.encode_post()
132
133        # Decode and check it has a signature
134        decoded_xml = base64.b64decode(encoded)
135        root = etree.fromstring(decoded_xml)
136
137        # Should have a Signature element
138        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
139        self.assertIsNotNone(signature)
140
141        # Check signature is after Issuer element (SAML spec requirement)
142        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
143        issuer_index = list(root).index(issuer)
144        sig_index = list(root).index(signature)
145        self.assertGreater(sig_index, issuer_index)
146
147    def test_encode_redirect_creates_deflated_encoded_request(self):
148        """Test encode_redirect() creates properly deflated and encoded request"""
149        encoded = self.processor.encode_redirect()
150
151        # Should be base64 encoded string
152        self.assertIsInstance(encoded, str)
153
154        # Try to decode
155        decoded = base64.b64decode(encoded)
156        # -15 for raw deflate
157        inflated = zlib.decompress(decoded, -15)
158
159        # Should be valid XML with declaration
160        self.assertTrue(inflated.startswith(b"<?xml"))
161
162        # Parse the XML
163        root = etree.fromstring(inflated)
164        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
165
166        # Verify it has the expected content
167        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
168        self.assertEqual(issuer.text, "https://idp.example.com")
169
170        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
171        self.assertEqual(name_id.text, "test@example.com")
172
173    def test_get_redirect_url_without_signing(self):
174        """Test get_redirect_url() without signing"""
175        url = self.processor.get_redirect_url()
176
177        # Parse the URL
178        parsed_url = urlparse(url)
179        self.assertEqual(parsed_url.scheme, "https")
180        self.assertEqual(parsed_url.netloc, "sp.example.com")
181        self.assertEqual(parsed_url.path, "/sls")
182
183        # Parse query parameters
184        params = parse_qs(parsed_url.query)
185
186        # Should have SAMLRequest and RelayState
187        self.assertIn("SAMLRequest", params)
188        self.assertIn("RelayState", params)
189
190        # Should NOT have signature parameters
191        self.assertNotIn("SigAlg", params)
192        self.assertNotIn("Signature", params)
193
194        # RelayState should match
195        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
196
197        # Verify SAMLRequest is properly encoded
198        saml_request = params["SAMLRequest"][0]
199        # Should be able to decode it
200        decoded = base64.b64decode(saml_request)
201        inflated = zlib.decompress(decoded, -15)
202        root = etree.fromstring(inflated)
203        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
204
205    def test_get_redirect_url_with_signing(self):
206        """Test get_redirect_url() with signing enabled"""
207        # Enable signing
208        self.provider.signing_kp = self.keypair
209        self.provider.sign_logout_request = True
210        self.provider.save()
211
212        # Create new processor with signing provider
213        processor = LogoutRequestProcessor(
214            provider=self.provider,
215            user=None,
216            destination="https://sp.example.com/sls",
217            name_id="test@example.com",
218            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
219            session_index="test-session-123",
220            relay_state="https://idp.example.com/flow/return",
221        )
222
223        url = processor.get_redirect_url()
224
225        # Parse the URL
226        parsed_url = urlparse(url)
227
228        # Check the raw query string order - Signature should be last
229        query_string = parsed_url.query
230        query_parts = query_string.split("&")
231
232        # Verify Signature parameter comes last
233        self.assertTrue(
234            query_parts[-1].startswith("Signature="),
235            f"Signature should be last parameter, got: {query_parts[-1]}",
236        )
237
238        # Verify order of signed parameters (everything except Signature)
239        signed_params = "&".join(query_parts[:-1])
240        # Should be SAMLRequest, RelayState, then SigAlg
241        self.assertTrue(signed_params.startswith("SAMLRequest="))
242        self.assertIn("&RelayState=", signed_params)
243        self.assertIn("&SigAlg=", signed_params)
244
245        # Verify correct order: SAMLRequest comes before RelayState, RelayState before SigAlg
246        saml_index = signed_params.index("SAMLRequest=")
247        relay_index = signed_params.index("&RelayState=")
248        sigalg_index = signed_params.index("&SigAlg=")
249        self.assertLess(saml_index, relay_index, "SAMLRequest should come before RelayState")
250        self.assertLess(relay_index, sigalg_index, "RelayState should come before SigAlg")
251
252        # Parse for detailed checks
253        params = parse_qs(parsed_url.query)
254
255        # Should have exactly these parameters
256        self.assertEqual(set(params.keys()), {"SAMLRequest", "RelayState", "SigAlg", "Signature"})
257
258        # Check signature algorithm
259        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
260
261        # RelayState should match
262        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
263
264        # Signature should be base64 encoded
265        signature = params["Signature"][0]
266        try:
267            decoded_sig = base64.b64decode(signature)
268            self.assertIsNotNone(decoded_sig)
269            self.assertGreater(len(decoded_sig), 0)
270        except ValueError, TypeError:
271            self.fail("Signature is not valid base64")
272
273    def test_signature_parameter_ordering(self):
274        """Test that signature is computed with correct parameter ordering"""
275        # Enable signing
276        self.provider.signing_kp = self.keypair
277        self.provider.sign_logout_request = True
278        self.provider.save()
279
280        processor = LogoutRequestProcessor(
281            provider=self.provider,
282            user=None,
283            destination="https://sp.example.com/sls",
284            name_id="test@example.com",
285            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
286            session_index="test-session-123",
287            relay_state="test-relay-state",
288        )
289
290        # Build the signable query string
291        params = {
292            "SAMLRequest": processor.encode_redirect(),
293            "RelayState": "test-relay-state",
294            "SigAlg": RSA_SHA256,
295        }
296
297        query_string = processor._build_signable_query_string(params)
298
299        # Check order is correct (SAMLRequest, RelayState, SigAlg)
300        parts = query_string.split("&")
301        self.assertEqual(len(parts), 3)
302        self.assertTrue(parts[0].startswith("SAMLRequest="))
303        self.assertTrue(parts[1].startswith("RelayState="))
304        self.assertTrue(parts[2].startswith("SigAlg="))
305
306    def test_url_encoding_in_signatures(self):
307        """Test that URL encoding is handled correctly in signatures"""
308        # Enable signing
309        self.provider.signing_kp = self.keypair
310        self.provider.sign_logout_request = True
311        self.provider.save()
312
313        # Use a relay state with special characters that need encoding
314        processor = LogoutRequestProcessor(
315            provider=self.provider,
316            user=None,
317            destination="https://sp.example.com/sls",
318            name_id="test@example.com",
319            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
320            session_index="test-session-123",
321            relay_state="https://idp.example.com/flow?param=value&other=test+space",
322        )
323
324        url = processor.get_redirect_url()
325
326        # Parse the URL
327        parsed_url = urlparse(url)
328        params = parse_qs(parsed_url.query)
329
330        # RelayState should be properly encoded in URL
331        self.assertIn("RelayState", params)
332        # parse_qs decodes it, so we should get the original value
333        self.assertEqual(
334            params["RelayState"][0], "https://idp.example.com/flow?param=value&other=test+space"
335        )
336
337        # Should have signature
338        self.assertIn("Signature", params)
339
340    def test_get_post_form_data(self):
341        """Test get_post_form_data() returns correct form fields"""
342        form_data = self.processor.get_post_form_data()
343
344        # Should have SAMLRequest and RelayState
345        self.assertIn("SAMLRequest", form_data)
346        self.assertIn("RelayState", form_data)
347
348        # SAMLRequest should be base64 encoded
349        self.assertIsInstance(form_data["SAMLRequest"], str)
350        decoded = base64.b64decode(form_data["SAMLRequest"])
351        root = etree.fromstring(decoded)
352        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
353
354        # RelayState should match
355        self.assertEqual(form_data["RelayState"], "https://idp.example.com/flow/return")
356
357    def test_processor_without_session_index(self):
358        """Test processor works without session_index"""
359        processor = LogoutRequestProcessor(
360            provider=self.provider,
361            user=None,
362            destination="https://sp.example.com/sls",
363            name_id="test@example.com",
364            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
365            session_index=None,  # No session index
366            relay_state="https://idp.example.com/flow/return",
367        )
368
369        logout_request = processor.build()
370
371        # Should not have SessionIndex element
372        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
373        self.assertIsNone(session_index)
374
375        # Should still have other required elements
376        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP))
377        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP))
378
379    def test_processor_without_relay_state(self):
380        """Test processor works without relay_state"""
381        processor = LogoutRequestProcessor(
382            provider=self.provider,
383            user=None,
384            destination="https://sp.example.com/sls",
385            name_id="test@example.com",
386            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
387            session_index="test-session-123",
388            relay_state=None,  # No relay state
389        )
390
391        url = processor.get_redirect_url()
392
393        # Parse the URL
394        parsed_url = urlparse(url)
395        params = parse_qs(parsed_url.query)
396
397        # Should have SAMLRequest but no RelayState
398        self.assertIn("SAMLRequest", params)
399        self.assertNotIn("RelayState", params)
400
401        # Form data should have empty RelayState
402        form_data = processor.get_post_form_data()
403        self.assertEqual(form_data["RelayState"], "")
404
405    def test_signed_redirect_url_without_relay_state(self):
406        """Test signed redirect URL without RelayState - signature must be computed correctly"""
407        # Enable signing
408        self.provider.signing_kp = self.keypair
409        self.provider.sign_logout_request = True
410        self.provider.save()
411
412        # Create processor without relay_state
413        processor = LogoutRequestProcessor(
414            provider=self.provider,
415            user=None,
416            destination="https://sp.example.com/sls",
417            name_id="test@example.com",
418            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
419            session_index="test-session-123",
420            relay_state=None,  # No relay state
421        )
422
423        url = processor.get_redirect_url()
424
425        # Parse the URL
426        parsed_url = urlparse(url)
427
428        # Check the raw query string order - Signature should be last
429        query_string = parsed_url.query
430        query_parts = query_string.split("&")
431
432        # Verify Signature parameter comes last
433        self.assertTrue(
434            query_parts[-1].startswith("Signature="),
435            f"Signature should be last parameter, got: {query_parts[-1]}",
436        )
437
438        # Verify order of signed parameters (everything except Signature)
439        signed_params = "&".join(query_parts[:-1])
440        # Should be SAMLRequest, then SigAlg (no RelayState)
441        self.assertTrue(signed_params.startswith("SAMLRequest="))
442        self.assertIn("&SigAlg=", signed_params)
443        self.assertNotIn("&RelayState=", signed_params)
444
445        # Parse for detailed checks
446        params = parse_qs(parsed_url.query)
447
448        # Should have exactly these parameters
449        self.assertEqual(set(params.keys()), {"SAMLRequest", "SigAlg", "Signature"})
450
451        # Verify signature algorithm
452        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
453
454        # Build the expected signable string (without RelayState)
455        test_params = {
456            "SAMLRequest": params["SAMLRequest"][0],
457            "SigAlg": params["SigAlg"][0],
458        }
459
460        # The signable string should only contain SAMLRequest and SigAlg
461        signable_string = processor._build_signable_query_string(test_params)
462
463        # Should only have 2 parts (no RelayState)
464        parts = signable_string.split("&")
465        self.assertEqual(len(parts), 2)
466        self.assertTrue(parts[0].startswith("SAMLRequest="))
467        self.assertTrue(parts[1].startswith("SigAlg="))
468
469        # Signature should be valid base64
470        signature = params["Signature"][0]
471        try:
472            decoded_sig = base64.b64decode(signature)
473            self.assertIsNotNone(decoded_sig)
474            self.assertGreater(len(decoded_sig), 0)
475        except ValueError, TypeError:
476            self.fail("Signature is not valid base64")

Unit tests for LogoutRequestProcessor - no external dependencies

def setUp(self):
26    def setUp(self):
27        """Set up test fixtures"""
28        self.flow = create_test_flow()
29
30        # Create a signing keypair
31        self.keypair = create_test_cert()
32
33        # Create provider without signing first
34        self.provider = SAMLProvider.objects.create(
35            name="test-provider",
36            authorization_flow=self.flow,
37            acs_url="https://sp.example.com/acs",
38            sls_url="https://sp.example.com/sls",
39            issuer="https://idp.example.com",
40            sp_binding="redirect",
41            sls_binding="redirect",
42            signature_algorithm=RSA_SHA256,
43        )
44
45        # Create processor
46        self.processor = LogoutRequestProcessor(
47            provider=self.provider,
48            user=None,
49            destination="https://sp.example.com/sls",
50            name_id="test@example.com",
51            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
52            session_index="test-session-123",
53            relay_state="https://idp.example.com/flow/return",
54        )

Set up test fixtures

def test_build_creates_valid_logout_request_xml(self):
56    def test_build_creates_valid_logout_request_xml(self):
57        """Test that build() creates valid LogoutRequest XML"""
58        logout_request = self.processor.build()
59
60        # Check it's an Element
61        self.assertIsNotNone(logout_request)
62        self.assertEqual(logout_request.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
63
64        # Check required attributes
65        self.assertIn("ID", logout_request.attrib)
66        self.assertIn("Version", logout_request.attrib)
67        self.assertEqual(logout_request.attrib["Version"], "2.0")
68        self.assertIn("IssueInstant", logout_request.attrib)
69        self.assertEqual(logout_request.attrib["Destination"], "https://sp.example.com/sls")
70
71        # Check Issuer element
72        issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
73        self.assertIsNotNone(issuer)
74        self.assertEqual(issuer.text, "https://idp.example.com")
75
76        # Check NameID element
77        name_id = logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
78        self.assertIsNotNone(name_id)
79        self.assertEqual(name_id.text, "test@example.com")
80        self.assertEqual(name_id.attrib["Format"], SAML_NAME_ID_FORMAT_EMAIL)
81
82        # Check SessionIndex element
83        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
84        self.assertIsNotNone(session_index)
85        self.assertEqual(session_index.text, "test-session-123")

Test that build() creates valid LogoutRequest XML

def test_encode_post_without_signing(self):
 87    def test_encode_post_without_signing(self):
 88        """Test encode_post() without signing"""
 89        # Provider has no signing_kp, so it shouldn't sign
 90        encoded = self.processor.encode_post()
 91
 92        # Should be base64 encoded
 93        self.assertIsInstance(encoded, str)
 94
 95        # Decode and check it's valid XML
 96        decoded_xml = base64.b64decode(encoded)
 97        root = etree.fromstring(decoded_xml)
 98
 99        # Verify root element
100        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
101
102        # Should not have a Signature element
103        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
104        self.assertIsNone(signature)
105
106        # Verify content matches what we set
107        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
108        self.assertEqual(issuer.text, "https://idp.example.com")
109
110        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
111        self.assertEqual(name_id.text, "test@example.com")

Test encode_post() without signing

def test_encode_post_with_signing(self):
113    def test_encode_post_with_signing(self):
114        """Test encode_post() with signing enabled"""
115        # Enable signing
116        self.provider.signing_kp = self.keypair
117        self.provider.sign_logout_request = True
118        self.provider.save()
119
120        # Create new processor with signing provider
121        processor = LogoutRequestProcessor(
122            provider=self.provider,
123            user=None,
124            destination="https://sp.example.com/sls",
125            name_id="test@example.com",
126            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
127            session_index="test-session-123",
128            relay_state="https://idp.example.com/flow/return",
129        )
130
131        encoded = processor.encode_post()
132
133        # Decode and check it has a signature
134        decoded_xml = base64.b64decode(encoded)
135        root = etree.fromstring(decoded_xml)
136
137        # Should have a Signature element
138        signature = root.find(".//{http://www.w3.org/2000/09/xmldsig#}Signature")
139        self.assertIsNotNone(signature)
140
141        # Check signature is after Issuer element (SAML spec requirement)
142        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
143        issuer_index = list(root).index(issuer)
144        sig_index = list(root).index(signature)
145        self.assertGreater(sig_index, issuer_index)

Test encode_post() with signing enabled

def test_encode_redirect_creates_deflated_encoded_request(self):
147    def test_encode_redirect_creates_deflated_encoded_request(self):
148        """Test encode_redirect() creates properly deflated and encoded request"""
149        encoded = self.processor.encode_redirect()
150
151        # Should be base64 encoded string
152        self.assertIsInstance(encoded, str)
153
154        # Try to decode
155        decoded = base64.b64decode(encoded)
156        # -15 for raw deflate
157        inflated = zlib.decompress(decoded, -15)
158
159        # Should be valid XML with declaration
160        self.assertTrue(inflated.startswith(b"<?xml"))
161
162        # Parse the XML
163        root = etree.fromstring(inflated)
164        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
165
166        # Verify it has the expected content
167        issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP)
168        self.assertEqual(issuer.text, "https://idp.example.com")
169
170        name_id = root.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP)
171        self.assertEqual(name_id.text, "test@example.com")

Test encode_redirect() creates properly deflated and encoded request

def test_get_redirect_url_without_signing(self):
173    def test_get_redirect_url_without_signing(self):
174        """Test get_redirect_url() without signing"""
175        url = self.processor.get_redirect_url()
176
177        # Parse the URL
178        parsed_url = urlparse(url)
179        self.assertEqual(parsed_url.scheme, "https")
180        self.assertEqual(parsed_url.netloc, "sp.example.com")
181        self.assertEqual(parsed_url.path, "/sls")
182
183        # Parse query parameters
184        params = parse_qs(parsed_url.query)
185
186        # Should have SAMLRequest and RelayState
187        self.assertIn("SAMLRequest", params)
188        self.assertIn("RelayState", params)
189
190        # Should NOT have signature parameters
191        self.assertNotIn("SigAlg", params)
192        self.assertNotIn("Signature", params)
193
194        # RelayState should match
195        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
196
197        # Verify SAMLRequest is properly encoded
198        saml_request = params["SAMLRequest"][0]
199        # Should be able to decode it
200        decoded = base64.b64decode(saml_request)
201        inflated = zlib.decompress(decoded, -15)
202        root = etree.fromstring(inflated)
203        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")

Test get_redirect_url() without signing

def test_get_redirect_url_with_signing(self):
205    def test_get_redirect_url_with_signing(self):
206        """Test get_redirect_url() with signing enabled"""
207        # Enable signing
208        self.provider.signing_kp = self.keypair
209        self.provider.sign_logout_request = True
210        self.provider.save()
211
212        # Create new processor with signing provider
213        processor = LogoutRequestProcessor(
214            provider=self.provider,
215            user=None,
216            destination="https://sp.example.com/sls",
217            name_id="test@example.com",
218            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
219            session_index="test-session-123",
220            relay_state="https://idp.example.com/flow/return",
221        )
222
223        url = processor.get_redirect_url()
224
225        # Parse the URL
226        parsed_url = urlparse(url)
227
228        # Check the raw query string order - Signature should be last
229        query_string = parsed_url.query
230        query_parts = query_string.split("&")
231
232        # Verify Signature parameter comes last
233        self.assertTrue(
234            query_parts[-1].startswith("Signature="),
235            f"Signature should be last parameter, got: {query_parts[-1]}",
236        )
237
238        # Verify order of signed parameters (everything except Signature)
239        signed_params = "&".join(query_parts[:-1])
240        # Should be SAMLRequest, RelayState, then SigAlg
241        self.assertTrue(signed_params.startswith("SAMLRequest="))
242        self.assertIn("&RelayState=", signed_params)
243        self.assertIn("&SigAlg=", signed_params)
244
245        # Verify correct order: SAMLRequest comes before RelayState, RelayState before SigAlg
246        saml_index = signed_params.index("SAMLRequest=")
247        relay_index = signed_params.index("&RelayState=")
248        sigalg_index = signed_params.index("&SigAlg=")
249        self.assertLess(saml_index, relay_index, "SAMLRequest should come before RelayState")
250        self.assertLess(relay_index, sigalg_index, "RelayState should come before SigAlg")
251
252        # Parse for detailed checks
253        params = parse_qs(parsed_url.query)
254
255        # Should have exactly these parameters
256        self.assertEqual(set(params.keys()), {"SAMLRequest", "RelayState", "SigAlg", "Signature"})
257
258        # Check signature algorithm
259        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
260
261        # RelayState should match
262        self.assertEqual(params["RelayState"][0], "https://idp.example.com/flow/return")
263
264        # Signature should be base64 encoded
265        signature = params["Signature"][0]
266        try:
267            decoded_sig = base64.b64decode(signature)
268            self.assertIsNotNone(decoded_sig)
269            self.assertGreater(len(decoded_sig), 0)
270        except ValueError, TypeError:
271            self.fail("Signature is not valid base64")

Test get_redirect_url() with signing enabled

def test_signature_parameter_ordering(self):
273    def test_signature_parameter_ordering(self):
274        """Test that signature is computed with correct parameter ordering"""
275        # Enable signing
276        self.provider.signing_kp = self.keypair
277        self.provider.sign_logout_request = True
278        self.provider.save()
279
280        processor = LogoutRequestProcessor(
281            provider=self.provider,
282            user=None,
283            destination="https://sp.example.com/sls",
284            name_id="test@example.com",
285            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
286            session_index="test-session-123",
287            relay_state="test-relay-state",
288        )
289
290        # Build the signable query string
291        params = {
292            "SAMLRequest": processor.encode_redirect(),
293            "RelayState": "test-relay-state",
294            "SigAlg": RSA_SHA256,
295        }
296
297        query_string = processor._build_signable_query_string(params)
298
299        # Check order is correct (SAMLRequest, RelayState, SigAlg)
300        parts = query_string.split("&")
301        self.assertEqual(len(parts), 3)
302        self.assertTrue(parts[0].startswith("SAMLRequest="))
303        self.assertTrue(parts[1].startswith("RelayState="))
304        self.assertTrue(parts[2].startswith("SigAlg="))

Test that signature is computed with correct parameter ordering

def test_url_encoding_in_signatures(self):
306    def test_url_encoding_in_signatures(self):
307        """Test that URL encoding is handled correctly in signatures"""
308        # Enable signing
309        self.provider.signing_kp = self.keypair
310        self.provider.sign_logout_request = True
311        self.provider.save()
312
313        # Use a relay state with special characters that need encoding
314        processor = LogoutRequestProcessor(
315            provider=self.provider,
316            user=None,
317            destination="https://sp.example.com/sls",
318            name_id="test@example.com",
319            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
320            session_index="test-session-123",
321            relay_state="https://idp.example.com/flow?param=value&other=test+space",
322        )
323
324        url = processor.get_redirect_url()
325
326        # Parse the URL
327        parsed_url = urlparse(url)
328        params = parse_qs(parsed_url.query)
329
330        # RelayState should be properly encoded in URL
331        self.assertIn("RelayState", params)
332        # parse_qs decodes it, so we should get the original value
333        self.assertEqual(
334            params["RelayState"][0], "https://idp.example.com/flow?param=value&other=test+space"
335        )
336
337        # Should have signature
338        self.assertIn("Signature", params)

Test that URL encoding is handled correctly in signatures

def test_get_post_form_data(self):
340    def test_get_post_form_data(self):
341        """Test get_post_form_data() returns correct form fields"""
342        form_data = self.processor.get_post_form_data()
343
344        # Should have SAMLRequest and RelayState
345        self.assertIn("SAMLRequest", form_data)
346        self.assertIn("RelayState", form_data)
347
348        # SAMLRequest should be base64 encoded
349        self.assertIsInstance(form_data["SAMLRequest"], str)
350        decoded = base64.b64decode(form_data["SAMLRequest"])
351        root = etree.fromstring(decoded)
352        self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutRequest")
353
354        # RelayState should match
355        self.assertEqual(form_data["RelayState"], "https://idp.example.com/flow/return")

Test get_post_form_data() returns correct form fields

def test_processor_without_session_index(self):
357    def test_processor_without_session_index(self):
358        """Test processor works without session_index"""
359        processor = LogoutRequestProcessor(
360            provider=self.provider,
361            user=None,
362            destination="https://sp.example.com/sls",
363            name_id="test@example.com",
364            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
365            session_index=None,  # No session index
366            relay_state="https://idp.example.com/flow/return",
367        )
368
369        logout_request = processor.build()
370
371        # Should not have SessionIndex element
372        session_index = logout_request.find(f"{{{NS_SAML_PROTOCOL}}}SessionIndex", NS_MAP)
373        self.assertIsNone(session_index)
374
375        # Should still have other required elements
376        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer", NS_MAP))
377        self.assertIsNotNone(logout_request.find(f"{{{NS_SAML_ASSERTION}}}NameID", NS_MAP))

Test processor works without session_index

def test_processor_without_relay_state(self):
379    def test_processor_without_relay_state(self):
380        """Test processor works without relay_state"""
381        processor = LogoutRequestProcessor(
382            provider=self.provider,
383            user=None,
384            destination="https://sp.example.com/sls",
385            name_id="test@example.com",
386            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
387            session_index="test-session-123",
388            relay_state=None,  # No relay state
389        )
390
391        url = processor.get_redirect_url()
392
393        # Parse the URL
394        parsed_url = urlparse(url)
395        params = parse_qs(parsed_url.query)
396
397        # Should have SAMLRequest but no RelayState
398        self.assertIn("SAMLRequest", params)
399        self.assertNotIn("RelayState", params)
400
401        # Form data should have empty RelayState
402        form_data = processor.get_post_form_data()
403        self.assertEqual(form_data["RelayState"], "")

Test processor works without relay_state

def test_signed_redirect_url_without_relay_state(self):
405    def test_signed_redirect_url_without_relay_state(self):
406        """Test signed redirect URL without RelayState - signature must be computed correctly"""
407        # Enable signing
408        self.provider.signing_kp = self.keypair
409        self.provider.sign_logout_request = True
410        self.provider.save()
411
412        # Create processor without relay_state
413        processor = LogoutRequestProcessor(
414            provider=self.provider,
415            user=None,
416            destination="https://sp.example.com/sls",
417            name_id="test@example.com",
418            name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
419            session_index="test-session-123",
420            relay_state=None,  # No relay state
421        )
422
423        url = processor.get_redirect_url()
424
425        # Parse the URL
426        parsed_url = urlparse(url)
427
428        # Check the raw query string order - Signature should be last
429        query_string = parsed_url.query
430        query_parts = query_string.split("&")
431
432        # Verify Signature parameter comes last
433        self.assertTrue(
434            query_parts[-1].startswith("Signature="),
435            f"Signature should be last parameter, got: {query_parts[-1]}",
436        )
437
438        # Verify order of signed parameters (everything except Signature)
439        signed_params = "&".join(query_parts[:-1])
440        # Should be SAMLRequest, then SigAlg (no RelayState)
441        self.assertTrue(signed_params.startswith("SAMLRequest="))
442        self.assertIn("&SigAlg=", signed_params)
443        self.assertNotIn("&RelayState=", signed_params)
444
445        # Parse for detailed checks
446        params = parse_qs(parsed_url.query)
447
448        # Should have exactly these parameters
449        self.assertEqual(set(params.keys()), {"SAMLRequest", "SigAlg", "Signature"})
450
451        # Verify signature algorithm
452        self.assertEqual(params["SigAlg"][0], RSA_SHA256)
453
454        # Build the expected signable string (without RelayState)
455        test_params = {
456            "SAMLRequest": params["SAMLRequest"][0],
457            "SigAlg": params["SigAlg"][0],
458        }
459
460        # The signable string should only contain SAMLRequest and SigAlg
461        signable_string = processor._build_signable_query_string(test_params)
462
463        # Should only have 2 parts (no RelayState)
464        parts = signable_string.split("&")
465        self.assertEqual(len(parts), 2)
466        self.assertTrue(parts[0].startswith("SAMLRequest="))
467        self.assertTrue(parts[1].startswith("SigAlg="))
468
469        # Signature should be valid base64
470        signature = params["Signature"][0]
471        try:
472            decoded_sig = base64.b64decode(signature)
473            self.assertIsNotNone(decoded_sig)
474            self.assertGreater(len(decoded_sig), 0)
475        except ValueError, TypeError:
476            self.fail("Signature is not valid base64")

Test signed redirect URL without RelayState - signature must be computed correctly