Skip to content

Commit

Permalink
Extract user identity hook (#344)
Browse files Browse the repository at this point in the history
* Update deps
* Add TRIGGER.EXTRACT_USER_IDENTITY hook function to extract_user_identity
* Update README
* Format code
* Update tests to reflect changes in extract_user_identity function
* Add new test for testing EXTRACT_USER_IDENTITY
* Submit coverage and PyPI package only once
* Create a release just once
* Fix mypy errors
* Revert "Submit coverage and PyPI package only once"
* This reverts commit 7f7fb0f.
* Update GitHub release action
  • Loading branch information
mostafa authored Oct 12, 2024
1 parent 3781931 commit cba317c
Show file tree
Hide file tree
Showing 9 changed files with 345 additions and 203 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
poetry publish --build --skip-existing
- name: Create release and add artifacts 🚀
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.tar.gz
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For IdP-initiated SSO, the user will be created if it doesn't exist. Still, for
- Original Author: Fang Li ([@fangli](https://github.com/fangli))
- Maintainer: Mostafa Moradian ([@mostafa](https://github.com/mostafa))
- Version support matrix:

| **Python** | **Django** | **django-saml2-auth** | **End of extended support<br/>(Django)** |
| ---------------------- | ---------- | --------------------- | ---------------------------------------- |
| 3.10.x, 3.11.x, 3.12.x | 4.2.x | >=3.4.0 | April 2026 |
Expand Down Expand Up @@ -175,6 +176,7 @@ python setup.py install
'SAML Group Name': 'Django Group Name',
},
'TRIGGER': {
'EXTRACT_USER_IDENTITY': 'path.to.your.extract.user.identity.hook.method',
# Optional: needs to return a User Model instance or None
'GET_USER': 'path.to.your.get.user.hook.method',
'CREATE_USER': 'path.to.your.new.user.hook.method',
Expand Down Expand Up @@ -244,12 +246,13 @@ Some of the following settings are related to how this module operates. The rest
| **ATTRIBUTES\_MAP** | Mapping of Django user attributes to SAML2 user attributes | `dict` | `{'email': 'user.email', 'username': 'user.username', 'first_name': 'user.first_name', 'last_name': 'user.last_name', 'token': 'token'}` | `{'your.field': 'SAML.field'}` |
| **TOKEN\_REQUIRED** | Set this to `False` if you don't require the token parameter in the SAML assertion (in the attributes map) | `bool` | `True` | |
| **TRIGGER** | Hooks to trigger additional actions during user login and creation flows. These `TRIGGER` hooks are strings containing a [dotted module name](https://docs.python.org/3/tutorial/modules.html#packages) which point to a method to be called. The referenced method should accept a single argument: a dictionary of attributes and values sent by the identity provider, representing the user's identity. Triggers will be executed only if they are set. | `dict` | `{}` | |
| **TRIGGER.EXTRACT\_USER\_IDENTITY** | A method to be called upon extracting the user identity from the SAML2 response. This method should accept TWO parameters of the user_dict and the AuthnResponse. This method can return an enriched user_dict (user identity). | `str` | `AuthnResponse` | `my_app.models.users.extract_user_identity` |
| **TRIGGER.GET\_USER** | A method to be called upon getting an existing user. This method will be called before the new user is logged in and is used to customize the retrieval of an existing user record. This method should accept ONE parameter of user dict and return a User model instance or none. | `str` | `None` | `my_app.models.users.get` |
| **TRIGGER.CREATE\_USER** | A method to be called upon new user creation. This method will be called before the new user is logged in and after the user's record is created. This method should accept ONE parameter of user dict. | `str` | `None` | `my_app.models.users.create` |
| **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.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
27 changes: 22 additions & 5 deletions django_saml2_auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def validate_metadata_url(url: str) -> bool:

def get_metadata(
user_id: Optional[str] = None,
domain: 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
Expand Down Expand Up @@ -388,22 +388,30 @@ def decode_saml_response(
return authn_response


def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[Any]]:
"""Extract user information from SAML user identity object
def extract_user_identity(
authn_response: Union[HttpResponseRedirect, Optional[AuthnResponse], None],
) -> Dict[str, Optional[Any]]:
"""Extract user information from SAML user identity object and optionally
enriches the output with anything that can be extracted from the
authentication response, like issuer, name_id, etc.
Args:
user_identity (Dict[str, Any]): SAML user identity object (dict)
authn_response (Union[HttpResponseRedirect, Optional[AuthnResponse], None]):
AuthnResponse object for extracting user identity from.
Raises:
SAMLAuthError: No token specified.
SAMLAuthError: No username or email provided.
Returns:
Dict[str, Optional[Any]]: Cleaned user information plus user_identity
for backwards compatibility
for backwards compatibility. Also, it can include any custom attributes
that are extracted from the SAML response.
"""
saml2_auth_settings = settings.SAML2_AUTH

user_identity: Dict[str, Any] = authn_response.get_identity() # type: ignore

email_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.email", default="user.email")
username_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.username", default="user.username")
firstname_field = dictor(
Expand Down Expand Up @@ -454,4 +462,13 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
},
)

# If there is a custom trigger, user identity is extracted directly within the trigger.
# This is useful when the user identity doesn't include custom attributes to determine
# the organization, project or team that the user belongs to. Hence, the trigger can use
# the user identity from the SAML response along with the whole authentication response.
extract_user_identity_trigger = dictor(saml2_auth_settings, "TRIGGER.EXTRACT_USER_IDENTITY")
if extract_user_identity_trigger:
return run_hook(extract_user_identity_trigger, user, authn_response) # type: ignore

# If there is no custom trigger, the user identity is returned as is.
return user
Loading

0 comments on commit cba317c

Please sign in to comment.