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