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

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

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

Test generated AuthNRequest with valid signature

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

Test full SAML Request/Response flow, fully encrypted

def test_request_encrypt_cert_only(self):
155    def test_request_encrypt_cert_only(self):
156        """Test SAML encryption with certificate-only keypair (no private key).
157
158        This tests the scenario where the IdP (provider) only has the SP's public
159        certificate for encryption, without a private key. This is the expected
160        real-world scenario since the SP would never share their private key.
161        """
162        # Create a full keypair for the source (SP) - it needs the private key to decrypt
163        full_keypair = create_test_cert()
164
165        # Create a certificate-only keypair for the provider (IdP)
166        # This simulates having only the SP's public certificate
167        cert_only = CertificateKeyPair.objects.create(
168            name=generate_id(),
169            certificate_data=full_keypair.certificate_data,
170            key_data="",  # No private key
171        )
172
173        self.provider.encryption_kp = cert_only
174        self.provider.save()
175        self.source.encryption_kp = full_keypair
176        self.source.save()
177        http_request = self.request_factory.get("/", user=get_anonymous_user())
178
179        # First create an AuthNRequest
180        request_proc = RequestProcessor(self.source, http_request, "test_state")
181        request = request_proc.build_auth_n()
182
183        # To get an assertion we need a parsed request (parsed by provider)
184        parsed_request = AuthNRequestParser(self.provider).parse(
185            b64encode(request.encode()).decode(), "test_state"
186        )
187        # Now create a response and convert it to string (provider)
188        # This should work with only the certificate (public key) for encryption
189        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
190        response = response_proc.build_response()
191
192        # Now parse the response (source) - decryption requires the private key
193        http_request.POST = QueryDict(mutable=True)
194        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
195
196        response_parser = ResponseProcessor(self.source, http_request)
197        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):
199    def test_request_sign_response_and_encrypt(self):
200        """Test SAML with sign_response enabled AND encryption.
201
202        This tests the fix for signature invalidation when encryption is enabled.
203        The response must be signed AFTER encryption, not before, because encryption
204        replaces the Assertion with EncryptedAssertion which changes the response content.
205        """
206        self.provider.sign_response = True
207        self.provider.sign_assertion = False
208        self.provider.encryption_kp = self.cert
209        self.provider.save()
210        self.source.encryption_kp = self.cert
211        self.source.signed_response = True
212        self.source.signed_assertion = False  # Only response is signed, not assertion
213        self.source.save()
214        http_request = self.request_factory.get("/", user=get_anonymous_user())
215
216        # First create an AuthNRequest
217        request_proc = RequestProcessor(self.source, http_request, "test_state")
218        request = request_proc.build_auth_n()
219
220        # To get an assertion we need a parsed request (parsed by provider)
221        parsed_request = AuthNRequestParser(self.provider).parse(
222            b64encode(request.encode()).decode(), "test_state"
223        )
224        # Now create a response and convert it to string (provider)
225        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
226        response = response_proc.build_response()
227
228        # Verify the response contains EncryptedAssertion and a signature
229        response_xml = fromstring(response)
230        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
231        self.assertEqual(
232            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
233        )
234
235        # Now parse the response (source) - this will verify the signature and decrypt
236        http_request.POST = QueryDict(mutable=True)
237        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
238
239        response_parser = ResponseProcessor(self.source, http_request)
240        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):
242    def test_request_sign_assertion_and_encrypt(self):
243        """Test SAML with sign_assertion enabled AND encryption.
244
245        The assertion signature should be inside the encrypted content and
246        remain valid after decryption.
247        """
248        self.provider.sign_response = False
249        self.provider.sign_assertion = True
250        self.provider.encryption_kp = self.cert
251        self.provider.save()
252        self.source.encryption_kp = self.cert
253        self.source.signed_assertion = True
254        self.source.save()
255        http_request = self.request_factory.get("/", user=get_anonymous_user())
256
257        # First create an AuthNRequest
258        request_proc = RequestProcessor(self.source, http_request, "test_state")
259        request = request_proc.build_auth_n()
260
261        # To get an assertion we need a parsed request (parsed by provider)
262        parsed_request = AuthNRequestParser(self.provider).parse(
263            b64encode(request.encode()).decode(), "test_state"
264        )
265        # Now create a response and convert it to string (provider)
266        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
267        response = response_proc.build_response()
268
269        # Verify the response contains EncryptedAssertion
270        response_xml = fromstring(response)
271        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
272
273        # Now parse the response (source) - this will decrypt and verify assertion signature
274        http_request.POST = QueryDict(mutable=True)
275        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
276
277        response_parser = ResponseProcessor(self.source, http_request)
278        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):
280    def test_request_sign_both_and_encrypt(self):
281        """Test SAML with both sign_assertion and sign_response enabled AND encryption.
282
283        This is the most complex scenario: assertion is signed, then encrypted,
284        then the response is signed. Both signatures should be valid.
285        """
286        self.provider.sign_response = True
287        self.provider.sign_assertion = True
288        self.provider.encryption_kp = self.cert
289        self.provider.save()
290        self.source.encryption_kp = self.cert
291        self.source.signed_assertion = True
292        self.source.signed_response = True
293        self.source.save()
294        http_request = self.request_factory.get("/", user=get_anonymous_user())
295
296        # First create an AuthNRequest
297        request_proc = RequestProcessor(self.source, http_request, "test_state")
298        request = request_proc.build_auth_n()
299
300        # To get an assertion we need a parsed request (parsed by provider)
301        parsed_request = AuthNRequestParser(self.provider).parse(
302            b64encode(request.encode()).decode(), "test_state"
303        )
304        # Now create a response and convert it to string (provider)
305        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
306        response = response_proc.build_response()
307
308        # Verify the response contains EncryptedAssertion and response signature
309        response_xml = fromstring(response)
310        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
311        self.assertEqual(
312            len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
313        )
314
315        # Now parse the response (source) - this will verify response signature,
316        # decrypt, then verify assertion signature
317        http_request.POST = QueryDict(mutable=True)
318        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
319
320        response_parser = ResponseProcessor(self.source, http_request)
321        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):
323    def test_encrypted_assertion_namespace_preservation(self):
324        """Test that encrypted assertions include namespace declarations.
325
326        When an assertion is encrypted, the resulting decrypted XML must include
327        the necessary namespace declarations (xmlns:saml) since it's now a standalone
328        document fragment, no longer inheriting namespaces from the parent Response.
329        """
330        self.provider.encryption_kp = self.cert
331        self.provider.save()
332        self.source.encryption_kp = self.cert
333        self.source.save()
334        http_request = self.request_factory.get("/", user=get_anonymous_user())
335
336        # First create an AuthNRequest
337        request_proc = RequestProcessor(self.source, http_request, "test_state")
338        request = request_proc.build_auth_n()
339
340        # To get an assertion we need a parsed request (parsed by provider)
341        parsed_request = AuthNRequestParser(self.provider).parse(
342            b64encode(request.encode()).decode(), "test_state"
343        )
344        # Now create a response and convert it to string (provider)
345        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
346        response = response_proc.build_response()
347
348        # Parse the encrypted response
349        response_xml = fromstring(response)
350        encrypted_assertion = response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)[0]
351        encrypted_data = encrypted_assertion.xpath("//xenc:EncryptedData", namespaces=NS_MAP)[0]
352
353        # Decrypt the assertion manually to verify namespace is present
354        import xmlsec
355
356        manager = xmlsec.KeysManager()
357        key = xmlsec.Key.from_memory(self.cert.key_data, xmlsec.constants.KeyDataFormatPem, None)
358        manager.add_key(key)
359        enc_ctx = xmlsec.EncryptionContext(manager)
360        decrypted = enc_ctx.decrypt(encrypted_data)
361
362        # The decrypted assertion should have xmlns:saml namespace declaration
363        decrypted_str = etree.tostring(decrypted).decode()
364        self.assertIn("xmlns:saml", decrypted_str)
365
366        # Also verify full round-trip works (source can parse it)
367        http_request.POST = QueryDict(mutable=True)
368        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
369
370        response_parser = ResponseProcessor(self.source, http_request)
371        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):
373    def test_encrypted_response_schema_validation(self):
374        """Test that encrypted SAML responses validate against the SAML schema.
375
376        The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd.
377        This ensures we don't have invalid elements like EncryptedData inside Assertion.
378        """
379        self.provider.encryption_kp = self.cert
380        self.provider.save()
381        http_request = self.request_factory.get("/", user=get_anonymous_user())
382
383        # First create an AuthNRequest
384        request_proc = RequestProcessor(self.source, http_request, "test_state")
385        request = request_proc.build_auth_n()
386
387        # To get an assertion we need a parsed request (parsed by provider)
388        parsed_request = AuthNRequestParser(self.provider).parse(
389            b64encode(request.encode()).decode(), "test_state"
390        )
391        # Now create a response and convert it to string (provider)
392        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
393        response = response_proc.build_response()
394
395        # Validate against SAML schema
396        schema = etree.XMLSchema(
397            etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser())  # nosec
398        )
399        self.assertTrue(schema.validate(lxml_from_string(response)))
400
401        # Verify structure: should have EncryptedAssertion, not Assertion with EncryptedData inside
402        response_xml = fromstring(response)
403        self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
404        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):
406    def test_request_signed(self):
407        """Test full SAML Request/Response flow, fully signed"""
408        http_request = self.request_factory.get("/", user=get_anonymous_user())
409
410        # First create an AuthNRequest
411        request_proc = RequestProcessor(self.source, http_request, "test_state")
412        request = request_proc.build_auth_n()
413
414        # To get an assertion we need a parsed request (parsed by provider)
415        parsed_request = AuthNRequestParser(self.provider).parse(
416            b64encode(request.encode()).decode(), "test_state"
417        )
418        # Now create a response and convert it to string (provider)
419        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
420        response = response_proc.build_response()
421
422        # Now parse the response (source)
423        http_request.POST = QueryDict(mutable=True)
424        http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
425
426        response_parser = ResponseProcessor(self.source, http_request)
427        response_parser.parse()

Test full SAML Request/Response flow, fully signed

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

Test full SAML Request/Response flow, fully signed

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

Test generated AuthNRequest with invalid request ID

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

Test generated AuthNRequest with valid signature (detached)

def test_signed_detached_static(self):
519    def test_signed_detached_static(self):
520        """Test request with detached signature,
521        taken from https://www.samltool.com/generic_sso_req.php"""
522        static_keypair = CertificateKeyPair.objects.create(
523            name="samltool", certificate_data=REDIRECT_CERT
524        )
525        provider = SAMLProvider(
526            name="samltool",
527            authorization_flow=create_test_flow(),
528            acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
529            audience="https://10.120.20.200/saml-sp/SAML2/POST",
530            issuer="https://10.120.20.200/saml-sp/SAML2/POST",
531            signing_kp=static_keypair,
532            verification_kp=static_keypair,
533        )
534        parsed_request = AuthNRequestParser(provider).parse_detached(
535            REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG
536        )
537        self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
538        self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED)
539        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):
541    def test_signed_static(self):
542        """Test post request with static request"""
543        provider = SAMLProvider(
544            name="aws",
545            authorization_flow=create_test_flow(),
546            acs_url=(
547                "https://eu-central-1.signin.aws.amazon.com/platform/"
548                "saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
549            ),
550            audience="https://10.120.20.200/saml-sp/SAML2/POST",
551            issuer="https://10.120.20.200/saml-sp/SAML2/POST",
552            signing_kp=create_test_cert(),
553        )
554        parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
555        self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
556        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):
558    def test_authn_context_class_ref_mapping(self):
559        """Test custom authn_context_class_ref"""
560        authn_context_class_ref = generate_id()
561        mapping = SAMLPropertyMapping.objects.create(
562            name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
563        )
564        self.provider.authn_context_class_ref_mapping = mapping
565        self.provider.save()
566        user = create_test_admin_user()
567        http_request = self.request_factory.get("/", user=user)
568
569        # First create an AuthNRequest
570        request_proc = RequestProcessor(self.source, http_request, "test_state")
571        request = request_proc.build_auth_n()
572
573        # To get an assertion we need a parsed request (parsed by provider)
574        parsed_request = AuthNRequestParser(self.provider).parse(
575            b64encode(request.encode()).decode(), "test_state"
576        )
577        # Now create a response and convert it to string (provider)
578        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
579        response = response_proc.build_response()
580        self.assertIn(user.username, response)
581        self.assertIn(authn_context_class_ref, response)

Test custom authn_context_class_ref

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

Test custom authn_context_class_ref (invalid)

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

Test full SAML Request/Response flow, fully signed

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

Test full SAML Request/Response flow, fully signed

def test_idp_initiated(self):
662    def test_idp_initiated(self):
663        """Test IDP-initiated login"""
664        self.provider.default_relay_state = generate_id()
665        request = AuthNRequestParser(self.provider).idp_initiated()
666        self.assertEqual(request.id, None)
667        self.assertEqual(request.relay_state, self.provider.default_relay_state)

Test IDP-initiated login