authentik.providers.saml.tests.test_auth_n_request

Test AuthN Request generator and parser

  1"""Test AuthN Request generator and parser"""
  2
  3from base64 import b64encode
  4
  5from defusedxml.lxml import fromstring
  6from django.http.request import QueryDict
  7from django.test import TestCase
  8from guardian.utils import get_anonymous_user
  9from lxml import etree  # nosec
 10
 11from authentik.blueprints.tests import apply_blueprint
 12from authentik.common.saml.constants import (
 13    NS_MAP,
 14    SAML_BINDING_POST,
 15    SAML_NAME_ID_FORMAT_EMAIL,
 16    SAML_NAME_ID_FORMAT_UNSPECIFIED,
 17)
 18from authentik.core.models import Application
 19from authentik.core.tests.utils import (
 20    RequestFactory,
 21    create_test_admin_user,
 22    create_test_cert,
 23    create_test_flow,
 24)
 25from authentik.crypto.models import CertificateKeyPair
 26from authentik.events.models import Event, EventAction
 27from authentik.lib.generators import generate_id
 28from authentik.lib.xml import lxml_from_string
 29from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
 30from authentik.providers.saml.processors.assertion import AssertionProcessor
 31from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
 32from authentik.sources.saml.exceptions import MismatchedRequestID
 33from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
 34from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID, RequestProcessor
 35from authentik.sources.saml.processors.response import ResponseProcessor
 36
 37POST_REQUEST = (
 38    "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOkF1dGhuUmVxdWVzdCB4bWxuczpzYW1sMn"
 39    "A9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgQXNzZXJ0aW9uQ29uc3VtZXJTZXJ2aWNlVVJMPSJo"
 40    "dHRwczovL2V1LWNlbnRyYWwtMS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9hY3MvMmQ3MzdmOTYtNT"
 41    "VmYi00MDM1LTk1M2UtNWUyNDEzNGViNzc4IiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9pZC5iZXJ5anUub3JnL2FwcGxpY2F0"
 42    "aW9uL3NhbWwvYXdzLXNzby9zc28vYmluZGluZy9wb3N0LyIgSUQ9ImF3c19MRHhMR2V1YnBjNWx4MTJneENnUzZ1UGJpeD"
 43    "F5ZDVyZSIgSXNzdWVJbnN0YW50PSIyMDIxLTA3LTA2VDE0OjIzOjA2LjM4OFoiIFByb3RvY29sQmluZGluZz0idXJuOm9h"
 44    "c2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIH"
 45    "htbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2V1LWNlbnRyYWwt"
 46    "MS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9kLTk5NjcyZjgyNzg8L3NhbWwyOklzc3Vlcj48c2FtbD"
 47    "JwOk5hbWVJRFBvbGljeSBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWls"
 48    "QWRkcmVzcyIvPjwvc2FtbDJwOkF1dGhuUmVxdWVzdD4="
 49)
 50REDIRECT_REQUEST = (
 51    "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu"
 52    "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H"
 53    "nAQbb6+07EGj7EI1j8SCeaVs21oVQ9dAoRqcf6OIhh6VLpV+pxZKZaFwlATbTUzeyqKazaqiDCO5WEQwZzKCagkwr8obWc"
 54    "qjb0PsYKvRSe1iErKQTTj3lYdc3HLBl68kyM4L340u19M5j4LiPs+zybjgC1gclvMNJFn104vB2P5L/TpW/kVNkqvBrug/"
 55    "+mjVikeP224y4/P7CdK6Nl9rC9JBTDihySi5vIbkFw=="
 56)
 57REDIRECT_SIGNATURE = (
 58    "UlOe1BItHVHM+io6rUZAenIqfibm7hM6wr9I1rcP5kPJ4N8cbkyqmAMh5LD2lUq3PDERJfjdO/oOKnvJmbD2y9MOObyR2d"
 59    "7Udv62KERrA0qM917Q+w8wrLX7w2nHY96EDvkXD4iAomR5EE9dHRuubDy7uRv2syEevc0gfoLi7W/5vp96vJgsaSqxnTp+"
 60    "QiYq49KyWyMtxRULF2yd+vYDnHCDME73mNSULEHfwCU71dvbKpnFaej78q7wS20gUk6ysOOXXtvDHbiVcpUb/9oyDgNAxU"
 61    "jVvPdh96AhBFj2HCuGZhP0CGotafTciu6YlsiwUpuBkIYgZmNWYa3FR9LS4Q=="
 62)
 63REDIRECT_SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
 64REDIRECT_RELAY_STATE = "ss:mem:7a054b4af44f34f89dd2d973f383c250b6b076e7f06cfa8276008a6504eaf3c7"
 65REDIRECT_CERT = """-----BEGIN CERTIFICATE-----
 66MIIDCDCCAfCgAwIBAgIRAM5s+bhOHk4ChSpPkGSh0NswDQYJKoZIhvcNAQELBQAw
 67KzEpMCcGA1UEAwwgcGFzc2Jvb2sgU2VsZi1zaWduZWQgQ2VydGlmaWNhdGUwHhcN
 68MjAxMTA3MjAzNDIxWhcNMjExMTA4MjAzNDIxWjBUMSkwJwYDVQQDDCBwYXNzYm9v
 69ayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTERMA8GA1UECgwIcGFzc2Jvb2sxFDAS
 70BgNVBAsMC1NlbGYtc2lnbmVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
 71AQEAuh+Bv6a/ogpic72X/sq86YiLzVjixnGqjc4wpsPPP00GX8jUAZJL4Tjo+sYK
 72IU2DF2/azlVqjkbLho4rGuuc8YkbFXBEXPYc5h3bseO2vk6sbbbWKV0mro1VFhBh
 73T59hBORuMMefmQdhFzsRNOGklIptQdg0quD8ET3+/uNfIT98S2ruZdYteFls46Sa
 74MokZFYVD6pWEYV4P2MKVAFqJX9bqBW0LfCCfFqHAOJjUZj9dtleg86d2WfedUOG2
 75LK0iLrydjhThbI0GUDhv0jWYkRlv04fdJ1WSRANYA3gBOnyw+Iigh2xNnYbVZMXT
 76I0BupIJ4UoODMc4QpD2GYJ6oGwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCEF3e
 77Y99KxEBSR4H4/TvKbnh4QtHswOf7MaGdjtrld7l4u4Hc4NEklNdDn1XLKhZwnq3Z
 78LRsRlJutDzZ18SRmAJPXPbka7z7D+LA1mbNQElOgiKyQHD9rIJSBr6X5SM9As3CR
 797QUsb8dg7kc+Jn7WuLZIEVxxMtekt0buWEdMJiklF0tCS3LNsP083FaQk/H1K0z6
 803PWP26EFdwir3RyTKLY5CBLjKrUAo9O1l/WBVFYbdetnipbGGu5f6nk6nnxbwLLI
 81Dm52Vkq+xFDDUq9IqIoYvLaE86MDvtpMQEx65tIGU19vUf3fL/+sSfdRZ1HDzP4d
 82qNAZMq1DqpibfCBg
 83-----END CERTIFICATE-----"""
 84
 85
 86class TestAuthNRequest(TestCase):
 87    """Test AuthN Request generator and parser"""
 88
 89    @apply_blueprint("system/providers-saml.yaml")
 90    def setUp(self):
 91        self.request_factory = RequestFactory()
 92        self.cert = create_test_cert()
 93        self.provider: SAMLProvider = SAMLProvider.objects.create(
 94            authorization_flow=create_test_flow(),
 95            acs_url="http://testserver/source/saml/provider/acs/",
 96            signing_kp=self.cert,
 97            verification_kp=self.cert,
 98        )
 99        self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
100        self.provider.save()
101        Application.objects.create(
102            name="test-app",
103            slug="test-app",
104            provider=self.provider,
105        )
106        self.source = SAMLSource.objects.create(
107            slug="provider",
108            issuer="authentik",
109            pre_authentication_flow=create_test_flow(),
110            signing_kp=self.cert,
111            verification_kp=self.cert,
112            signed_assertion=True,
113            binding_type=SAMLBindingTypes.POST,
114        )
115
116    def test_signed_valid(self):
117        """Test generated AuthNRequest with valid signature"""
118        http_request = self.request_factory.get("/")
119
120        # First create an AuthNRequest
121        request_proc = RequestProcessor(self.source, http_request, "test_state")
122        auth_n = request_proc.get_auth_n()
123        self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_POST)
124
125        request = request_proc.build_auth_n()
126        # Now we check the ID and signature
127        parsed_request = AuthNRequestParser(self.provider).parse(
128            b64encode(request.encode()).decode(), "test_state"
129        )
130        self.assertEqual(parsed_request.id, request_proc.request_id)
131        self.assertEqual(parsed_request.relay_state, "test_state")
132
133    def test_request_encrypt(self):
134        """Test full SAML Request/Response flow, fully encrypted"""
135        self.provider.encryption_kp = self.cert
136        self.provider.save()
137        self.source.encryption_kp = self.cert
138        self.source.save()
139        http_request = self.request_factory.get("/", user=get_anonymous_user())
140
141        # First create an AuthNRequest
142        request_proc = RequestProcessor(self.source, http_request, "test_state")
143        request = request_proc.build_auth_n()
144
145        # To get an assertion we need a parsed request (parsed by provider)
146        parsed_request = AuthNRequestParser(self.provider).parse(
147            b64encode(request.encode()).decode(), "test_state"
148        )
149        # Now create a response and convert it to string (provider)
150        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
151        response = response_proc.build_response()
152
153        # Now parse the response (source)
154        http_request.POST = QueryDict(mutable=True)
155        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
156
157        response_parser = ResponseProcessor(self.source, http_request)
158        response_parser.parse()
159
160    def test_request_encrypt_cert_only(self):
161        """Test SAML encryption with certificate-only keypair (no private key).
162
163        This tests the scenario where the IdP (provider) only has the SP's public
164        certificate for encryption, without a private key. This is the expected
165        real-world scenario since the SP would never share their private key.
166        """
167        # Create a full keypair for the source (SP) - it needs the private key to decrypt
168        full_keypair = create_test_cert()
169
170        # Create a certificate-only keypair for the provider (IdP)
171        # This simulates having only the SP's public certificate
172        cert_only = CertificateKeyPair.objects.create(
173            name=generate_id(),
174            certificate_data=full_keypair.certificate_data,
175            key_data="",  # No private key
176        )
177
178        self.provider.encryption_kp = cert_only
179        self.provider.save()
180        self.source.encryption_kp = full_keypair
181        self.source.save()
182        http_request = self.request_factory.get("/", user=get_anonymous_user())
183
184        # First create an AuthNRequest
185        request_proc = RequestProcessor(self.source, http_request, "test_state")
186        request = request_proc.build_auth_n()
187
188        # To get an assertion we need a parsed request (parsed by provider)
189        parsed_request = AuthNRequestParser(self.provider).parse(
190            b64encode(request.encode()).decode(), "test_state"
191        )
192        # Now create a response and convert it to string (provider)
193        # This should work with only the certificate (public key) for encryption
194        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
195        response = response_proc.build_response()
196
197        # Now parse the response (source) - decryption requires the private key
198        http_request.POST = QueryDict(mutable=True)
199        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
200
201        response_parser = ResponseProcessor(self.source, http_request)
202        response_parser.parse()
203
204    def test_request_sign_response_and_encrypt(self):
205        """Test SAML with sign_response enabled AND encryption.
206
207        This tests the fix for signature invalidation when encryption is enabled.
208        The response must be signed AFTER encryption, not before, because encryption
209        replaces the Assertion with EncryptedAssertion which changes the response content.
210        """
211        self.provider.sign_response = True
212        self.provider.sign_assertion = False
213        self.provider.encryption_kp = self.cert
214        self.provider.save()
215        self.source.encryption_kp = self.cert
216        self.source.signed_response = True
217        self.source.signed_assertion = False  # Only response is signed, not assertion
218        self.source.save()
219        http_request = self.request_factory.get("/", user=get_anonymous_user())
220
221        # First create an AuthNRequest
222        request_proc = RequestProcessor(self.source, http_request, "test_state")
223        request = request_proc.build_auth_n()
224
225        # To get an assertion we need a parsed request (parsed by provider)
226        parsed_request = AuthNRequestParser(self.provider).parse(
227            b64encode(request.encode()).decode(), "test_state"
228        )
229        # Now create a response and convert it to string (provider)
230        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
231        response = response_proc.build_response()
232
233        # Verify the response contains EncryptedAssertion and a signature
234        response_xml = fromstring(response)
235        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
236        self.assertEqual(
237            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
238        )
239
240        # Now parse the response (source) - this will verify the signature and decrypt
241        http_request.POST = QueryDict(mutable=True)
242        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
243
244        response_parser = ResponseProcessor(self.source, http_request)
245        response_parser.parse()
246
247    def test_request_sign_assertion_and_encrypt(self):
248        """Test SAML with sign_assertion enabled AND encryption.
249
250        The assertion signature should be inside the encrypted content and
251        remain valid after decryption.
252        """
253        self.provider.sign_response = False
254        self.provider.sign_assertion = True
255        self.provider.encryption_kp = self.cert
256        self.provider.save()
257        self.source.encryption_kp = self.cert
258        self.source.signed_assertion = True
259        self.source.save()
260        http_request = self.request_factory.get("/", user=get_anonymous_user())
261
262        # First create an AuthNRequest
263        request_proc = RequestProcessor(self.source, http_request, "test_state")
264        request = request_proc.build_auth_n()
265
266        # To get an assertion we need a parsed request (parsed by provider)
267        parsed_request = AuthNRequestParser(self.provider).parse(
268            b64encode(request.encode()).decode(), "test_state"
269        )
270        # Now create a response and convert it to string (provider)
271        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
272        response = response_proc.build_response()
273
274        # Verify the response contains EncryptedAssertion
275        response_xml = fromstring(response)
276        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
277
278        # Now parse the response (source) - this will decrypt and verify assertion signature
279        http_request.POST = QueryDict(mutable=True)
280        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
281
282        response_parser = ResponseProcessor(self.source, http_request)
283        response_parser.parse()
284
285    def test_request_sign_both_and_encrypt(self):
286        """Test SAML with both sign_assertion and sign_response enabled AND encryption.
287
288        This is the most complex scenario: assertion is signed, then encrypted,
289        then the response is signed. Both signatures should be valid.
290        """
291        self.provider.sign_response = True
292        self.provider.sign_assertion = True
293        self.provider.encryption_kp = self.cert
294        self.provider.save()
295        self.source.encryption_kp = self.cert
296        self.source.signed_assertion = True
297        self.source.signed_response = True
298        self.source.save()
299        http_request = self.request_factory.get("/", user=get_anonymous_user())
300
301        # First create an AuthNRequest
302        request_proc = RequestProcessor(self.source, http_request, "test_state")
303        request = request_proc.build_auth_n()
304
305        # To get an assertion we need a parsed request (parsed by provider)
306        parsed_request = AuthNRequestParser(self.provider).parse(
307            b64encode(request.encode()).decode(), "test_state"
308        )
309        # Now create a response and convert it to string (provider)
310        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
311        response = response_proc.build_response()
312
313        # Verify the response contains EncryptedAssertion and response signature
314        response_xml = fromstring(response)
315        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
316        self.assertEqual(
317            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
318        )
319
320        # Now parse the response (source) - this will verify response signature,
321        # decrypt, then verify assertion signature
322        http_request.POST = QueryDict(mutable=True)
323        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
324
325        response_parser = ResponseProcessor(self.source, http_request)
326        response_parser.parse()
327
328    def test_encrypted_assertion_namespace_preservation(self):
329        """Test that encrypted assertions include namespace declarations.
330
331        When an assertion is encrypted, the resulting decrypted XML must include
332        the necessary namespace declarations (xmlns:saml) since it's now a standalone
333        document fragment, no longer inheriting namespaces from the parent Response.
334        """
335        self.provider.encryption_kp = self.cert
336        self.provider.save()
337        self.source.encryption_kp = self.cert
338        self.source.save()
339        http_request = self.request_factory.get("/", user=get_anonymous_user())
340
341        # First create an AuthNRequest
342        request_proc = RequestProcessor(self.source, http_request, "test_state")
343        request = request_proc.build_auth_n()
344
345        # To get an assertion we need a parsed request (parsed by provider)
346        parsed_request = AuthNRequestParser(self.provider).parse(
347            b64encode(request.encode()).decode(), "test_state"
348        )
349        # Now create a response and convert it to string (provider)
350        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
351        response = response_proc.build_response()
352
353        # Parse the encrypted response
354        response_xml = fromstring(response)
355        encrypted_assertion = response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)[0]
356        encrypted_data = encrypted_assertion.xpath("//xenc:EncryptedData", namespaces=NS_MAP)[0]
357
358        # Decrypt the assertion manually to verify namespace is present
359        import xmlsec
360
361        manager = xmlsec.KeysManager()
362        key = xmlsec.Key.from_memory(self.cert.key_data, xmlsec.constants.KeyDataFormatPem, None)
363        manager.add_key(key)
364        enc_ctx = xmlsec.EncryptionContext(manager)
365        decrypted = enc_ctx.decrypt(encrypted_data)
366
367        # The decrypted assertion should have xmlns:saml namespace declaration
368        decrypted_str = etree.tostring(decrypted).decode()
369        self.assertIn("xmlns:saml", decrypted_str)
370
371        # Also verify full round-trip works (source can parse it)
372        http_request.POST = QueryDict(mutable=True)
373        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
374
375        response_parser = ResponseProcessor(self.source, http_request)
376        response_parser.parse()
377
378    def test_encrypted_response_schema_validation(self):
379        """Test that encrypted SAML responses validate against the SAML schema.
380
381        The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd.
382        This ensures we don't have invalid elements like EncryptedData inside Assertion.
383        """
384        self.provider.encryption_kp = self.cert
385        self.provider.save()
386        http_request = self.request_factory.get("/", user=get_anonymous_user())
387
388        # First create an AuthNRequest
389        request_proc = RequestProcessor(self.source, http_request, "test_state")
390        request = request_proc.build_auth_n()
391
392        # To get an assertion we need a parsed request (parsed by provider)
393        parsed_request = AuthNRequestParser(self.provider).parse(
394            b64encode(request.encode()).decode(), "test_state"
395        )
396        # Now create a response and convert it to string (provider)
397        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
398        response = response_proc.build_response()
399
400        # Validate against SAML schema
401        schema = etree.XMLSchema(
402            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
403        )
404        self.assertTrue(schema.validate(lxml_from_string(response)))
405
406        # Verify structure: should have EncryptedAssertion, not Assertion with EncryptedData inside
407        response_xml = fromstring(response)
408        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
409        self.assertEqual(len(response_xml.xpath("//saml:Assertion", namespaces=NS_MAP)), 0)
410
411    def test_request_signed(self):
412        """Test full SAML Request/Response flow, fully signed"""
413        http_request = self.request_factory.get("/", user=get_anonymous_user())
414
415        # First create an AuthNRequest
416        request_proc = RequestProcessor(self.source, http_request, "test_state")
417        request = request_proc.build_auth_n()
418
419        # To get an assertion we need a parsed request (parsed by provider)
420        parsed_request = AuthNRequestParser(self.provider).parse(
421            b64encode(request.encode()).decode(), "test_state"
422        )
423        # Now create a response and convert it to string (provider)
424        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
425        response = response_proc.build_response()
426
427        # Now parse the response (source)
428        http_request.POST = QueryDict(mutable=True)
429        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
430
431        response_parser = ResponseProcessor(self.source, http_request)
432        response_parser.parse()
433
434    def test_request_signed_both(self):
435        """Test full SAML Request/Response flow, fully signed"""
436        self.provider.sign_assertion = True
437        self.provider.sign_response = True
438        self.provider.save()
439        self.source.signed_response = True
440        http_request = self.request_factory.get("/", user=get_anonymous_user())
441
442        # First create an AuthNRequest
443        request_proc = RequestProcessor(self.source, http_request, "test_state")
444        request = request_proc.build_auth_n()
445
446        # To get an assertion we need a parsed request (parsed by provider)
447        parsed_request = AuthNRequestParser(self.provider).parse(
448            b64encode(request.encode()).decode(), "test_state"
449        )
450        # Now create a response and convert it to string (provider)
451        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
452        response = response_proc.build_response()
453        # Ensure both response and assertion ID are in the response twice (once as ID attribute,
454        # once as ds:Reference URI)
455        self.assertEqual(response.count(response_proc._assertion_id), 2)
456        self.assertEqual(response.count(response_proc._response_id), 2)
457
458        schema = etree.XMLSchema(
459            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
460        )
461        self.assertTrue(schema.validate(lxml_from_string(response)))
462
463        response_xml = fromstring(response)
464        self.assertEqual(
465            len(response_xml.xpath("//saml:Assertion/ds:Signature", namespaces=NS_MAP)), 1
466        )
467        self.assertEqual(
468            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
469        )
470
471        # Now parse the response (source)
472        http_request.POST = QueryDict(mutable=True)
473        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
474
475        response_parser = ResponseProcessor(self.source, http_request)
476        response_parser.parse()
477
478    def test_request_id_invalid(self):
479        """Test generated AuthNRequest with invalid request ID"""
480        http_request = self.request_factory.get("/", user=get_anonymous_user())
481
482        # First create an AuthNRequest
483        request_proc = RequestProcessor(self.source, http_request, "test_state")
484        request = request_proc.build_auth_n()
485
486        # change the request ID
487        http_request.session[SESSION_KEY_REQUEST_ID] = "test"
488        http_request.session.save()
489
490        # To get an assertion we need a parsed request (parsed by provider)
491        parsed_request = AuthNRequestParser(self.provider).parse(
492            b64encode(request.encode()).decode(), "test_state"
493        )
494        # Now create a response and convert it to string (provider)
495        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
496        response = response_proc.build_response()
497
498        # Now parse the response (source)
499        http_request.POST = QueryDict(mutable=True)
500        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
501
502        response_parser = ResponseProcessor(self.source, http_request)
503
504        with self.assertRaises(MismatchedRequestID):
505            response_parser.parse()
506
507    def test_signed_valid_detached(self):
508        """Test generated AuthNRequest with valid signature (detached)"""
509        http_request = self.request_factory.get("/")
510
511        # First create an AuthNRequest
512        request_proc = RequestProcessor(self.source, http_request, "test_state")
513        params = request_proc.build_auth_n_detached()
514        # Now we check the ID and signature
515        parsed_request = AuthNRequestParser(self.provider).parse_detached(
516            params["SAMLRequest"],
517            params["RelayState"],
518            params["Signature"],
519            params["SigAlg"],
520        )
521        self.assertEqual(parsed_request.id, request_proc.request_id)
522        self.assertEqual(parsed_request.relay_state, "test_state")
523
524    def test_signed_detached_static(self):
525        """Test request with detached signature,
526        taken from https://www.samltool.com/generic_sso_req.php"""
527        static_keypair = CertificateKeyPair.objects.create(
528            name="samltool", certificate_data=REDIRECT_CERT
529        )
530        provider = SAMLProvider(
531            name="samltool",
532            authorization_flow=create_test_flow(),
533            acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
534            audience="https://10.120.20.200/saml-sp/SAML2/POST",
535            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
536            signing_kp=static_keypair,
537            verification_kp=static_keypair,
538        )
539        parsed_request = AuthNRequestParser(provider).parse_detached(
540            REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG
541        )
542        self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
543        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED)
544        self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE)
545
546    def test_signed_static(self):
547        """Test post request with static request"""
548        provider = SAMLProvider(
549            name="aws",
550            authorization_flow=create_test_flow(),
551            acs_url=(
552                "https://eu-central-1.signin.aws.amazon.com/platform/"
553                "saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
554            ),
555            audience="https://10.120.20.200/saml-sp/SAML2/POST",
556            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
557            signing_kp=create_test_cert(),
558        )
559        parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
560        self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
561        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
562
563    def test_authn_context_class_ref_mapping(self):
564        """Test custom authn_context_class_ref"""
565        authn_context_class_ref = generate_id()
566        mapping = SAMLPropertyMapping.objects.create(
567            name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
568        )
569        self.provider.authn_context_class_ref_mapping = mapping
570        self.provider.save()
571        user = create_test_admin_user()
572        http_request = self.request_factory.get("/", user=user)
573
574        # First create an AuthNRequest
575        request_proc = RequestProcessor(self.source, http_request, "test_state")
576        request = request_proc.build_auth_n()
577
578        # To get an assertion we need a parsed request (parsed by provider)
579        parsed_request = AuthNRequestParser(self.provider).parse(
580            b64encode(request.encode()).decode(), "test_state"
581        )
582        # Now create a response and convert it to string (provider)
583        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
584        response = response_proc.build_response()
585        self.assertIn(user.username, response)
586        self.assertIn(authn_context_class_ref, response)
587
588    def test_authn_context_class_ref_mapping_invalid(self):
589        """Test custom authn_context_class_ref (invalid)"""
590        mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
591        self.provider.authn_context_class_ref_mapping = mapping
592        self.provider.save()
593        user = create_test_admin_user()
594        http_request = self.request_factory.get("/", user=user)
595
596        # First create an AuthNRequest
597        request_proc = RequestProcessor(self.source, http_request, "test_state")
598        request = request_proc.build_auth_n()
599
600        # To get an assertion we need a parsed request (parsed by provider)
601        parsed_request = AuthNRequestParser(self.provider).parse(
602            b64encode(request.encode()).decode(), "test_state"
603        )
604        # Now create a response and convert it to string (provider)
605        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
606        response = response_proc.build_response()
607        self.assertIn(user.username, response)
608
609        events = Event.objects.filter(
610            action=EventAction.CONFIGURATION_ERROR,
611        )
612        self.assertTrue(events.exists())
613        self.assertEqual(
614            events.first().context["message"],
615            f"Failed to evaluate property-mapping: '{mapping.name}'",
616        )
617
618    def test_request_attributes(self):
619        """Test full SAML Request/Response flow, fully signed"""
620        user = create_test_admin_user()
621        http_request = self.request_factory.get("/", user=user)
622
623        # First create an AuthNRequest
624        request_proc = RequestProcessor(self.source, http_request, "test_state")
625        request = request_proc.build_auth_n()
626
627        # To get an assertion we need a parsed request (parsed by provider)
628        parsed_request = AuthNRequestParser(self.provider).parse(
629            b64encode(request.encode()).decode(), "test_state"
630        )
631        # Now create a response and convert it to string (provider)
632        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
633        self.assertIn(user.username, response_proc.build_response())
634
635    def test_request_attributes_invalid(self):
636        """Test full SAML Request/Response flow, fully signed"""
637        user = create_test_admin_user()
638        http_request = self.request_factory.get("/", user=user)
639
640        # First create an AuthNRequest
641        request_proc = RequestProcessor(self.source, http_request, "test_state")
642        request = request_proc.build_auth_n()
643
644        # Create invalid PropertyMapping
645        mapping = SAMLPropertyMapping.objects.create(
646            name=generate_id(), saml_name="test", expression="q"
647        )
648        self.provider.property_mappings.add(mapping)
649
650        # To get an assertion we need a parsed request (parsed by provider)
651        parsed_request = AuthNRequestParser(self.provider).parse(
652            b64encode(request.encode()).decode(), "test_state"
653        )
654        # Now create a response and convert it to string (provider)
655        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
656        self.assertIn(user.username, response_proc.build_response())
657
658        events = Event.objects.filter(
659            action=EventAction.CONFIGURATION_ERROR,
660        )
661        self.assertTrue(events.exists())
662        self.assertEqual(
663            events.first().context["message"],
664            f"Failed to evaluate property-mapping: '{mapping.name}'",
665        )
666
667    def test_idp_initiated(self):
668        """Test IDP-initiated login"""
669        self.provider.default_relay_state = generate_id()
670        request = AuthNRequestParser(self.provider).idp_initiated()
671        self.assertEqual(request.id, None)
672        self.assertEqual(request.relay_state, self.provider.default_relay_state)
POST_REQUEST = 'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOkF1dGhuUmVxdWVzdCB4bWxuczpzYW1sMnA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgQXNzZXJ0aW9uQ29uc3VtZXJTZXJ2aWNlVVJMPSJodHRwczovL2V1LWNlbnRyYWwtMS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9hY3MvMmQ3MzdmOTYtNTVmYi00MDM1LTk1M2UtNWUyNDEzNGViNzc4IiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9pZC5iZXJ5anUub3JnL2FwcGxpY2F0aW9uL3NhbWwvYXdzLXNzby9zc28vYmluZGluZy9wb3N0LyIgSUQ9ImF3c19MRHhMR2V1YnBjNWx4MTJneENnUzZ1UGJpeDF5ZDVyZSIgSXNzdWVJbnN0YW50PSIyMDIxLTA3LTA2VDE0OjIzOjA2LjM4OFoiIFByb3RvY29sQmluZGluZz0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2V1LWNlbnRyYWwtMS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9kLTk5NjcyZjgyNzg8L3NhbWwyOklzc3Vlcj48c2FtbDJwOk5hbWVJRFBvbGljeSBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWlsQWRkcmVzcyIvPjwvc2FtbDJwOkF1dGhuUmVxdWVzdD4='
REDIRECT_REQUEST = 'fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iuEjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+HnAQbb6+07EGj7EI1j8SCeaVs21oVQ9dAoRqcf6OIhh6VLpV+pxZKZaFwlATbTUzeyqKazaqiDCO5WEQwZzKCagkwr8obWcqjb0PsYKvRSe1iErKQTTj3lYdc3HLBl68kyM4L340u19M5j4LiPs+zybjgC1gclvMNJFn104vB2P5L/TpW/kVNkqvBrug/+mjVikeP224y4/P7CdK6Nl9rC9JBTDihySi5vIbkFw=='
REDIRECT_SIGNATURE = 'UlOe1BItHVHM+io6rUZAenIqfibm7hM6wr9I1rcP5kPJ4N8cbkyqmAMh5LD2lUq3PDERJfjdO/oOKnvJmbD2y9MOObyR2d7Udv62KERrA0qM917Q+w8wrLX7w2nHY96EDvkXD4iAomR5EE9dHRuubDy7uRv2syEevc0gfoLi7W/5vp96vJgsaSqxnTp+QiYq49KyWyMtxRULF2yd+vYDnHCDME73mNSULEHfwCU71dvbKpnFaej78q7wS20gUk6ysOOXXtvDHbiVcpUb/9oyDgNAxUjVvPdh96AhBFj2HCuGZhP0CGotafTciu6YlsiwUpuBkIYgZmNWYa3FR9LS4Q=='
REDIRECT_SIG_ALG = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
REDIRECT_RELAY_STATE = 'ss:mem:7a054b4af44f34f89dd2d973f383c250b6b076e7f06cfa8276008a6504eaf3c7'
REDIRECT_CERT = '-----BEGIN CERTIFICATE-----\nMIIDCDCCAfCgAwIBAgIRAM5s+bhOHk4ChSpPkGSh0NswDQYJKoZIhvcNAQELBQAw\nKzEpMCcGA1UEAwwgcGFzc2Jvb2sgU2VsZi1zaWduZWQgQ2VydGlmaWNhdGUwHhcN\nMjAxMTA3MjAzNDIxWhcNMjExMTA4MjAzNDIxWjBUMSkwJwYDVQQDDCBwYXNzYm9v\nayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTERMA8GA1UECgwIcGFzc2Jvb2sxFDAS\nBgNVBAsMC1NlbGYtc2lnbmVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC\nAQEAuh+Bv6a/ogpic72X/sq86YiLzVjixnGqjc4wpsPPP00GX8jUAZJL4Tjo+sYK\nIU2DF2/azlVqjkbLho4rGuuc8YkbFXBEXPYc5h3bseO2vk6sbbbWKV0mro1VFhBh\nT59hBORuMMefmQdhFzsRNOGklIptQdg0quD8ET3+/uNfIT98S2ruZdYteFls46Sa\nMokZFYVD6pWEYV4P2MKVAFqJX9bqBW0LfCCfFqHAOJjUZj9dtleg86d2WfedUOG2\nLK0iLrydjhThbI0GUDhv0jWYkRlv04fdJ1WSRANYA3gBOnyw+Iigh2xNnYbVZMXT\nI0BupIJ4UoODMc4QpD2GYJ6oGwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCEF3e\nY99KxEBSR4H4/TvKbnh4QtHswOf7MaGdjtrld7l4u4Hc4NEklNdDn1XLKhZwnq3Z\nLRsRlJutDzZ18SRmAJPXPbka7z7D+LA1mbNQElOgiKyQHD9rIJSBr6X5SM9As3CR\n7QUsb8dg7kc+Jn7WuLZIEVxxMtekt0buWEdMJiklF0tCS3LNsP083FaQk/H1K0z6\n3PWP26EFdwir3RyTKLY5CBLjKrUAo9O1l/WBVFYbdetnipbGGu5f6nk6nnxbwLLI\nDm52Vkq+xFDDUq9IqIoYvLaE86MDvtpMQEx65tIGU19vUf3fL/+sSfdRZ1HDzP4d\nqNAZMq1DqpibfCBg\n-----END CERTIFICATE-----'
class TestAuthNRequest(django.test.testcases.TestCase):
 87class TestAuthNRequest(TestCase):
 88    """Test AuthN Request generator and parser"""
 89
 90    @apply_blueprint("system/providers-saml.yaml")
 91    def setUp(self):
 92        self.request_factory = RequestFactory()
 93        self.cert = create_test_cert()
 94        self.provider: SAMLProvider = SAMLProvider.objects.create(
 95            authorization_flow=create_test_flow(),
 96            acs_url="http://testserver/source/saml/provider/acs/",
 97            signing_kp=self.cert,
 98            verification_kp=self.cert,
 99        )
100        self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
101        self.provider.save()
102        Application.objects.create(
103            name="test-app",
104            slug="test-app",
105            provider=self.provider,
106        )
107        self.source = SAMLSource.objects.create(
108            slug="provider",
109            issuer="authentik",
110            pre_authentication_flow=create_test_flow(),
111            signing_kp=self.cert,
112            verification_kp=self.cert,
113            signed_assertion=True,
114            binding_type=SAMLBindingTypes.POST,
115        )
116
117    def test_signed_valid(self):
118        """Test generated AuthNRequest with valid signature"""
119        http_request = self.request_factory.get("/")
120
121        # First create an AuthNRequest
122        request_proc = RequestProcessor(self.source, http_request, "test_state")
123        auth_n = request_proc.get_auth_n()
124        self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_POST)
125
126        request = request_proc.build_auth_n()
127        # Now we check the ID and signature
128        parsed_request = AuthNRequestParser(self.provider).parse(
129            b64encode(request.encode()).decode(), "test_state"
130        )
131        self.assertEqual(parsed_request.id, request_proc.request_id)
132        self.assertEqual(parsed_request.relay_state, "test_state")
133
134    def test_request_encrypt(self):
135        """Test full SAML Request/Response flow, fully encrypted"""
136        self.provider.encryption_kp = self.cert
137        self.provider.save()
138        self.source.encryption_kp = self.cert
139        self.source.save()
140        http_request = self.request_factory.get("/", user=get_anonymous_user())
141
142        # First create an AuthNRequest
143        request_proc = RequestProcessor(self.source, http_request, "test_state")
144        request = request_proc.build_auth_n()
145
146        # To get an assertion we need a parsed request (parsed by provider)
147        parsed_request = AuthNRequestParser(self.provider).parse(
148            b64encode(request.encode()).decode(), "test_state"
149        )
150        # Now create a response and convert it to string (provider)
151        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
152        response = response_proc.build_response()
153
154        # Now parse the response (source)
155        http_request.POST = QueryDict(mutable=True)
156        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
157
158        response_parser = ResponseProcessor(self.source, http_request)
159        response_parser.parse()
160
161    def test_request_encrypt_cert_only(self):
162        """Test SAML encryption with certificate-only keypair (no private key).
163
164        This tests the scenario where the IdP (provider) only has the SP's public
165        certificate for encryption, without a private key. This is the expected
166        real-world scenario since the SP would never share their private key.
167        """
168        # Create a full keypair for the source (SP) - it needs the private key to decrypt
169        full_keypair = create_test_cert()
170
171        # Create a certificate-only keypair for the provider (IdP)
172        # This simulates having only the SP's public certificate
173        cert_only = CertificateKeyPair.objects.create(
174            name=generate_id(),
175            certificate_data=full_keypair.certificate_data,
176            key_data="",  # No private key
177        )
178
179        self.provider.encryption_kp = cert_only
180        self.provider.save()
181        self.source.encryption_kp = full_keypair
182        self.source.save()
183        http_request = self.request_factory.get("/", user=get_anonymous_user())
184
185        # First create an AuthNRequest
186        request_proc = RequestProcessor(self.source, http_request, "test_state")
187        request = request_proc.build_auth_n()
188
189        # To get an assertion we need a parsed request (parsed by provider)
190        parsed_request = AuthNRequestParser(self.provider).parse(
191            b64encode(request.encode()).decode(), "test_state"
192        )
193        # Now create a response and convert it to string (provider)
194        # This should work with only the certificate (public key) for encryption
195        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
196        response = response_proc.build_response()
197
198        # Now parse the response (source) - decryption requires the private key
199        http_request.POST = QueryDict(mutable=True)
200        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
201
202        response_parser = ResponseProcessor(self.source, http_request)
203        response_parser.parse()
204
205    def test_request_sign_response_and_encrypt(self):
206        """Test SAML with sign_response enabled AND encryption.
207
208        This tests the fix for signature invalidation when encryption is enabled.
209        The response must be signed AFTER encryption, not before, because encryption
210        replaces the Assertion with EncryptedAssertion which changes the response content.
211        """
212        self.provider.sign_response = True
213        self.provider.sign_assertion = False
214        self.provider.encryption_kp = self.cert
215        self.provider.save()
216        self.source.encryption_kp = self.cert
217        self.source.signed_response = True
218        self.source.signed_assertion = False  # Only response is signed, not assertion
219        self.source.save()
220        http_request = self.request_factory.get("/", user=get_anonymous_user())
221
222        # First create an AuthNRequest
223        request_proc = RequestProcessor(self.source, http_request, "test_state")
224        request = request_proc.build_auth_n()
225
226        # To get an assertion we need a parsed request (parsed by provider)
227        parsed_request = AuthNRequestParser(self.provider).parse(
228            b64encode(request.encode()).decode(), "test_state"
229        )
230        # Now create a response and convert it to string (provider)
231        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
232        response = response_proc.build_response()
233
234        # Verify the response contains EncryptedAssertion and a signature
235        response_xml = fromstring(response)
236        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
237        self.assertEqual(
238            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
239        )
240
241        # Now parse the response (source) - this will verify the signature and decrypt
242        http_request.POST = QueryDict(mutable=True)
243        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
244
245        response_parser = ResponseProcessor(self.source, http_request)
246        response_parser.parse()
247
248    def test_request_sign_assertion_and_encrypt(self):
249        """Test SAML with sign_assertion enabled AND encryption.
250
251        The assertion signature should be inside the encrypted content and
252        remain valid after decryption.
253        """
254        self.provider.sign_response = False
255        self.provider.sign_assertion = True
256        self.provider.encryption_kp = self.cert
257        self.provider.save()
258        self.source.encryption_kp = self.cert
259        self.source.signed_assertion = True
260        self.source.save()
261        http_request = self.request_factory.get("/", user=get_anonymous_user())
262
263        # First create an AuthNRequest
264        request_proc = RequestProcessor(self.source, http_request, "test_state")
265        request = request_proc.build_auth_n()
266
267        # To get an assertion we need a parsed request (parsed by provider)
268        parsed_request = AuthNRequestParser(self.provider).parse(
269            b64encode(request.encode()).decode(), "test_state"
270        )
271        # Now create a response and convert it to string (provider)
272        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
273        response = response_proc.build_response()
274
275        # Verify the response contains EncryptedAssertion
276        response_xml = fromstring(response)
277        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
278
279        # Now parse the response (source) - this will decrypt and verify assertion signature
280        http_request.POST = QueryDict(mutable=True)
281        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
282
283        response_parser = ResponseProcessor(self.source, http_request)
284        response_parser.parse()
285
286    def test_request_sign_both_and_encrypt(self):
287        """Test SAML with both sign_assertion and sign_response enabled AND encryption.
288
289        This is the most complex scenario: assertion is signed, then encrypted,
290        then the response is signed. Both signatures should be valid.
291        """
292        self.provider.sign_response = True
293        self.provider.sign_assertion = True
294        self.provider.encryption_kp = self.cert
295        self.provider.save()
296        self.source.encryption_kp = self.cert
297        self.source.signed_assertion = True
298        self.source.signed_response = True
299        self.source.save()
300        http_request = self.request_factory.get("/", user=get_anonymous_user())
301
302        # First create an AuthNRequest
303        request_proc = RequestProcessor(self.source, http_request, "test_state")
304        request = request_proc.build_auth_n()
305
306        # To get an assertion we need a parsed request (parsed by provider)
307        parsed_request = AuthNRequestParser(self.provider).parse(
308            b64encode(request.encode()).decode(), "test_state"
309        )
310        # Now create a response and convert it to string (provider)
311        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
312        response = response_proc.build_response()
313
314        # Verify the response contains EncryptedAssertion and response signature
315        response_xml = fromstring(response)
316        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
317        self.assertEqual(
318            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
319        )
320
321        # Now parse the response (source) - this will verify response signature,
322        # decrypt, then verify assertion signature
323        http_request.POST = QueryDict(mutable=True)
324        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
325
326        response_parser = ResponseProcessor(self.source, http_request)
327        response_parser.parse()
328
329    def test_encrypted_assertion_namespace_preservation(self):
330        """Test that encrypted assertions include namespace declarations.
331
332        When an assertion is encrypted, the resulting decrypted XML must include
333        the necessary namespace declarations (xmlns:saml) since it's now a standalone
334        document fragment, no longer inheriting namespaces from the parent Response.
335        """
336        self.provider.encryption_kp = self.cert
337        self.provider.save()
338        self.source.encryption_kp = self.cert
339        self.source.save()
340        http_request = self.request_factory.get("/", user=get_anonymous_user())
341
342        # First create an AuthNRequest
343        request_proc = RequestProcessor(self.source, http_request, "test_state")
344        request = request_proc.build_auth_n()
345
346        # To get an assertion we need a parsed request (parsed by provider)
347        parsed_request = AuthNRequestParser(self.provider).parse(
348            b64encode(request.encode()).decode(), "test_state"
349        )
350        # Now create a response and convert it to string (provider)
351        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
352        response = response_proc.build_response()
353
354        # Parse the encrypted response
355        response_xml = fromstring(response)
356        encrypted_assertion = response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)[0]
357        encrypted_data = encrypted_assertion.xpath("//xenc:EncryptedData", namespaces=NS_MAP)[0]
358
359        # Decrypt the assertion manually to verify namespace is present
360        import xmlsec
361
362        manager = xmlsec.KeysManager()
363        key = xmlsec.Key.from_memory(self.cert.key_data, xmlsec.constants.KeyDataFormatPem, None)
364        manager.add_key(key)
365        enc_ctx = xmlsec.EncryptionContext(manager)
366        decrypted = enc_ctx.decrypt(encrypted_data)
367
368        # The decrypted assertion should have xmlns:saml namespace declaration
369        decrypted_str = etree.tostring(decrypted).decode()
370        self.assertIn("xmlns:saml", decrypted_str)
371
372        # Also verify full round-trip works (source can parse it)
373        http_request.POST = QueryDict(mutable=True)
374        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
375
376        response_parser = ResponseProcessor(self.source, http_request)
377        response_parser.parse()
378
379    def test_encrypted_response_schema_validation(self):
380        """Test that encrypted SAML responses validate against the SAML schema.
381
382        The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd.
383        This ensures we don't have invalid elements like EncryptedData inside Assertion.
384        """
385        self.provider.encryption_kp = self.cert
386        self.provider.save()
387        http_request = self.request_factory.get("/", user=get_anonymous_user())
388
389        # First create an AuthNRequest
390        request_proc = RequestProcessor(self.source, http_request, "test_state")
391        request = request_proc.build_auth_n()
392
393        # To get an assertion we need a parsed request (parsed by provider)
394        parsed_request = AuthNRequestParser(self.provider).parse(
395            b64encode(request.encode()).decode(), "test_state"
396        )
397        # Now create a response and convert it to string (provider)
398        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
399        response = response_proc.build_response()
400
401        # Validate against SAML schema
402        schema = etree.XMLSchema(
403            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
404        )
405        self.assertTrue(schema.validate(lxml_from_string(response)))
406
407        # Verify structure: should have EncryptedAssertion, not Assertion with EncryptedData inside
408        response_xml = fromstring(response)
409        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
410        self.assertEqual(len(response_xml.xpath("//saml:Assertion", namespaces=NS_MAP)), 0)
411
412    def test_request_signed(self):
413        """Test full SAML Request/Response flow, fully signed"""
414        http_request = self.request_factory.get("/", user=get_anonymous_user())
415
416        # First create an AuthNRequest
417        request_proc = RequestProcessor(self.source, http_request, "test_state")
418        request = request_proc.build_auth_n()
419
420        # To get an assertion we need a parsed request (parsed by provider)
421        parsed_request = AuthNRequestParser(self.provider).parse(
422            b64encode(request.encode()).decode(), "test_state"
423        )
424        # Now create a response and convert it to string (provider)
425        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
426        response = response_proc.build_response()
427
428        # Now parse the response (source)
429        http_request.POST = QueryDict(mutable=True)
430        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
431
432        response_parser = ResponseProcessor(self.source, http_request)
433        response_parser.parse()
434
435    def test_request_signed_both(self):
436        """Test full SAML Request/Response flow, fully signed"""
437        self.provider.sign_assertion = True
438        self.provider.sign_response = True
439        self.provider.save()
440        self.source.signed_response = True
441        http_request = self.request_factory.get("/", user=get_anonymous_user())
442
443        # First create an AuthNRequest
444        request_proc = RequestProcessor(self.source, http_request, "test_state")
445        request = request_proc.build_auth_n()
446
447        # To get an assertion we need a parsed request (parsed by provider)
448        parsed_request = AuthNRequestParser(self.provider).parse(
449            b64encode(request.encode()).decode(), "test_state"
450        )
451        # Now create a response and convert it to string (provider)
452        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
453        response = response_proc.build_response()
454        # Ensure both response and assertion ID are in the response twice (once as ID attribute,
455        # once as ds:Reference URI)
456        self.assertEqual(response.count(response_proc._assertion_id), 2)
457        self.assertEqual(response.count(response_proc._response_id), 2)
458
459        schema = etree.XMLSchema(
460            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
461        )
462        self.assertTrue(schema.validate(lxml_from_string(response)))
463
464        response_xml = fromstring(response)
465        self.assertEqual(
466            len(response_xml.xpath("//saml:Assertion/ds:Signature", namespaces=NS_MAP)), 1
467        )
468        self.assertEqual(
469            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
470        )
471
472        # Now parse the response (source)
473        http_request.POST = QueryDict(mutable=True)
474        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
475
476        response_parser = ResponseProcessor(self.source, http_request)
477        response_parser.parse()
478
479    def test_request_id_invalid(self):
480        """Test generated AuthNRequest with invalid request ID"""
481        http_request = self.request_factory.get("/", user=get_anonymous_user())
482
483        # First create an AuthNRequest
484        request_proc = RequestProcessor(self.source, http_request, "test_state")
485        request = request_proc.build_auth_n()
486
487        # change the request ID
488        http_request.session[SESSION_KEY_REQUEST_ID] = "test"
489        http_request.session.save()
490
491        # To get an assertion we need a parsed request (parsed by provider)
492        parsed_request = AuthNRequestParser(self.provider).parse(
493            b64encode(request.encode()).decode(), "test_state"
494        )
495        # Now create a response and convert it to string (provider)
496        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
497        response = response_proc.build_response()
498
499        # Now parse the response (source)
500        http_request.POST = QueryDict(mutable=True)
501        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
502
503        response_parser = ResponseProcessor(self.source, http_request)
504
505        with self.assertRaises(MismatchedRequestID):
506            response_parser.parse()
507
508    def test_signed_valid_detached(self):
509        """Test generated AuthNRequest with valid signature (detached)"""
510        http_request = self.request_factory.get("/")
511
512        # First create an AuthNRequest
513        request_proc = RequestProcessor(self.source, http_request, "test_state")
514        params = request_proc.build_auth_n_detached()
515        # Now we check the ID and signature
516        parsed_request = AuthNRequestParser(self.provider).parse_detached(
517            params["SAMLRequest"],
518            params["RelayState"],
519            params["Signature"],
520            params["SigAlg"],
521        )
522        self.assertEqual(parsed_request.id, request_proc.request_id)
523        self.assertEqual(parsed_request.relay_state, "test_state")
524
525    def test_signed_detached_static(self):
526        """Test request with detached signature,
527        taken from https://www.samltool.com/generic_sso_req.php"""
528        static_keypair = CertificateKeyPair.objects.create(
529            name="samltool", certificate_data=REDIRECT_CERT
530        )
531        provider = SAMLProvider(
532            name="samltool",
533            authorization_flow=create_test_flow(),
534            acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
535            audience="https://10.120.20.200/saml-sp/SAML2/POST",
536            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
537            signing_kp=static_keypair,
538            verification_kp=static_keypair,
539        )
540        parsed_request = AuthNRequestParser(provider).parse_detached(
541            REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG
542        )
543        self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
544        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED)
545        self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE)
546
547    def test_signed_static(self):
548        """Test post request with static request"""
549        provider = SAMLProvider(
550            name="aws",
551            authorization_flow=create_test_flow(),
552            acs_url=(
553                "https://eu-central-1.signin.aws.amazon.com/platform/"
554                "saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
555            ),
556            audience="https://10.120.20.200/saml-sp/SAML2/POST",
557            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
558            signing_kp=create_test_cert(),
559        )
560        parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
561        self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
562        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
563
564    def test_authn_context_class_ref_mapping(self):
565        """Test custom authn_context_class_ref"""
566        authn_context_class_ref = generate_id()
567        mapping = SAMLPropertyMapping.objects.create(
568            name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
569        )
570        self.provider.authn_context_class_ref_mapping = mapping
571        self.provider.save()
572        user = create_test_admin_user()
573        http_request = self.request_factory.get("/", user=user)
574
575        # First create an AuthNRequest
576        request_proc = RequestProcessor(self.source, http_request, "test_state")
577        request = request_proc.build_auth_n()
578
579        # To get an assertion we need a parsed request (parsed by provider)
580        parsed_request = AuthNRequestParser(self.provider).parse(
581            b64encode(request.encode()).decode(), "test_state"
582        )
583        # Now create a response and convert it to string (provider)
584        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
585        response = response_proc.build_response()
586        self.assertIn(user.username, response)
587        self.assertIn(authn_context_class_ref, response)
588
589    def test_authn_context_class_ref_mapping_invalid(self):
590        """Test custom authn_context_class_ref (invalid)"""
591        mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
592        self.provider.authn_context_class_ref_mapping = mapping
593        self.provider.save()
594        user = create_test_admin_user()
595        http_request = self.request_factory.get("/", user=user)
596
597        # First create an AuthNRequest
598        request_proc = RequestProcessor(self.source, http_request, "test_state")
599        request = request_proc.build_auth_n()
600
601        # To get an assertion we need a parsed request (parsed by provider)
602        parsed_request = AuthNRequestParser(self.provider).parse(
603            b64encode(request.encode()).decode(), "test_state"
604        )
605        # Now create a response and convert it to string (provider)
606        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
607        response = response_proc.build_response()
608        self.assertIn(user.username, response)
609
610        events = Event.objects.filter(
611            action=EventAction.CONFIGURATION_ERROR,
612        )
613        self.assertTrue(events.exists())
614        self.assertEqual(
615            events.first().context["message"],
616            f"Failed to evaluate property-mapping: '{mapping.name}'",
617        )
618
619    def test_request_attributes(self):
620        """Test full SAML Request/Response flow, fully signed"""
621        user = create_test_admin_user()
622        http_request = self.request_factory.get("/", user=user)
623
624        # First create an AuthNRequest
625        request_proc = RequestProcessor(self.source, http_request, "test_state")
626        request = request_proc.build_auth_n()
627
628        # To get an assertion we need a parsed request (parsed by provider)
629        parsed_request = AuthNRequestParser(self.provider).parse(
630            b64encode(request.encode()).decode(), "test_state"
631        )
632        # Now create a response and convert it to string (provider)
633        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
634        self.assertIn(user.username, response_proc.build_response())
635
636    def test_request_attributes_invalid(self):
637        """Test full SAML Request/Response flow, fully signed"""
638        user = create_test_admin_user()
639        http_request = self.request_factory.get("/", user=user)
640
641        # First create an AuthNRequest
642        request_proc = RequestProcessor(self.source, http_request, "test_state")
643        request = request_proc.build_auth_n()
644
645        # Create invalid PropertyMapping
646        mapping = SAMLPropertyMapping.objects.create(
647            name=generate_id(), saml_name="test", expression="q"
648        )
649        self.provider.property_mappings.add(mapping)
650
651        # To get an assertion we need a parsed request (parsed by provider)
652        parsed_request = AuthNRequestParser(self.provider).parse(
653            b64encode(request.encode()).decode(), "test_state"
654        )
655        # Now create a response and convert it to string (provider)
656        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
657        self.assertIn(user.username, response_proc.build_response())
658
659        events = Event.objects.filter(
660            action=EventAction.CONFIGURATION_ERROR,
661        )
662        self.assertTrue(events.exists())
663        self.assertEqual(
664            events.first().context["message"],
665            f"Failed to evaluate property-mapping: '{mapping.name}'",
666        )
667
668    def test_idp_initiated(self):
669        """Test IDP-initiated login"""
670        self.provider.default_relay_state = generate_id()
671        request = AuthNRequestParser(self.provider).idp_initiated()
672        self.assertEqual(request.id, None)
673        self.assertEqual(request.relay_state, self.provider.default_relay_state)

Test AuthN Request generator and parser

@apply_blueprint('system/providers-saml.yaml')
def setUp(self):
 90    @apply_blueprint("system/providers-saml.yaml")
 91    def setUp(self):
 92        self.request_factory = RequestFactory()
 93        self.cert = create_test_cert()
 94        self.provider: SAMLProvider = SAMLProvider.objects.create(
 95            authorization_flow=create_test_flow(),
 96            acs_url="http://testserver/source/saml/provider/acs/",
 97            signing_kp=self.cert,
 98            verification_kp=self.cert,
 99        )
100        self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
101        self.provider.save()
102        Application.objects.create(
103            name="test-app",
104            slug="test-app",
105            provider=self.provider,
106        )
107        self.source = SAMLSource.objects.create(
108            slug="provider",
109            issuer="authentik",
110            pre_authentication_flow=create_test_flow(),
111            signing_kp=self.cert,
112            verification_kp=self.cert,
113            signed_assertion=True,
114            binding_type=SAMLBindingTypes.POST,
115        )

Hook method for setting up the test fixture before exercising it.

def test_signed_valid(self):
117    def test_signed_valid(self):
118        """Test generated AuthNRequest with valid signature"""
119        http_request = self.request_factory.get("/")
120
121        # First create an AuthNRequest
122        request_proc = RequestProcessor(self.source, http_request, "test_state")
123        auth_n = request_proc.get_auth_n()
124        self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_POST)
125
126        request = request_proc.build_auth_n()
127        # Now we check the ID and signature
128        parsed_request = AuthNRequestParser(self.provider).parse(
129            b64encode(request.encode()).decode(), "test_state"
130        )
131        self.assertEqual(parsed_request.id, request_proc.request_id)
132        self.assertEqual(parsed_request.relay_state, "test_state")

Test generated AuthNRequest with valid signature

def test_request_encrypt(self):
134    def test_request_encrypt(self):
135        """Test full SAML Request/Response flow, fully encrypted"""
136        self.provider.encryption_kp = self.cert
137        self.provider.save()
138        self.source.encryption_kp = self.cert
139        self.source.save()
140        http_request = self.request_factory.get("/", user=get_anonymous_user())
141
142        # First create an AuthNRequest
143        request_proc = RequestProcessor(self.source, http_request, "test_state")
144        request = request_proc.build_auth_n()
145
146        # To get an assertion we need a parsed request (parsed by provider)
147        parsed_request = AuthNRequestParser(self.provider).parse(
148            b64encode(request.encode()).decode(), "test_state"
149        )
150        # Now create a response and convert it to string (provider)
151        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
152        response = response_proc.build_response()
153
154        # Now parse the response (source)
155        http_request.POST = QueryDict(mutable=True)
156        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
157
158        response_parser = ResponseProcessor(self.source, http_request)
159        response_parser.parse()

Test full SAML Request/Response flow, fully encrypted

def test_request_encrypt_cert_only(self):
161    def test_request_encrypt_cert_only(self):
162        """Test SAML encryption with certificate-only keypair (no private key).
163
164        This tests the scenario where the IdP (provider) only has the SP's public
165        certificate for encryption, without a private key. This is the expected
166        real-world scenario since the SP would never share their private key.
167        """
168        # Create a full keypair for the source (SP) - it needs the private key to decrypt
169        full_keypair = create_test_cert()
170
171        # Create a certificate-only keypair for the provider (IdP)
172        # This simulates having only the SP's public certificate
173        cert_only = CertificateKeyPair.objects.create(
174            name=generate_id(),
175            certificate_data=full_keypair.certificate_data,
176            key_data="",  # No private key
177        )
178
179        self.provider.encryption_kp = cert_only
180        self.provider.save()
181        self.source.encryption_kp = full_keypair
182        self.source.save()
183        http_request = self.request_factory.get("/", user=get_anonymous_user())
184
185        # First create an AuthNRequest
186        request_proc = RequestProcessor(self.source, http_request, "test_state")
187        request = request_proc.build_auth_n()
188
189        # To get an assertion we need a parsed request (parsed by provider)
190        parsed_request = AuthNRequestParser(self.provider).parse(
191            b64encode(request.encode()).decode(), "test_state"
192        )
193        # Now create a response and convert it to string (provider)
194        # This should work with only the certificate (public key) for encryption
195        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
196        response = response_proc.build_response()
197
198        # Now parse the response (source) - decryption requires the private key
199        http_request.POST = QueryDict(mutable=True)
200        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
201
202        response_parser = ResponseProcessor(self.source, http_request)
203        response_parser.parse()

Test SAML encryption with certificate-only keypair (no private key).

This tests the scenario where the IdP (provider) only has the SP's public certificate for encryption, without a private key. This is the expected real-world scenario since the SP would never share their private key.

def test_request_sign_response_and_encrypt(self):
205    def test_request_sign_response_and_encrypt(self):
206        """Test SAML with sign_response enabled AND encryption.
207
208        This tests the fix for signature invalidation when encryption is enabled.
209        The response must be signed AFTER encryption, not before, because encryption
210        replaces the Assertion with EncryptedAssertion which changes the response content.
211        """
212        self.provider.sign_response = True
213        self.provider.sign_assertion = False
214        self.provider.encryption_kp = self.cert
215        self.provider.save()
216        self.source.encryption_kp = self.cert
217        self.source.signed_response = True
218        self.source.signed_assertion = False  # Only response is signed, not assertion
219        self.source.save()
220        http_request = self.request_factory.get("/", user=get_anonymous_user())
221
222        # First create an AuthNRequest
223        request_proc = RequestProcessor(self.source, http_request, "test_state")
224        request = request_proc.build_auth_n()
225
226        # To get an assertion we need a parsed request (parsed by provider)
227        parsed_request = AuthNRequestParser(self.provider).parse(
228            b64encode(request.encode()).decode(), "test_state"
229        )
230        # Now create a response and convert it to string (provider)
231        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
232        response = response_proc.build_response()
233
234        # Verify the response contains EncryptedAssertion and a signature
235        response_xml = fromstring(response)
236        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
237        self.assertEqual(
238            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
239        )
240
241        # Now parse the response (source) - this will verify the signature and decrypt
242        http_request.POST = QueryDict(mutable=True)
243        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
244
245        response_parser = ResponseProcessor(self.source, http_request)
246        response_parser.parse()

Test SAML with sign_response enabled AND encryption.

This tests the fix for signature invalidation when encryption is enabled. The response must be signed AFTER encryption, not before, because encryption replaces the Assertion with EncryptedAssertion which changes the response content.

def test_request_sign_assertion_and_encrypt(self):
248    def test_request_sign_assertion_and_encrypt(self):
249        """Test SAML with sign_assertion enabled AND encryption.
250
251        The assertion signature should be inside the encrypted content and
252        remain valid after decryption.
253        """
254        self.provider.sign_response = False
255        self.provider.sign_assertion = True
256        self.provider.encryption_kp = self.cert
257        self.provider.save()
258        self.source.encryption_kp = self.cert
259        self.source.signed_assertion = True
260        self.source.save()
261        http_request = self.request_factory.get("/", user=get_anonymous_user())
262
263        # First create an AuthNRequest
264        request_proc = RequestProcessor(self.source, http_request, "test_state")
265        request = request_proc.build_auth_n()
266
267        # To get an assertion we need a parsed request (parsed by provider)
268        parsed_request = AuthNRequestParser(self.provider).parse(
269            b64encode(request.encode()).decode(), "test_state"
270        )
271        # Now create a response and convert it to string (provider)
272        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
273        response = response_proc.build_response()
274
275        # Verify the response contains EncryptedAssertion
276        response_xml = fromstring(response)
277        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
278
279        # Now parse the response (source) - this will decrypt and verify assertion signature
280        http_request.POST = QueryDict(mutable=True)
281        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
282
283        response_parser = ResponseProcessor(self.source, http_request)
284        response_parser.parse()

Test SAML with sign_assertion enabled AND encryption.

The assertion signature should be inside the encrypted content and remain valid after decryption.

def test_request_sign_both_and_encrypt(self):
286    def test_request_sign_both_and_encrypt(self):
287        """Test SAML with both sign_assertion and sign_response enabled AND encryption.
288
289        This is the most complex scenario: assertion is signed, then encrypted,
290        then the response is signed. Both signatures should be valid.
291        """
292        self.provider.sign_response = True
293        self.provider.sign_assertion = True
294        self.provider.encryption_kp = self.cert
295        self.provider.save()
296        self.source.encryption_kp = self.cert
297        self.source.signed_assertion = True
298        self.source.signed_response = True
299        self.source.save()
300        http_request = self.request_factory.get("/", user=get_anonymous_user())
301
302        # First create an AuthNRequest
303        request_proc = RequestProcessor(self.source, http_request, "test_state")
304        request = request_proc.build_auth_n()
305
306        # To get an assertion we need a parsed request (parsed by provider)
307        parsed_request = AuthNRequestParser(self.provider).parse(
308            b64encode(request.encode()).decode(), "test_state"
309        )
310        # Now create a response and convert it to string (provider)
311        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
312        response = response_proc.build_response()
313
314        # Verify the response contains EncryptedAssertion and response signature
315        response_xml = fromstring(response)
316        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
317        self.assertEqual(
318            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
319        )
320
321        # Now parse the response (source) - this will verify response signature,
322        # decrypt, then verify assertion signature
323        http_request.POST = QueryDict(mutable=True)
324        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
325
326        response_parser = ResponseProcessor(self.source, http_request)
327        response_parser.parse()

Test SAML with both sign_assertion and sign_response enabled AND encryption.

This is the most complex scenario: assertion is signed, then encrypted, then the response is signed. Both signatures should be valid.

def test_encrypted_assertion_namespace_preservation(self):
329    def test_encrypted_assertion_namespace_preservation(self):
330        """Test that encrypted assertions include namespace declarations.
331
332        When an assertion is encrypted, the resulting decrypted XML must include
333        the necessary namespace declarations (xmlns:saml) since it's now a standalone
334        document fragment, no longer inheriting namespaces from the parent Response.
335        """
336        self.provider.encryption_kp = self.cert
337        self.provider.save()
338        self.source.encryption_kp = self.cert
339        self.source.save()
340        http_request = self.request_factory.get("/", user=get_anonymous_user())
341
342        # First create an AuthNRequest
343        request_proc = RequestProcessor(self.source, http_request, "test_state")
344        request = request_proc.build_auth_n()
345
346        # To get an assertion we need a parsed request (parsed by provider)
347        parsed_request = AuthNRequestParser(self.provider).parse(
348            b64encode(request.encode()).decode(), "test_state"
349        )
350        # Now create a response and convert it to string (provider)
351        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
352        response = response_proc.build_response()
353
354        # Parse the encrypted response
355        response_xml = fromstring(response)
356        encrypted_assertion = response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)[0]
357        encrypted_data = encrypted_assertion.xpath("//xenc:EncryptedData", namespaces=NS_MAP)[0]
358
359        # Decrypt the assertion manually to verify namespace is present
360        import xmlsec
361
362        manager = xmlsec.KeysManager()
363        key = xmlsec.Key.from_memory(self.cert.key_data, xmlsec.constants.KeyDataFormatPem, None)
364        manager.add_key(key)
365        enc_ctx = xmlsec.EncryptionContext(manager)
366        decrypted = enc_ctx.decrypt(encrypted_data)
367
368        # The decrypted assertion should have xmlns:saml namespace declaration
369        decrypted_str = etree.tostring(decrypted).decode()
370        self.assertIn("xmlns:saml", decrypted_str)
371
372        # Also verify full round-trip works (source can parse it)
373        http_request.POST = QueryDict(mutable=True)
374        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
375
376        response_parser = ResponseProcessor(self.source, http_request)
377        response_parser.parse()

Test that encrypted assertions include namespace declarations.

When an assertion is encrypted, the resulting decrypted XML must include the necessary namespace declarations (xmlns:saml) since it's now a standalone document fragment, no longer inheriting namespaces from the parent Response.

def test_encrypted_response_schema_validation(self):
379    def test_encrypted_response_schema_validation(self):
380        """Test that encrypted SAML responses validate against the SAML schema.
381
382        The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd.
383        This ensures we don't have invalid elements like EncryptedData inside Assertion.
384        """
385        self.provider.encryption_kp = self.cert
386        self.provider.save()
387        http_request = self.request_factory.get("/", user=get_anonymous_user())
388
389        # First create an AuthNRequest
390        request_proc = RequestProcessor(self.source, http_request, "test_state")
391        request = request_proc.build_auth_n()
392
393        # To get an assertion we need a parsed request (parsed by provider)
394        parsed_request = AuthNRequestParser(self.provider).parse(
395            b64encode(request.encode()).decode(), "test_state"
396        )
397        # Now create a response and convert it to string (provider)
398        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
399        response = response_proc.build_response()
400
401        # Validate against SAML schema
402        schema = etree.XMLSchema(
403            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
404        )
405        self.assertTrue(schema.validate(lxml_from_string(response)))
406
407        # Verify structure: should have EncryptedAssertion, not Assertion with EncryptedData inside
408        response_xml = fromstring(response)
409        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
410        self.assertEqual(len(response_xml.xpath("//saml:Assertion", namespaces=NS_MAP)), 0)

Test that encrypted SAML responses validate against the SAML schema.

The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd. This ensures we don't have invalid elements like EncryptedData inside Assertion.

def test_request_signed(self):
412    def test_request_signed(self):
413        """Test full SAML Request/Response flow, fully signed"""
414        http_request = self.request_factory.get("/", user=get_anonymous_user())
415
416        # First create an AuthNRequest
417        request_proc = RequestProcessor(self.source, http_request, "test_state")
418        request = request_proc.build_auth_n()
419
420        # To get an assertion we need a parsed request (parsed by provider)
421        parsed_request = AuthNRequestParser(self.provider).parse(
422            b64encode(request.encode()).decode(), "test_state"
423        )
424        # Now create a response and convert it to string (provider)
425        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
426        response = response_proc.build_response()
427
428        # Now parse the response (source)
429        http_request.POST = QueryDict(mutable=True)
430        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
431
432        response_parser = ResponseProcessor(self.source, http_request)
433        response_parser.parse()

Test full SAML Request/Response flow, fully signed

def test_request_signed_both(self):
435    def test_request_signed_both(self):
436        """Test full SAML Request/Response flow, fully signed"""
437        self.provider.sign_assertion = True
438        self.provider.sign_response = True
439        self.provider.save()
440        self.source.signed_response = True
441        http_request = self.request_factory.get("/", user=get_anonymous_user())
442
443        # First create an AuthNRequest
444        request_proc = RequestProcessor(self.source, http_request, "test_state")
445        request = request_proc.build_auth_n()
446
447        # To get an assertion we need a parsed request (parsed by provider)
448        parsed_request = AuthNRequestParser(self.provider).parse(
449            b64encode(request.encode()).decode(), "test_state"
450        )
451        # Now create a response and convert it to string (provider)
452        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
453        response = response_proc.build_response()
454        # Ensure both response and assertion ID are in the response twice (once as ID attribute,
455        # once as ds:Reference URI)
456        self.assertEqual(response.count(response_proc._assertion_id), 2)
457        self.assertEqual(response.count(response_proc._response_id), 2)
458
459        schema = etree.XMLSchema(
460            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
461        )
462        self.assertTrue(schema.validate(lxml_from_string(response)))
463
464        response_xml = fromstring(response)
465        self.assertEqual(
466            len(response_xml.xpath("//saml:Assertion/ds:Signature", namespaces=NS_MAP)), 1
467        )
468        self.assertEqual(
469            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
470        )
471
472        # Now parse the response (source)
473        http_request.POST = QueryDict(mutable=True)
474        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
475
476        response_parser = ResponseProcessor(self.source, http_request)
477        response_parser.parse()

Test full SAML Request/Response flow, fully signed

def test_request_id_invalid(self):
479    def test_request_id_invalid(self):
480        """Test generated AuthNRequest with invalid request ID"""
481        http_request = self.request_factory.get("/", user=get_anonymous_user())
482
483        # First create an AuthNRequest
484        request_proc = RequestProcessor(self.source, http_request, "test_state")
485        request = request_proc.build_auth_n()
486
487        # change the request ID
488        http_request.session[SESSION_KEY_REQUEST_ID] = "test"
489        http_request.session.save()
490
491        # To get an assertion we need a parsed request (parsed by provider)
492        parsed_request = AuthNRequestParser(self.provider).parse(
493            b64encode(request.encode()).decode(), "test_state"
494        )
495        # Now create a response and convert it to string (provider)
496        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
497        response = response_proc.build_response()
498
499        # Now parse the response (source)
500        http_request.POST = QueryDict(mutable=True)
501        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
502
503        response_parser = ResponseProcessor(self.source, http_request)
504
505        with self.assertRaises(MismatchedRequestID):
506            response_parser.parse()

Test generated AuthNRequest with invalid request ID

def test_signed_valid_detached(self):
508    def test_signed_valid_detached(self):
509        """Test generated AuthNRequest with valid signature (detached)"""
510        http_request = self.request_factory.get("/")
511
512        # First create an AuthNRequest
513        request_proc = RequestProcessor(self.source, http_request, "test_state")
514        params = request_proc.build_auth_n_detached()
515        # Now we check the ID and signature
516        parsed_request = AuthNRequestParser(self.provider).parse_detached(
517            params["SAMLRequest"],
518            params["RelayState"],
519            params["Signature"],
520            params["SigAlg"],
521        )
522        self.assertEqual(parsed_request.id, request_proc.request_id)
523        self.assertEqual(parsed_request.relay_state, "test_state")

Test generated AuthNRequest with valid signature (detached)

def test_signed_detached_static(self):
525    def test_signed_detached_static(self):
526        """Test request with detached signature,
527        taken from https://www.samltool.com/generic_sso_req.php"""
528        static_keypair = CertificateKeyPair.objects.create(
529            name="samltool", certificate_data=REDIRECT_CERT
530        )
531        provider = SAMLProvider(
532            name="samltool",
533            authorization_flow=create_test_flow(),
534            acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
535            audience="https://10.120.20.200/saml-sp/SAML2/POST",
536            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
537            signing_kp=static_keypair,
538            verification_kp=static_keypair,
539        )
540        parsed_request = AuthNRequestParser(provider).parse_detached(
541            REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG
542        )
543        self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
544        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED)
545        self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE)

Test request with detached signature, taken from https://www.samltool.com/generic_sso_req.php

def test_signed_static(self):
547    def test_signed_static(self):
548        """Test post request with static request"""
549        provider = SAMLProvider(
550            name="aws",
551            authorization_flow=create_test_flow(),
552            acs_url=(
553                "https://eu-central-1.signin.aws.amazon.com/platform/"
554                "saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
555            ),
556            audience="https://10.120.20.200/saml-sp/SAML2/POST",
557            issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
558            signing_kp=create_test_cert(),
559        )
560        parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
561        self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
562        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)

Test post request with static request

def test_authn_context_class_ref_mapping(self):
564    def test_authn_context_class_ref_mapping(self):
565        """Test custom authn_context_class_ref"""
566        authn_context_class_ref = generate_id()
567        mapping = SAMLPropertyMapping.objects.create(
568            name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
569        )
570        self.provider.authn_context_class_ref_mapping = mapping
571        self.provider.save()
572        user = create_test_admin_user()
573        http_request = self.request_factory.get("/", user=user)
574
575        # First create an AuthNRequest
576        request_proc = RequestProcessor(self.source, http_request, "test_state")
577        request = request_proc.build_auth_n()
578
579        # To get an assertion we need a parsed request (parsed by provider)
580        parsed_request = AuthNRequestParser(self.provider).parse(
581            b64encode(request.encode()).decode(), "test_state"
582        )
583        # Now create a response and convert it to string (provider)
584        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
585        response = response_proc.build_response()
586        self.assertIn(user.username, response)
587        self.assertIn(authn_context_class_ref, response)

Test custom authn_context_class_ref

def test_authn_context_class_ref_mapping_invalid(self):
589    def test_authn_context_class_ref_mapping_invalid(self):
590        """Test custom authn_context_class_ref (invalid)"""
591        mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
592        self.provider.authn_context_class_ref_mapping = mapping
593        self.provider.save()
594        user = create_test_admin_user()
595        http_request = self.request_factory.get("/", user=user)
596
597        # First create an AuthNRequest
598        request_proc = RequestProcessor(self.source, http_request, "test_state")
599        request = request_proc.build_auth_n()
600
601        # To get an assertion we need a parsed request (parsed by provider)
602        parsed_request = AuthNRequestParser(self.provider).parse(
603            b64encode(request.encode()).decode(), "test_state"
604        )
605        # Now create a response and convert it to string (provider)
606        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
607        response = response_proc.build_response()
608        self.assertIn(user.username, response)
609
610        events = Event.objects.filter(
611            action=EventAction.CONFIGURATION_ERROR,
612        )
613        self.assertTrue(events.exists())
614        self.assertEqual(
615            events.first().context["message"],
616            f"Failed to evaluate property-mapping: '{mapping.name}'",
617        )

Test custom authn_context_class_ref (invalid)

def test_request_attributes(self):
619    def test_request_attributes(self):
620        """Test full SAML Request/Response flow, fully signed"""
621        user = create_test_admin_user()
622        http_request = self.request_factory.get("/", user=user)
623
624        # First create an AuthNRequest
625        request_proc = RequestProcessor(self.source, http_request, "test_state")
626        request = request_proc.build_auth_n()
627
628        # To get an assertion we need a parsed request (parsed by provider)
629        parsed_request = AuthNRequestParser(self.provider).parse(
630            b64encode(request.encode()).decode(), "test_state"
631        )
632        # Now create a response and convert it to string (provider)
633        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
634        self.assertIn(user.username, response_proc.build_response())

Test full SAML Request/Response flow, fully signed

def test_request_attributes_invalid(self):
636    def test_request_attributes_invalid(self):
637        """Test full SAML Request/Response flow, fully signed"""
638        user = create_test_admin_user()
639        http_request = self.request_factory.get("/", user=user)
640
641        # First create an AuthNRequest
642        request_proc = RequestProcessor(self.source, http_request, "test_state")
643        request = request_proc.build_auth_n()
644
645        # Create invalid PropertyMapping
646        mapping = SAMLPropertyMapping.objects.create(
647            name=generate_id(), saml_name="test", expression="q"
648        )
649        self.provider.property_mappings.add(mapping)
650
651        # To get an assertion we need a parsed request (parsed by provider)
652        parsed_request = AuthNRequestParser(self.provider).parse(
653            b64encode(request.encode()).decode(), "test_state"
654        )
655        # Now create a response and convert it to string (provider)
656        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
657        self.assertIn(user.username, response_proc.build_response())
658
659        events = Event.objects.filter(
660            action=EventAction.CONFIGURATION_ERROR,
661        )
662        self.assertTrue(events.exists())
663        self.assertEqual(
664            events.first().context["message"],
665            f"Failed to evaluate property-mapping: '{mapping.name}'",
666        )

Test full SAML Request/Response flow, fully signed

def test_idp_initiated(self):
668    def test_idp_initiated(self):
669        """Test IDP-initiated login"""
670        self.provider.default_relay_state = generate_id()
671        request = AuthNRequestParser(self.provider).idp_initiated()
672        self.assertEqual(request.id, None)
673        self.assertEqual(request.relay_state, self.provider.default_relay_state)

Test IDP-initiated login