Skip to content

Commit 56361c2

Browse files
authored
providers/saml: fix signing order for encrypted saml responses (#19620)
providers/saml: fix signature verification order for encrypted saml responses
1 parent 9721c4f commit 56361c2

File tree

2 files changed

+216
-7
lines changed

2 files changed

+216
-7
lines changed

authentik/providers/saml/processors/assertion.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,16 @@ def _encrypt(self, element: _Element, parent: _Element):
434434
def build_response(self) -> str:
435435
"""Build string XML Response and sign if signing is enabled."""
436436
root_response = self.get_response()
437-
if self.provider.signing_kp:
438-
if self.provider.sign_assertion:
439-
assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
440-
self._sign(assertion)
441-
if self.provider.sign_response:
442-
response = root_response.xpath("//samlp:Response", namespaces=NS_MAP)[0]
443-
self._sign(response)
437+
# Sign assertion first (before encryption)
438+
if self.provider.signing_kp and self.provider.sign_assertion:
439+
assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
440+
self._sign(assertion)
441+
# Encrypt assertion (this replaces Assertion with EncryptedAssertion)
444442
if self.provider.encryption_kp:
445443
assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
446444
self._encrypt(assertion, root_response)
445+
# Sign response AFTER encryption so signature covers the encrypted content
446+
if self.provider.signing_kp and self.provider.sign_response:
447+
response = root_response.xpath("//samlp:Response", namespaces=NS_MAP)[0]
448+
self._sign(response)
447449
return etree.tostring(root_response).decode("utf-8") # nosec

authentik/providers/saml/tests/test_auth_n_request.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,213 @@ def test_request_encrypt_cert_only(self):
194194
response_parser = ResponseProcessor(self.source, http_request)
195195
response_parser.parse()
196196

197+
def test_request_sign_response_and_encrypt(self):
198+
"""Test SAML with sign_response enabled AND encryption.
199+
200+
This tests the fix for signature invalidation when encryption is enabled.
201+
The response must be signed AFTER encryption, not before, because encryption
202+
replaces the Assertion with EncryptedAssertion which changes the response content.
203+
"""
204+
self.provider.sign_response = True
205+
self.provider.sign_assertion = False
206+
self.provider.encryption_kp = self.cert
207+
self.provider.save()
208+
self.source.encryption_kp = self.cert
209+
self.source.signed_response = True
210+
self.source.signed_assertion = False # Only response is signed, not assertion
211+
self.source.save()
212+
http_request = self.request_factory.get("/", user=get_anonymous_user())
213+
214+
# First create an AuthNRequest
215+
request_proc = RequestProcessor(self.source, http_request, "test_state")
216+
request = request_proc.build_auth_n()
217+
218+
# To get an assertion we need a parsed request (parsed by provider)
219+
parsed_request = AuthNRequestParser(self.provider).parse(
220+
b64encode(request.encode()).decode(), "test_state"
221+
)
222+
# Now create a response and convert it to string (provider)
223+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
224+
response = response_proc.build_response()
225+
226+
# Verify the response contains EncryptedAssertion and a signature
227+
response_xml = fromstring(response)
228+
self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
229+
self.assertEqual(
230+
len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
231+
)
232+
233+
# Now parse the response (source) - this will verify the signature and decrypt
234+
http_request.POST = QueryDict(mutable=True)
235+
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
236+
237+
response_parser = ResponseProcessor(self.source, http_request)
238+
response_parser.parse()
239+
240+
def test_request_sign_assertion_and_encrypt(self):
241+
"""Test SAML with sign_assertion enabled AND encryption.
242+
243+
The assertion signature should be inside the encrypted content and
244+
remain valid after decryption.
245+
"""
246+
self.provider.sign_response = False
247+
self.provider.sign_assertion = True
248+
self.provider.encryption_kp = self.cert
249+
self.provider.save()
250+
self.source.encryption_kp = self.cert
251+
self.source.signed_assertion = True
252+
self.source.save()
253+
http_request = self.request_factory.get("/", user=get_anonymous_user())
254+
255+
# First create an AuthNRequest
256+
request_proc = RequestProcessor(self.source, http_request, "test_state")
257+
request = request_proc.build_auth_n()
258+
259+
# To get an assertion we need a parsed request (parsed by provider)
260+
parsed_request = AuthNRequestParser(self.provider).parse(
261+
b64encode(request.encode()).decode(), "test_state"
262+
)
263+
# Now create a response and convert it to string (provider)
264+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
265+
response = response_proc.build_response()
266+
267+
# Verify the response contains EncryptedAssertion
268+
response_xml = fromstring(response)
269+
self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
270+
271+
# Now parse the response (source) - this will decrypt and verify assertion signature
272+
http_request.POST = QueryDict(mutable=True)
273+
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
274+
275+
response_parser = ResponseProcessor(self.source, http_request)
276+
response_parser.parse()
277+
278+
def test_request_sign_both_and_encrypt(self):
279+
"""Test SAML with both sign_assertion and sign_response enabled AND encryption.
280+
281+
This is the most complex scenario: assertion is signed, then encrypted,
282+
then the response is signed. Both signatures should be valid.
283+
"""
284+
self.provider.sign_response = True
285+
self.provider.sign_assertion = True
286+
self.provider.encryption_kp = self.cert
287+
self.provider.save()
288+
self.source.encryption_kp = self.cert
289+
self.source.signed_assertion = True
290+
self.source.signed_response = True
291+
self.source.save()
292+
http_request = self.request_factory.get("/", user=get_anonymous_user())
293+
294+
# First create an AuthNRequest
295+
request_proc = RequestProcessor(self.source, http_request, "test_state")
296+
request = request_proc.build_auth_n()
297+
298+
# To get an assertion we need a parsed request (parsed by provider)
299+
parsed_request = AuthNRequestParser(self.provider).parse(
300+
b64encode(request.encode()).decode(), "test_state"
301+
)
302+
# Now create a response and convert it to string (provider)
303+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
304+
response = response_proc.build_response()
305+
306+
# Verify the response contains EncryptedAssertion and response signature
307+
response_xml = fromstring(response)
308+
self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
309+
self.assertEqual(
310+
len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
311+
)
312+
313+
# Now parse the response (source) - this will verify response signature,
314+
# decrypt, then verify assertion signature
315+
http_request.POST = QueryDict(mutable=True)
316+
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
317+
318+
response_parser = ResponseProcessor(self.source, http_request)
319+
response_parser.parse()
320+
321+
def test_encrypted_assertion_namespace_preservation(self):
322+
"""Test that encrypted assertions include namespace declarations.
323+
324+
When an assertion is encrypted, the resulting decrypted XML must include
325+
the necessary namespace declarations (xmlns:saml) since it's now a standalone
326+
document fragment, no longer inheriting namespaces from the parent Response.
327+
"""
328+
self.provider.encryption_kp = self.cert
329+
self.provider.save()
330+
self.source.encryption_kp = self.cert
331+
self.source.save()
332+
http_request = self.request_factory.get("/", user=get_anonymous_user())
333+
334+
# First create an AuthNRequest
335+
request_proc = RequestProcessor(self.source, http_request, "test_state")
336+
request = request_proc.build_auth_n()
337+
338+
# To get an assertion we need a parsed request (parsed by provider)
339+
parsed_request = AuthNRequestParser(self.provider).parse(
340+
b64encode(request.encode()).decode(), "test_state"
341+
)
342+
# Now create a response and convert it to string (provider)
343+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
344+
response = response_proc.build_response()
345+
346+
# Parse the encrypted response
347+
response_xml = fromstring(response)
348+
encrypted_assertion = response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)[0]
349+
encrypted_data = encrypted_assertion.xpath("//xenc:EncryptedData", namespaces=NS_MAP)[0]
350+
351+
# Decrypt the assertion manually to verify namespace is present
352+
import xmlsec
353+
354+
manager = xmlsec.KeysManager()
355+
key = xmlsec.Key.from_memory(self.cert.key_data, xmlsec.constants.KeyDataFormatPem, None)
356+
manager.add_key(key)
357+
enc_ctx = xmlsec.EncryptionContext(manager)
358+
decrypted = enc_ctx.decrypt(encrypted_data)
359+
360+
# The decrypted assertion should have xmlns:saml namespace declaration
361+
decrypted_str = etree.tostring(decrypted).decode()
362+
self.assertIn("xmlns:saml", decrypted_str)
363+
364+
# Also verify full round-trip works (source can parse it)
365+
http_request.POST = QueryDict(mutable=True)
366+
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
367+
368+
response_parser = ResponseProcessor(self.source, http_request)
369+
response_parser.parse()
370+
371+
def test_encrypted_response_schema_validation(self):
372+
"""Test that encrypted SAML responses validate against the SAML schema.
373+
374+
The response with EncryptedAssertion must be valid per saml-schema-protocol-2.0.xsd.
375+
This ensures we don't have invalid elements like EncryptedData inside Assertion.
376+
"""
377+
self.provider.encryption_kp = self.cert
378+
self.provider.save()
379+
http_request = self.request_factory.get("/", user=get_anonymous_user())
380+
381+
# First create an AuthNRequest
382+
request_proc = RequestProcessor(self.source, http_request, "test_state")
383+
request = request_proc.build_auth_n()
384+
385+
# To get an assertion we need a parsed request (parsed by provider)
386+
parsed_request = AuthNRequestParser(self.provider).parse(
387+
b64encode(request.encode()).decode(), "test_state"
388+
)
389+
# Now create a response and convert it to string (provider)
390+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
391+
response = response_proc.build_response()
392+
393+
# Validate against SAML schema
394+
schema = etree.XMLSchema(
395+
etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser()) # nosec
396+
)
397+
self.assertTrue(schema.validate(lxml_from_string(response)))
398+
399+
# Verify structure: should have EncryptedAssertion, not Assertion with EncryptedData inside
400+
response_xml = fromstring(response)
401+
self.assertEqual(len(response_xml.xpath("//saml:EncryptedAssertion", namespaces=NS_MAP)), 1)
402+
self.assertEqual(len(response_xml.xpath("//saml:Assertion", namespaces=NS_MAP)), 0)
403+
197404
def test_request_signed(self):
198405
"""Test full SAML Request/Response flow, fully signed"""
199406
http_request = self.request_factory.get("/", user=get_anonymous_user())

0 commit comments

Comments
 (0)