From 72f87d4f32b23e0a03a65690809ad1c03ba49b31 Mon Sep 17 00:00:00 2001 From: Gabrio Mauri Date: Fri, 30 Aug 2024 11:39:34 +0200 Subject: [PATCH 1/5] feat(trigger): add custom get metadata hook - use custom trigger to override metadata retrieval - update README - update tests with new functions --- README.md | 1 + django_saml2_auth/saml.py | 16 +++++++- django_saml2_auth/tests/metadata2.xml | 27 +++++++++++++ django_saml2_auth/tests/test_saml.py | 57 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 django_saml2_auth/tests/metadata2.xml diff --git a/README.md b/README.md index 98a2cc0..96402ea 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ Some of the following settings are related to how this module operates. The rest | **TRIGGER.BEFORE\_LOGIN** | A method to be called when an existing user logs in. This method will be called before the user is logged in and after the SAML2 identity provider returns user attributes. This method should accept ONE parameter of user dict. | `str` | `None` | `my_app.models.users.before_login` | | **TRIGGER.AFTER\_LOGIN** | A method to be called when an existing user logs in. This method will be called after the user is logged in and after the SAML2 identity provider returns user attributes. This method should accept TWO parameters of session and user dict. | `str` | `None` | `my_app.models.users.after_login` | | **TRIGGER.GET\_METADATA\_AUTO\_CONF\_URLS** | A hook function that returns a list of metadata Autoconf URLs. This can override the `METADATA_AUTO_CONF_URL` to enumerate all existing metadata autoconf URLs. | `str` | `None` | `my_app.models.users.get_metadata_autoconf_urls` | +| **TRIGGER.GET\_CUSTOM\_METADATA** | A hook function to retrieve the SAML2 metadata with a custom method. This method should return a SAML metadata object as dictionary (Mapping[str, Any]). If added, it overrides all other configuration to retrieve metadata. An example can be found in `tests.test_saml.get_custom_metadata_example`. This method accepts the same three parameters of the django_saml2_auth.saml.get_metadata function: `user_id`, `domain`, `saml_response`. | `str` | `None`, `None`, `None` | `my_app.utils.get_custom_saml_metadata` | | **TRIGGER.CUSTOM\_DECODE\_JWT** | A hook function to decode the user JWT. This method will be called instead of the `decode_jwt_token` default function and should return the user_model.USERNAME_FIELD. This method accepts one parameter: `token`. | `str` | `None` | `my_app.models.users.decode_custom_token` | | **TRIGGER.CUSTOM\_CREATE\_JWT** | A hook function to create a custom JWT for the user. This method will be called instead of the `create_jwt_token` default function and should return the token. This method accepts one parameter: `user`. | `str` | `None` | `my_app.models.users.create_custom_token` | | **TRIGGER.CUSTOM\_TOKEN\_QUERY** | A hook function to create a custom query params with the JWT for the user. This method will be called after `CUSTOM_CREATE_JWT` to populate a query and attach it to a URL; should return the query params containing the token (e.g., `?token=encoded.jwt.token`). This method accepts one parameter: `token`. | `str` | `None` | `my_app.models.users.get_custom_token_query` | diff --git a/django_saml2_auth/saml.py b/django_saml2_auth/saml.py index 8085bee..1ad7b65 100644 --- a/django_saml2_auth/saml.py +++ b/django_saml2_auth/saml.py @@ -86,7 +86,11 @@ def validate_metadata_url(url: str) -> bool: return True -def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]: +def get_metadata( + user_id: Optional[str] = None, + domain: Optional[str] = None, + saml_response: Optional[str] = None, +) -> Mapping[str, Any]: """Returns metadata information, either by running the GET_METADATA_AUTO_CONF_URLS hook function if available, or by checking and returning a local file path or the METADATA_AUTO_CONF_URL. URLs are always validated and invalid URLs will be either filtered or raise a SAMLAuthError @@ -96,6 +100,8 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]: user_id (str, optional): If passed, it will be further processed by the GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding to the given user identifier, either email or username. Defaults to None. + domain (str, optional): Domain name to get SAML config for + saml_response (str or None): decoded XML SAML response. Raises: SAMLAuthError: No metadata URL associated with the given user identifier. @@ -105,6 +111,12 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]: Mapping[str, Any]: Returns a SAML metadata object as dictionary """ saml2_auth_settings = settings.SAML2_AUTH + + # If there is a custom trigger, metadata is retrieved directly within the trigger + get_custom_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_CUSTOM_METADATA") + if get_custom_metadata_trigger: + return run_hook(get_custom_metadata_trigger, user_id, domain, saml_response) + get_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_METADATA_AUTO_CONF_URLS") if get_metadata_trigger: metadata_urls = run_hook(get_metadata_trigger, user_id) # type: ignore @@ -177,7 +189,7 @@ def get_saml_client( if get_user_id_from_saml_response and saml_response: user_id = run_hook(get_user_id_from_saml_response, saml_response, user_id) # type: ignore - metadata = get_metadata(user_id) + metadata = get_metadata(user_id, domain, saml_response) if metadata and ( ("local" in metadata and not metadata["local"]) or ("remote" in metadata and not metadata["remote"]) diff --git a/django_saml2_auth/tests/metadata2.xml b/django_saml2_auth/tests/metadata2.xml new file mode 100644 index 0000000..d41cb3a --- /dev/null +++ b/django_saml2_auth/tests/metadata2.xml @@ -0,0 +1,27 @@ + + + + + + + + https://testserver2.com/category/self-certified + + + + + + ... + ... + https://testserver2.com/ + + + SAML Technical Support + mailto:technical-support@example.info + + \ No newline at end of file diff --git a/django_saml2_auth/tests/test_saml.py b/django_saml2_auth/tests/test_saml.py index 93c2586..4eb7872 100644 --- a/django_saml2_auth/tests/test_saml.py +++ b/django_saml2_auth/tests/test_saml.py @@ -21,6 +21,7 @@ get_saml_client, validate_metadata_url, ) +from django_saml2_auth.utils import run_hook from django_saml2_auth.views import acs from pytest_django.fixtures import SettingsWrapper from saml2.client import Saml2Client @@ -88,6 +89,11 @@ mailto:technical-support@example.info """ +DOMAIN_PATH_MAP = { + "example.org": "django_saml2_auth/tests/metadata.xml", + "example.com": "django_saml2_auth/tests/metadata2.xml", + "api.example.com": "django_saml2_auth/tests/metadata.xml", +} def get_metadata_auto_conf_urls( @@ -587,3 +593,54 @@ def test_acs_view_when_redirection_state_is_passed_in_relay_state( result = acs(post_request) assert result["Location"] == "/admin/logs" + + +def get_custom_metadata_example( + user_id: Optional[str] = None, + domain: Optional[str] = None, + saml_response: Optional[str] = None, +): + """ + Get metadata file locally depending on current SP domain + """ + print('************') + print('get_custom_metadata_example') + print('************') + metadata_file_path = "/absolute/path/to/metadata.xml" + if domain: + protocol_idx = domain.find("https://") + if protocol_idx > -1: + domain = domain[protocol_idx + 8:] + if domain in DOMAIN_PATH_MAP: + print('metadata domain', domain) + metadata_file_path = DOMAIN_PATH_MAP[domain] + print('metadata path', metadata_file_path) + else: + raise SAMLAuthError(f"Domain {domain} not mapped!") + else: + # Fallback to local path + metadata_file_path = "/absolute/path/to/metadata.xml" + return {"local": [metadata_file_path]} + + +# WARNING: leave this test at the end or add +# settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_METADATA"] = None +# to following tests that uses settings, otherwise the TRIGGER.GET_CUSTOM_METADATA is always set +# and used in the get_metadata function + +def test_get_metadata_success_with_custom_trigger(settings: SettingsWrapper): + """Test get_metadata function to verify if correctly returns path to local metadata file. + + Args: + settings (SettingsWrapper): Fixture for django settings + """ + settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None + settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_METADATA"] = "django_saml2_auth.tests.test_saml.get_custom_metadata_example" + + result = get_metadata(domain="https://example.com") + assert result == {"local": ["django_saml2_auth/tests/metadata2.xml"]} + + with pytest.raises(SAMLAuthError) as exc_info: + get_metadata(domain="not-mapped-example.com") + + assert str(exc_info.value) == "Domain not-mapped-example.com not mapped!" From d3f978afb45f69c11918e2b77339d097688b9451 Mon Sep 17 00:00:00 2001 From: Mostafa Moradian Date: Wed, 11 Sep 2024 15:32:11 +0200 Subject: [PATCH 2/5] Remove prints --- django_saml2_auth/tests/test_saml.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/django_saml2_auth/tests/test_saml.py b/django_saml2_auth/tests/test_saml.py index 4eb7872..5eb40a3 100644 --- a/django_saml2_auth/tests/test_saml.py +++ b/django_saml2_auth/tests/test_saml.py @@ -603,9 +603,6 @@ def get_custom_metadata_example( """ Get metadata file locally depending on current SP domain """ - print('************') - print('get_custom_metadata_example') - print('************') metadata_file_path = "/absolute/path/to/metadata.xml" if domain: protocol_idx = domain.find("https://") From d47d31bf5b6c01fcdbc8c577ccb81e5ff2c14ac0 Mon Sep 17 00:00:00 2001 From: Mostafa Moradian Date: Wed, 11 Sep 2024 15:35:21 +0200 Subject: [PATCH 3/5] Ignore types --- django_saml2_auth/saml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_saml2_auth/saml.py b/django_saml2_auth/saml.py index 1ad7b65..a4fa2ec 100644 --- a/django_saml2_auth/saml.py +++ b/django_saml2_auth/saml.py @@ -115,7 +115,7 @@ def get_metadata( # If there is a custom trigger, metadata is retrieved directly within the trigger get_custom_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_CUSTOM_METADATA") if get_custom_metadata_trigger: - return run_hook(get_custom_metadata_trigger, user_id, domain, saml_response) + return run_hook(get_custom_metadata_trigger, user_id, domain, saml_response) # type: ignore get_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_METADATA_AUTO_CONF_URLS") if get_metadata_trigger: From 70df945b589d6587fb242f57d874ff6cae730591 Mon Sep 17 00:00:00 2001 From: Mostafa Moradian Date: Wed, 11 Sep 2024 15:47:57 +0200 Subject: [PATCH 4/5] Remove unused import --- django_saml2_auth/tests/test_saml.py | 1 - 1 file changed, 1 deletion(-) diff --git a/django_saml2_auth/tests/test_saml.py b/django_saml2_auth/tests/test_saml.py index 5eb40a3..a55fc68 100644 --- a/django_saml2_auth/tests/test_saml.py +++ b/django_saml2_auth/tests/test_saml.py @@ -21,7 +21,6 @@ get_saml_client, validate_metadata_url, ) -from django_saml2_auth.utils import run_hook from django_saml2_auth.views import acs from pytest_django.fixtures import SettingsWrapper from saml2.client import Saml2Client From 9aba9fffa7715e7617414800e8154e71c5b42bc0 Mon Sep 17 00:00:00 2001 From: Mostafa Moradian Date: Wed, 11 Sep 2024 15:50:16 +0200 Subject: [PATCH 5/5] Update Django patch versions --- .github/workflows/deploy.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0fdd876..56aae25 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,12 +15,12 @@ jobs: strategy: matrix: versions: - - { "djangoVersion": "4.2.15", "pythonVersion": "3.10" } - - { "djangoVersion": "4.2.15", "pythonVersion": "3.11" } - - { "djangoVersion": "4.2.15", "pythonVersion": "3.12" } - - { "djangoVersion": "5.0.8", "pythonVersion": "3.10" } - - { "djangoVersion": "5.0.8", "pythonVersion": "3.11" } - - { "djangoVersion": "5.0.8", "pythonVersion": "3.12" } + - { "djangoVersion": "4.2.16", "pythonVersion": "3.10" } + - { "djangoVersion": "4.2.16", "pythonVersion": "3.11" } + - { "djangoVersion": "4.2.16", "pythonVersion": "3.12" } + - { "djangoVersion": "5.0.9", "pythonVersion": "3.10" } + - { "djangoVersion": "5.0.9", "pythonVersion": "3.11" } + - { "djangoVersion": "5.0.9", "pythonVersion": "3.12" } poetry-version: ["1.8.3"] permissions: id-token: write