Skip to content

Commit

Permalink
feat(trigger): add custom get metadata hook (#342)
Browse files Browse the repository at this point in the history
* feat(trigger): add custom get metadata hook
* use custom trigger to override metadata retrieval
* update README
* update tests with new functions
* Remove prints
* Ignore types
* Remove unused import
* Update Django patch versions

---------

Co-authored-by: Mostafa Moradian <[email protected]>
Co-authored-by: Mostafa Moradian <[email protected]>
  • Loading branch information
3 people authored Sep 11, 2024
1 parent 2fecbec commit 8429830
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 8 deletions.
12 changes: 6 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 14 additions & 2 deletions django_saml2_auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
27 changes: 27 additions & 0 deletions django_saml2_auth/tests/metadata2.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<md:EntityDescriptor entityID="https://testserver2.com/entity" validUntil="2025-08-30T19:10:29Z"
xmlns:md="urn:oasis:names:tc:SAML:2.0:METADATA1"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:mdrpi="urn:oasis:names:tc:SAML:METADATA1:rpi"
xmlns:mdattr="urn:oasis:names:tc:SAML:METADATA1:attribute"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<!-- insert ds:Signature element (omitted) -->
<md:Extensions>
<mdrpi:RegistrationInfo registrationAuthority="https://testserver2.com/"/>
<mdrpi:PublicationInfo creationInstant="2025-08-16T19:10:29Z" publisher="https://testserver2.com/"/>
<mdattr:EntityAttributes>
<saml:Attribute Name="https://testserver2.com/entity-category" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue>https://testserver2.com/category/self-certified</saml:AttributeValue>
</saml:Attribute>
</mdattr:EntityAttributes>
</md:Extensions>
<!-- insert one or more concrete instances of the md:RoleDescriptor abstract type (see below) -->
<md:Organization>
<md:OrganizationName xml:lang="en">...</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">...</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">https://testserver2.com/</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:SurName>SAML Technical Support</md:SurName>
<md:EmailAddress>mailto:[email protected]</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
53 changes: 53 additions & 0 deletions django_saml2_auth/tests/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@
<md:EmailAddress>mailto:[email protected]</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>"""
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(
Expand Down Expand Up @@ -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!"

0 comments on commit 8429830

Please sign in to comment.