Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow custom assertion URIs #348

Merged
merged 7 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,11 @@ python setup.py install
'GET_USER_ID_FROM_SAML_RESPONSE': 'path.to.your.get.user.from.saml.hook.method',
# This can override the METADATA_AUTO_CONF_URL to enumerate all existing metadata autoconf URLs
'GET_METADATA_AUTO_CONF_URLS': 'path.to.your.get.metadata.conf.hook.method',
# This will override ASSERTION_URL and CUSTOM_ASSERTION_URI to allow more dynamic assertion URIs
'GET_CUSTOM_ASSERTION_URI': 'path.to.your.get.custom.assertion.uri.hook.method',
},
'ASSERTION_URL': 'https://mysite.com', # Custom URL to validate incoming SAML requests against
'CUSTOM_ASSERTION_URI': 'https://mysite.com/sso/acs/', # Custom URI to validate incoming SAML requests against, allows more control than ASSERTION_URL
'ENTITY_ID': 'https://mysite.com/sso/acs/', # Populates the Issuer element in authn request
'NAME_ID_FORMAT': FormatString, # Sets the Format property of authn NameIDPolicy element, e.g. 'user.email'
'USE_JWT': True, # Set this to True if you are running a Single Page Application (SPA) with Django Rest Framework (DRF), and are using JWT authentication to authorize client users
Expand Down Expand Up @@ -232,7 +235,7 @@ Some of the following settings are related to how this module operates. The rest
<summary>Click to see the module settings</summary>

| **Field name** | **Description** | **Data type(s)** | **Default value(s)** | **Example** |
|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ---------------- |------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| **METADATA\_AUTO\_CONF\_URL** | Auto SAML2 metadata configuration URL | `str` | `None` | `https://ORG.okta.com/app/APP-ID/sso/saml/metadata` |
| **METADATA\_LOCAL\_FILE\_PATH** | SAML2 metadata configuration file path | `str` | `None` | `/path/to/the/metadata.xml` |
| **KEY_FILE** | SAML2 private key file path. Required for AUTHN\_REQUESTS\_SIGNED | `str` | `None` | `/path/to/the/key.pem` |
Expand All @@ -257,7 +260,9 @@ Some of the following settings are related to how this module operates. The rest
| **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` |
| **ASSERTION\_URL** | A URL to validate incoming SAML responses against. By default, `django-saml2-auth` will validate the SAML response's Service Provider address against the actual HTTP request's host and scheme. If this value is set, it will validate against `ASSERTION_URL` instead - perfect for when Django is running behind a reverse proxy. | `str` | `https://example.com` | |
| **TRIGGER.GET\_CUSTOM\_ASSERTION\_URI** | A hook function to get the assertion URI dynamically. Useful when you have dynamic routing, multi-tenant setup and etc. Overrides `ASSERTION_URL` and `CUSTOM_ASSERTION_URI`. | `str` | `None` | `my_app.utils.get_custom_assertion_uri` |
| **ASSERTION\_URL** | A URL to validate incoming SAML responses against. By default, `django-saml2-auth` will validate the SAML response's Service Provider address against the actual HTTP request's host and scheme. If this value is set, it will validate against `ASSERTION_URL` instead - perfect for when Django is running behind a reverse proxy. This will only allow to customize the domain part of the URI, for more customization use `CUSTOM_ASSERTION_URI`. | `str` | `None` | `https://example.com` |
| **CUSTOM\_ASSERTION\_URI** | A URI to validate incoming SAML responses against. Useful when you have internal redirects. | `str` | `None` | `https://example.com/saml/acs` |
| **ENTITY\_ID** | The optional entity ID string to be passed in the 'Issuer' element of authentication request, if required by the IDP. | `str` | `None` | `https://exmaple.com/sso/acs` |
| **NAME\_ID\_FORMAT** | Set to the string `'None'`, to exclude sending the `'Format'` property of the `'NameIDPolicy'` element in authentication requests. | `str` | `<urn:oasis:names:tc:SAML:2.0:nameid-format:transient>` | |
| **USE\_JWT** | Set this to the boolean `True` if you are using Django with JWT authentication | `bool` | `False` | |
Expand Down
15 changes: 12 additions & 3 deletions django_saml2_auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ def get_metadata(
)


def get_custom_acs_uri() -> str:
get_custom_acs_url_hook = dictor(settings.SAML2_AUTH, "TRIGGER.GET_CUSTOM_ASSERTION_URI")
if get_custom_acs_url_hook:
return run_hook(get_custom_acs_url_hook)
return dictor(settings.SAML2_AUTH, "CUSTOM_ASSERTION_URI")
mostafa marked this conversation as resolved.
Show resolved Hide resolved


def get_saml_client(
domain: str,
acs: Callable[..., HttpResponse],
Expand All @@ -180,9 +187,6 @@ def get_saml_client(
Returns:
Optional[Saml2Client]: A Saml2Client or None
"""
# get_reverse raises an exception if the view is not found, so we can safely ignore type errors
acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) # type: ignore

get_user_id_from_saml_response = dictor(
settings.SAML2_AUTH, "TRIGGER.GET_USER_ID_FROM_SAML_RESPONSE"
)
Expand All @@ -204,6 +208,11 @@ def get_saml_client(
},
)

acs_url = get_custom_acs_uri()
if not acs_url:
# get_reverse raises an exception if the view is not found, so we can safely ignore type errors
acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) # type: ignore

saml2_auth_settings = settings.SAML2_AUTH

saml_settings: Dict[str, Any] = {
Expand Down
32 changes: 32 additions & 0 deletions django_saml2_auth/tests/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from django.http import HttpRequest
from django.test.client import RequestFactory
from django.urls import NoReverseMatch
from saml2 import BINDING_HTTP_POST

from django_saml2_auth.exceptions import SAMLAuthError
from django_saml2_auth.saml import (
decode_saml_response,
Expand Down Expand Up @@ -113,6 +115,10 @@ def get_metadata_auto_conf_urls(
return [{"url": METADATA_URL1}, {"url": METADATA_URL2}]


get_custom_assertion_uri = lambda: "https://example.com/custom-tenant/acs"
GET_CUSTOM_ASSERTION_URI = "django_saml2_auth.tests.test_saml.get_custom_assertion_uri"


def mock_extract_user_identity(
user: Dict[str, Optional[Any]], authn_response: AuthnResponse
) -> Dict[str, Optional[Any]]:
Expand Down Expand Up @@ -457,6 +463,32 @@ def test_get_saml_client_success_with_key_and_cert_files(
del settings.SAML2_AUTH[key]


def test_get_saml_client_success_with_custom_assertion_uri(settings: SettingsWrapper):
settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml"

custom_assertion_uri = "https://example.com/test-tenant/acs"
settings.SAML2_AUTH["CUSTOM_ASSERTION_URI"] = custom_assertion_uri

result = get_saml_client("example.com", acs, "[email protected]")
assert custom_assertion_uri in result.config.endpoint(
"assertion_consumer_service",
BINDING_HTTP_POST,
result.entity_type,
)


def test_get_saml_client_success_with_custom_assertion_uri_hook(settings: SettingsWrapper):
settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml"

settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_ASSERTION_URI"] = GET_CUSTOM_ASSERTION_URI

result = get_saml_client("example.com", acs, "[email protected]")
assert "https://example.com/custom-tenant/acs" in result.config.endpoint(
"assertion_consumer_service",
BINDING_HTTP_POST,
result.entity_type,
)

@responses.activate
def test_decode_saml_response_success(
settings: SettingsWrapper,
Expand Down
Loading