Skip to content

Commit cba317c

Browse files
authored
Extract user identity hook (#344)
* 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
1 parent 3781931 commit cba317c

File tree

9 files changed

+345
-203
lines changed

9 files changed

+345
-203
lines changed

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ jobs:
6666
poetry publish --build --skip-existing
6767
- name: Create release and add artifacts 🚀
6868
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
69-
uses: softprops/action-gh-release@v1
69+
uses: softprops/action-gh-release@v2
7070
with:
7171
files: |
7272
dist/*.tar.gz

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ For IdP-initiated SSO, the user will be created if it doesn't exist. Still, for
1616
- Original Author: Fang Li ([@fangli](https://github.com/fangli))
1717
- Maintainer: Mostafa Moradian ([@mostafa](https://github.com/mostafa))
1818
- Version support matrix:
19+
1920
| **Python** | **Django** | **django-saml2-auth** | **End of extended support<br/>(Django)** |
2021
| ---------------------- | ---------- | --------------------- | ---------------------------------------- |
2122
| 3.10.x, 3.11.x, 3.12.x | 4.2.x | >=3.4.0 | April 2026 |
@@ -175,6 +176,7 @@ python setup.py install
175176
'SAML Group Name': 'Django Group Name',
176177
},
177178
'TRIGGER': {
179+
'EXTRACT_USER_IDENTITY': 'path.to.your.extract.user.identity.hook.method',
178180
# Optional: needs to return a User Model instance or None
179181
'GET_USER': 'path.to.your.get.user.hook.method',
180182
'CREATE_USER': 'path.to.your.new.user.hook.method',
@@ -244,12 +246,13 @@ Some of the following settings are related to how this module operates. The rest
244246
| **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'}` |
245247
| **TOKEN\_REQUIRED** | Set this to `False` if you don't require the token parameter in the SAML assertion (in the attributes map) | `bool` | `True` | |
246248
| **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` | `{}` | |
249+
| **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` |
247250
| **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` |
248251
| **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` |
249252
| **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` |
250253
| **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` |
251254
| **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` |
252-
| **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` |
255+
| **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` |
253256
| **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` |
254257
| **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` |
255258
| **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` |

django_saml2_auth/saml.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def validate_metadata_url(url: str) -> bool:
8888

8989
def get_metadata(
9090
user_id: Optional[str] = None,
91-
domain: Optional[str] = None,
91+
domain: Optional[str] = None,
9292
saml_response: Optional[str] = None,
9393
) -> Mapping[str, Any]:
9494
"""Returns metadata information, either by running the GET_METADATA_AUTO_CONF_URLS hook function
@@ -388,22 +388,30 @@ def decode_saml_response(
388388
return authn_response
389389

390390

391-
def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[Any]]:
392-
"""Extract user information from SAML user identity object
391+
def extract_user_identity(
392+
authn_response: Union[HttpResponseRedirect, Optional[AuthnResponse], None],
393+
) -> Dict[str, Optional[Any]]:
394+
"""Extract user information from SAML user identity object and optionally
395+
enriches the output with anything that can be extracted from the
396+
authentication response, like issuer, name_id, etc.
393397
394398
Args:
395-
user_identity (Dict[str, Any]): SAML user identity object (dict)
399+
authn_response (Union[HttpResponseRedirect, Optional[AuthnResponse], None]):
400+
AuthnResponse object for extracting user identity from.
396401
397402
Raises:
398403
SAMLAuthError: No token specified.
399404
SAMLAuthError: No username or email provided.
400405
401406
Returns:
402407
Dict[str, Optional[Any]]: Cleaned user information plus user_identity
403-
for backwards compatibility
408+
for backwards compatibility. Also, it can include any custom attributes
409+
that are extracted from the SAML response.
404410
"""
405411
saml2_auth_settings = settings.SAML2_AUTH
406412

413+
user_identity: Dict[str, Any] = authn_response.get_identity() # type: ignore
414+
407415
email_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.email", default="user.email")
408416
username_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.username", default="user.username")
409417
firstname_field = dictor(
@@ -454,4 +462,13 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
454462
},
455463
)
456464

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

0 commit comments

Comments
 (0)