Ctrl K
ComunidadGuías…

tests/saml/test_saml_config.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from allauth.socialaccount.models import SocialApp
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.http import Http404
from django.test import RequestFactory, TestCase
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.constants import OneLogin_Saml2_Constants

from saml_auth.custom import utils
from saml_auth.custom.provider import CustomSAMLProvider
from saml_auth.models import SAMLConfiguration

EMAIL_NAMEID = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"


def make_app(client_id, settings=None):
    app = SocialApp.objects.create(provider="saml", provider_id=f"{client_id}-id", name=f"{client_id} name", client_id=client_id, settings=settings or {})
    app.sites.add(Site.objects.get_current())
    return app


def make_configuration(social_app, **overrides):
    fields = {
        "social_app": social_app,
        "sso_url": "https://idp.example.org/sso",
        "slo_url": "https://idp.example.org/slo",
        "sp_metadata_url": "https://sp.example.org/saml/metadata",
        "idp_id": "https://idp.example.org/entity",
        "idp_cert": "IDPCERT",
        "uid": "urn:oid:uid",
        "name": "displayName",
        "email": "mail",
        "groups": "isMemberOf",
        "first_name": "gn",
        "last_name": "sn",
        "role": "affiliation",
    }
    fields.update(overrides)
    return SAMLConfiguration.objects.create(**fields)


def saml_data(attributes, friendly=None, nameid="nameid@example.org", nameid_format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"):
    data = MagicMock()
    data.get_attributes.return_value = attributes
    if friendly is None:
        del data.get_friendlyname_attributes
    else:
        data.get_friendlyname_attributes.return_value = friendly
    data.get_nameid.return_value = nameid
    data.get_nameid_format.return_value = nameid_format
    return data


class SAMLConfigurationModelTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.app = make_app("model-org")

    def test_str_names_app_and_idp(self):
        configuration = make_configuration(self.app)
        self.assertEqual(str(configuration), "SAML Config for model-org name - https://idp.example.org/entity")

    def test_second_configuration_for_same_app_is_rejected(self):
        make_configuration(self.app)
        duplicate = SAMLConfiguration(social_app=self.app, idp_id="https://other.example.org", sp_cert="", sp_private_key="")
        with self.assertRaises(ValidationError) as ctx:
            duplicate.clean()
        self.assertIn("social_app", ctx.exception.message_dict)

    def test_existing_configuration_can_be_cleaned_again(self):
        configuration = make_configuration(self.app)
        configuration.clean()

    def test_sp_certificate_and_key_must_come_together(self):
        with self.assertRaises(ValidationError) as ctx:
            SAMLConfiguration(social_app=self.app, sp_cert="CERT").clean()
        self.assertIn("sp_private_key", ctx.exception.message_dict)
        with self.assertRaises(ValidationError) as ctx:
            SAMLConfiguration(social_app=self.app, sp_private_key="KEY").clean()
        self.assertIn("sp_cert", ctx.exception.message_dict)

    def test_provider_settings_without_sp_keys(self):
        provider_settings = make_configuration(self.app, verified_email=True).saml_provider_settings
        self.assertEqual(provider_settings["sp"], {"entity_id": "https://sp.example.org/saml/metadata"})
        self.assertEqual(
            provider_settings["idp"],
            {"slo_url": "https://idp.example.org/slo", "sso_url": "https://idp.example.org/sso", "x509cert": "IDPCERT", "entity_id": "https://idp.example.org/entity"},
        )
        self.assertEqual(provider_settings["attribute_mapping"]["uid"], "urn:oid:uid")
        self.assertEqual(provider_settings["attribute_mapping"]["groups"], "isMemberOf")
        self.assertTrue(provider_settings["email_verified"])
        self.assertFalse(provider_settings["email_authentication"])

    def test_provider_settings_include_sp_keys_when_set(self):
        provider_settings = make_configuration(self.app, sp_cert="SPCERT", sp_private_key="SPKEY").saml_provider_settings
        self.assertEqual(provider_settings["sp"]["x509cert"], "SPCERT")
        self.assertEqual(provider_settings["sp"]["private_key"], "SPKEY")


class SAMLUtilsTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.app = make_app("utils-org", settings={"idp": {"entity_id": "https://settings-idp.example.org", "x509cert": "C", "sso_url": "https://settings-idp.example.org/sso"}})

    def setUp(self):
        self.factory = RequestFactory()

    def test_get_app_or_404_finds_app_by_client_id(self):
        self.assertEqual(utils.get_app_or_404(self.factory.get("/"), "utils-org"), self.app)

    def test_get_app_or_404_raises_for_unknown_client_id(self):
        with self.assertRaises(Http404):
            utils.get_app_or_404(self.factory.get("/"), "no-such-org")

    def test_prepare_django_request_describes_request(self):
        request = self.factory.post("/accounts/saml/x/acs/?a=1", {"SAMLResponse": "abc"}, HTTP_HOST="media.example.org", secure=True)
        prepared = utils.prepare_django_request(request)
        self.assertEqual(prepared["https"], "on")
        self.assertEqual(prepared["http_host"], "media.example.org")
        self.assertEqual(prepared["script_name"], "/accounts/saml/x/acs/")
        self.assertEqual(prepared["get_data"]["a"], "1")
        self.assertEqual(prepared["post_data"]["SAMLResponse"], "abc")
        self.assertEqual(utils.prepare_django_request(self.factory.get("/", HTTP_HOST="testserver"))["https"], "off")

    def test_sp_config_falls_back_to_metadata_url_as_entity_id(self):
        sp_config = utils.build_sp_config(self.factory.get("/"), {}, "utils-org")
        self.assertTrue(sp_config["entityId"].startswith("http://testserver/"))
        self.assertTrue(sp_config["entityId"].endswith("/saml/utils-org/metadata/"))
        self.assertTrue(sp_config["assertionConsumerService"]["url"].endswith("/saml/utils-org/acs/"))
        self.assertEqual(sp_config["assertionConsumerService"]["binding"], OneLogin_Saml2_Constants.BINDING_HTTP_POST)
        self.assertTrue(sp_config["singleLogoutService"]["url"].endswith("/saml/utils-org/sls/"))
        self.assertNotIn("x509cert", sp_config)
        self.assertNotIn("privateKey", sp_config)
        self.assertNotIn("NameIDFormat", sp_config)

    def test_sp_config_uses_explicit_entity_id_keys_and_name_id_format(self):
        provider_config = {"sp": {"entity_id": "urn:sp", "x509cert": "CERT", "private_key": "KEY"}, "advanced": {"name_id_format": "urn:fmt"}}
        sp_config = utils.build_sp_config(self.factory.get("/"), provider_config, "utils-org")
        self.assertEqual(sp_config["entityId"], "urn:sp")
        self.assertEqual(sp_config["x509cert"], "CERT")
        self.assertEqual(sp_config["privateKey"], "KEY")
        self.assertEqual(sp_config["NameIDFormat"], "urn:fmt")

    def test_saml_config_requires_idp(self):
        with self.assertRaises(ImproperlyConfigured):
            utils.build_saml_config(self.factory.get("/"), {}, "utils-org")

    def test_saml_config_with_inline_idp_and_defaults(self):
        provider_config = {"idp": {"entity_id": "urn:idp", "x509cert": "CERT", "sso_url": "https://idp/sso"}}
        config = utils.build_saml_config(self.factory.get("/"), provider_config, "utils-org")
        self.assertTrue(config["strict"])
        self.assertEqual(config["idp"], {"entityId": "urn:idp", "x509cert": "CERT", "singleSignOnService": {"url": "https://idp/sso"}})
        self.assertFalse(config["security"]["authnRequestsSigned"])
        self.assertTrue(config["security"]["rejectDeprecatedAlgorithm"])
        self.assertEqual(config["security"]["signatureAlgorithm"], OneLogin_Saml2_Constants.RSA_SHA256)
        self.assertNotIn("contactPerson", config)
        self.assertNotIn("organization", config)
        self.assertIn("entityId", config["sp"])

    def test_saml_config_honours_advanced_slo_contact_and_organization(self):
        provider_config = {
            "idp": {"entity_id": "urn:idp", "x509cert": "CERT", "sso_url": "https://idp/sso", "slo_url": "https://idp/slo"},
            "advanced": {"strict": False, "authn_request_signed": True, "want_assertion_signed": True},
            "contact_person": {"technical": {"givenName": "Ops", "emailAddress": "ops@example.org"}},
            "organization": {"en-US": {"name": "Uni", "displayname": "Uni", "url": "https://uni.example.org"}},
        }
        config = utils.build_saml_config(self.factory.get("/"), provider_config, "utils-org")
        self.assertFalse(config["strict"])
        self.assertTrue(config["security"]["authnRequestsSigned"])
        self.assertTrue(config["security"]["wantAssertionsSigned"])
        self.assertEqual(config["idp"]["singleLogoutService"], {"url": "https://idp/slo"})
        self.assertEqual(config["contactPerson"], provider_config["contact_person"])
        self.assertEqual(config["organization"], provider_config["organization"])

    def test_saml_config_with_metadata_url_uses_remote_metadata(self):
        remote = {"idp": {"entityId": "urn:remote"}}
        provider_config = {"idp": {"metadata_url": "https://idp.example.org/metadata", "entity_id": "urn:remote"}}
        with patch.object(utils.OneLogin_Saml2_IdPMetadataParser, "parse_remote", return_value=remote) as parse_remote:
            config = utils.build_saml_config(self.factory.get("/"), provider_config, "utils-org")
        self.assertEqual(config["idp"], {"entityId": "urn:remote"})
        parse_remote.assert_called_once_with("https://idp.example.org/metadata", entity_id="urn:remote", timeout=10)

    def test_remote_metadata_is_cached(self):
        idp_config = {"metadata_url": "https://idp.example.org/cached", "entity_id": "urn:cached", "metadata_request_timeout": 3}
        with patch.object(utils.OneLogin_Saml2_IdPMetadataParser, "parse_remote", return_value={"idp": {}}) as parse_remote:
            first = utils.fetch_metadata_url_config(idp_config)
            second = utils.fetch_metadata_url_config(idp_config)
        self.assertEqual(first, second)
        parse_remote.assert_called_once_with("https://idp.example.org/cached", entity_id="urn:cached", timeout=3)

    def test_relay_state_round_trip(self):
        self.assertEqual(utils.encode_relay_state("abc def"), "state=abc+def")
        self.assertEqual(utils.decode_relay_state("/media/view"), "/media/view")
        self.assertEqual(utils.decode_relay_state("https://sp.example.org/x"), "https://sp.example.org/x")
        self.assertIsNone(utils.decode_relay_state("opaque-state"))
        self.assertIsNone(utils.decode_relay_state(""))
        self.assertIsNone(utils.decode_relay_state(None))

    def test_build_auth_prefers_custom_configuration(self):
        configuration_app = make_app("auth-org")
        make_configuration(configuration_app)
        provider = SimpleNamespace(app=configuration_app)
        auth = utils.build_auth(self.factory.get("/", HTTP_HOST="sp.example.org"), provider)
        self.assertIsInstance(auth, OneLogin_Saml2_Auth)
        idp = auth.get_settings().get_idp_data()
        self.assertEqual(idp["entityId"], "https://idp.example.org/entity")
        self.assertEqual(auth.get_settings().get_sp_data()["entityId"], "https://sp.example.org/saml/metadata")

    def test_build_auth_falls_back_to_app_settings(self):
        auth = utils.build_auth(self.factory.get("/", HTTP_HOST="sp.example.org"), SimpleNamespace(app=self.app))
        self.assertEqual(auth.get_settings().get_idp_data()["entityId"], "https://settings-idp.example.org")


class CustomSAMLProviderTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.configured_app = make_app("provider-org")
        make_configuration(cls.configured_app, first_name=None, last_name="")
        cls.settings_app = make_app("provider-settings-org", settings={"attribute_mapping": {"uid": ["urn:uid", "uid"], "email": "mail"}, "email_verified": "yes"})

    def provider(self, app):
        return CustomSAMLProvider(request=None, app=app)

    def test_configuration_mapping_extracts_attributes(self):
        data = saml_data({"urn:oid:uid": ["jdoe"], "displayName": ["Jane Doe"], "mail": ["jane@example.org"], "isMemberOf": ["physics", "maths"], "affiliation": ["faculty"]}, friendly={})
        attributes = self.provider(self.configured_app)._extract(data)
        self.assertEqual(attributes["uid"], "jdoe")
        self.assertEqual(attributes["username"], "jdoe")
        self.assertEqual(attributes["name"], "Jane Doe")
        self.assertEqual(attributes["email"], "jane@example.org")
        self.assertEqual(attributes["groups"], "physics,maths")
        self.assertEqual(attributes["role"], "faculty")
        self.assertFalse(attributes["email_verified"])
        self.assertNotIn("first_name", attributes)
        self.assertNotIn("last_name", attributes)

    def test_friendly_name_is_used_when_name_lookup_misses(self):
        data = saml_data({"urn:oid:uid": ["jdoe"]}, friendly={"mail": ["friendly@example.org"], "displayName": []})
        attributes = self.provider(self.configured_app)._extract(data)
        self.assertEqual(attributes["email"], "friendly@example.org")
        self.assertNotIn("name", attributes)

    def test_data_without_friendly_names_is_supported(self):
        attributes = self.provider(self.configured_app)._extract(saml_data({"mail": ["only@example.org"]}))
        self.assertEqual(attributes["email"], "only@example.org")
        self.assertNotIn("username", attributes)

    def test_email_falls_back_to_email_nameid(self):
        data = saml_data({"urn:oid:uid": ["jdoe"]}, friendly={}, nameid="nameid@example.org", nameid_format=EMAIL_NAMEID)
        self.assertEqual(self.provider(self.configured_app)._extract(data)["email"], "nameid@example.org")

    def test_persistent_nameid_is_not_used_as_email(self):
        data = saml_data({"urn:oid:uid": ["jdoe"]}, friendly={})
        self.assertNotIn("email", self.provider(self.configured_app)._extract(data))

    def test_app_settings_are_used_without_configuration(self):
        data = saml_data({"uid": ["fallback"], "mail": ["s@example.org"]}, friendly={})
        attributes = self.provider(self.settings_app)._extract(data)
        self.assertEqual(attributes["uid"], "fallback")
        self.assertEqual(attributes["email"], "s@example.org")
        self.assertTrue(attributes["email_verified"])

    def test_use_nameid_for_email_setting(self):
        app = make_app("provider-nameid-org", settings={"attribute_mapping": {"uid": "uid"}, "use_nameid_for_email": True, "email_verified": True})
        attributes = self.provider(app)._extract(saml_data({"uid": ["u"]}, friendly={}, nameid="n@example.org"))
        self.assertEqual(attributes["email"], "n@example.org")
        self.assertTrue(attributes["email_verified"])

    def test_extract_uid_and_common_fields_use_custom_mapping(self):
        provider = self.provider(self.configured_app)
        data = saml_data({"urn:oid:uid": ["jdoe"], "mail": ["jane@example.org"]}, friendly={})
        self.assertEqual(provider.extract_uid(data), "jdoe")
        common = provider.extract_common_fields(data)
        self.assertNotIn("uid", common)
        self.assertEqual(common["email"], "jane@example.org")

    def test_redirect_sends_user_to_idp_and_stashes_request_id(self):
        request = RequestFactory().get("/")
        request.session = SessionStore()
        auth = MagicMock()
        auth.login.return_value = "https://idp.example.org/sso?SAMLRequest=xyz"
        auth.get_last_request_id.return_value = "ONELOGIN_123"
        provider = self.provider(self.configured_app)
        with patch("saml_auth.custom.provider.build_auth", return_value=auth) as build_auth, patch.object(CustomSAMLProvider, "stash_redirect_state") as stash:
            response = provider.redirect(request, "login", next_url="/next")
        build_auth.assert_called_once_with(request, provider)
        auth.login.assert_called_once_with(return_to="")
        self.assertEqual(response.status_code, 302)
        self.assertEqual(response["Location"], "https://idp.example.org/sso?SAMLRequest=xyz")
        stash.assert_called_once_with(request, "login", "/next", None, state_id="ONELOGIN_123")