From a6fb48238e1315ef0269f2fa66807af18de13230 Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Wed, 16 Sep 2026 15:15:19 +0200 Subject: [PATCH] Decrypt the EncryptedID of a LogoutRequest A Shibboleth IdP encrypts the NameID of a front-channel LogoutRequest by default whenever the service provider publishes an encryption key in its metadata, so this is what a service provider in an identity federation receives. LogoutRequest left the EncryptedID alone: the parsed message carried no NameID, Saml2Client.handle_logout_request compared None with the NameID stored at login and answered UnknownPrincipal, "Wrong user", for the very user the IdP asked about. The IdP could not finish the logout. LogoutRequest now decrypts the EncryptedID with the configured encryption keys when it loads the message, the same way AuthnResponse handles an encrypted NameID in an assertion. If no key can decrypt it, the NameID stays empty and the behaviour is as before. Fixes #536 --- src/saml2/request.py | 51 +++++++++++++++++++++++++++++++++++++++++ tests/test_51_client.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/saml2/request.py b/src/saml2/request.py index e013e058c..2f53109d9 100644 --- a/src/saml2/request.py +++ b/src/saml2/request.py @@ -6,6 +6,8 @@ from saml2.response import IncorrectlySigned from saml2.s_utils import OtherError from saml2.s_utils import VersionMismatch +from saml2.saml import name_id_from_string +from saml2.sigver import DecryptError from saml2.sigver import verify_redirect_signature from saml2.validate import NotValid from saml2.validate import valid_instance @@ -195,6 +197,55 @@ def __init__(self, sec_context, receiver_addrs, attribute_converters=None, times Request.__init__(self, sec_context, receiver_addrs, attribute_converters, timeslack) self.signature_check = self.sec.correctly_signed_logout_request + def _loads( + self, + xmldata, + binding=None, + origdoc=None, + must=None, + only_valid_cert=False, + relay_state=None, + sigalg=None, + signature=None, + ): + super()._loads( + xmldata, + binding, + origdoc, + must, + only_valid_cert=only_valid_cert, + relay_state=relay_state, + sigalg=sigalg, + signature=signature, + ) + + if self.message.name_id is None and self.message.encrypted_id is not None: + self.message.name_id = self._decrypt_name_id(self.message.encrypted_id) + + return self + + def _decrypt_name_id(self, encrypted_id): + """Decrypt the EncryptedID of the request with our encryption keys. + + A Shibboleth IdP encrypts the NameID of a LogoutRequest whenever the + service provider publishes an encryption key in its metadata, so this + is what a service provider in an identity federation receives. Without + the NameID the request cannot be matched to a session and the logout + is answered with "Wrong user". + + :param encrypted_id: The EncryptedID element of the request + :return: The decrypted NameID, or None if no key could decrypt it + """ + try: + name_id_str = self.sec.decrypt_keys(encrypted_id.encrypted_data.to_string()) + except DecryptError as exc: + logger.warning("Could not decrypt the EncryptedID of the logout request: %s", exc) + return None + + name_id = name_id_from_string(name_id_str) + logger.debug("Decrypted NameID of the logout request: %s", name_id) + return name_id + @property def issuer(self): return self.message.issuer diff --git a/tests/test_51_client.py b/tests/test_51_client.py index e1eb85ecb..e852fd0de 100644 --- a/tests/test_51_client.py +++ b/tests/test_51_client.py @@ -21,6 +21,7 @@ from saml2 import saml from saml2 import samlp from saml2 import sigver +from saml2 import xmlenc from saml2.argtree import add_path from saml2.assertion import Assertion from saml2.authn_context import INTERNETPROTOCOLPASSWORD @@ -442,6 +443,55 @@ def test_logout_response(self): # Signature not found assert xml.decode("UTF-8").find(r"Signature") < 0 + def test_logout_request_with_encrypted_name_id(self): + # A Shibboleth IdP encrypts the NameID of a LogoutRequest whenever the + # SP publishes an encryption key, so this is what an SP in an identity + # federation receives. Without decryption the SP answers "Wrong user". + req_id, req = self.server.create_logout_request( + "http://localhost:8088/slo", + "urn:mace:example.com:saml:roland:sp", + name_id=nid, + reason="Tired", + expire=in_a_while(minutes=15), + session_indexes=["_foo"], + ) + + template = pre_encryption_part(msg_enc="http://www.w3.org/2001/04/xmlenc#aes128-cbc") + encrypted = self.server.sec.crypto.encrypt_assertion( + str(req.name_id), + full_path("test_1.crt"), + template, + key_type="aes-128", + node_xpath="/*[local-name()='NameID']", + ) + req.encrypted_id = saml.EncryptedID(encrypted_data=xmlenc.encrypted_data_from_string(rm_xmltag(encrypted))) + req.name_id = None + assert "EncryptedID" in str(req) + assert "NameID" not in str(req).replace("EncryptedID", "") + + info = self.client.apply_binding(BINDING_HTTP_POST, req, destination="", relay_state="relay2") + _dic_info = unpack_form(info["data"], "SAMLRequest") + samlreq = _dic_info["SAMLRequest"] + + parsed = self.client.parse_logout_request(samlreq, BINDING_HTTP_POST) + assert parsed.message.name_id == nid + assert parsed.subject_id() == nid + + # the session the SP stored at login, which the logout has to find + self.client.users.add_information_about_person( + { + "name_id": nid, + "issuer": self.server.config.entityid, + "not_on_or_after": in_a_while(minutes=15), + "ava": {}, + } + ) + resphttp = self.client.handle_logout_request(samlreq, nid, BINDING_HTTP_POST) + _dic = unpack_form(resphttp["data"], "SAMLResponse") + xml = b64decode(_dic["SAMLResponse"].encode("UTF-8")).decode("UTF-8") + assert "urn:oasis:names:tc:SAML:2.0:status:Success" in xml + assert "Wrong user" not in xml + def test_create_logout_request(self): req_id, req = self.client.create_logout_request( "http://localhost:8088/slo",