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 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..a4fa2ec 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) # type: ignore + 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..a55fc68 100644 --- a/django_saml2_auth/tests/test_saml.py +++ b/django_saml2_auth/tests/test_saml.py @@ -88,6 +88,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 +592,51 @@ 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 + """ + 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!"