├── django_saml2_auth ├── tests │ ├── __init__.py │ ├── metadata.xml │ ├── metadata2.xml │ ├── dummy_cert.pem │ ├── dummy_key.pem │ ├── settings.py │ ├── test_utils.py │ ├── test_user.py │ └── test_saml.py ├── __init__.py ├── templates │ └── django_saml2_auth │ │ ├── signout.html │ │ ├── denied.html │ │ └── error.html ├── urls.py ├── exceptions.py ├── errors.py ├── utils.py ├── views.py ├── user.py └── saml.py ├── .git-blame-ignore-revs ├── .github ├── dependabot.yml └── workflows │ ├── stale.yml │ └── deploy.yml ├── LICENSE ├── .gitignore ├── pyproject.toml ├── CONTRIBUTING.md ├── AUTHORS.md └── README.md /django_saml2_auth/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for django_saml2_auth. 3 | """ 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Format code with ruff 2 | 169ea6d286d639d1670acbe6c07609b0dffa7f62 3 | -------------------------------------------------------------------------------- /django_saml2_auth/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-saml2-auth is a Django app that provides a SAML2 authentication backend. 3 | """ 4 | 5 | import importlib.metadata 6 | 7 | try: 8 | __version__ = importlib.metadata.version(__name__) 9 | except importlib.metadata.PackageNotFoundError: 10 | __version__ = "0.0.0" 11 | -------------------------------------------------------------------------------- /django_saml2_auth/templates/django_saml2_auth/signout.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {% trans "Signed out" %} 7 | 8 | 9 |

{% trans "You have signed out successfully." %}

10 |
11 |

{% trans "If you want to login again or switch to another account, please do so in SSO." %}

12 | 13 | -------------------------------------------------------------------------------- /django_saml2_auth/urls.py: -------------------------------------------------------------------------------- 1 | """Django URL mappings""" 2 | 3 | from django.urls import path 4 | from django_saml2_auth import views 5 | 6 | app_name = "django_saml2_auth" 7 | 8 | urlpatterns = [ 9 | path(r"acs/", views.acs, name="acs"), 10 | path(r"sp/", views.sp_initiated_login, name="sp"), 11 | path(r"welcome/", views.welcome, name="welcome"), 12 | path(r"denied/", views.denied, name="denied"), 13 | ] 14 | -------------------------------------------------------------------------------- /django_saml2_auth/templates/django_saml2_auth/denied.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {% trans "Permission Denied" %} 7 | 8 | 9 |

{% trans "Sorry, you are not allowed to access this app" %}

10 |
11 |

{% trans "To report a problem with your access please contact your system administrator" %}

12 | 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016-2017 Fang Li 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /django_saml2_auth/templates/django_saml2_auth/error.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {% trans "Permission Denied" %} 7 | 8 | 9 |

{% trans "Sorry, you are not allowed to access this app" %}

10 |
11 |

12 | {% trans "To report a problem with your access please contact your system administrator" %} 13 |

14 | {% if error_code %}

Error code: {{ error_code }}

{% endif %} 15 | {% if reason %}

Reason: {{ reason }}

{% endif %} 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /django_saml2_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exception class for handling extra arguments.""" 2 | 3 | from typing import Any, Dict, Optional 4 | 5 | 6 | class SAMLAuthError(Exception): 7 | """Custom exception class for handling extra arguments.""" 8 | 9 | extra: Optional[Dict[str, Any]] = None 10 | 11 | def __init__(self, msg: str, extra: Optional[Dict[str, Any]] = None): 12 | """Initialize exception class. 13 | 14 | Args: 15 | msg (str): Exception message. 16 | extra (Optional[Dict[str, Any]], optional): Extra arguments. 17 | Defaults to None. 18 | """ 19 | self.message = msg 20 | self.extra = extra 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # IDEs 65 | .vscode/ 66 | .idea/ 67 | -------------------------------------------------------------------------------- /django_saml2_auth/errors.py: -------------------------------------------------------------------------------- 1 | """Error codes used for reporting errors to user.""" 2 | 3 | EMPTY_FUNCTION_PATH = 1100 4 | PATH_ERROR = 1101 5 | IMPORT_ERROR = 1102 6 | GENERAL_EXCEPTION = 1103 7 | CREATE_USER_ERROR = 1104 8 | GROUP_JOIN_ERROR = 1105 9 | NO_REVERSE_MATCH = 1106 10 | ERROR_CREATING_SAML_CONFIG_OR_CLIENT = 1107 11 | NO_SAML_RESPONSE_FROM_CLIENT = 1108 12 | NO_SAML_RESPONSE_FROM_IDP = 1109 13 | NO_NAME_ID_IN_SAML_RESPONSE = 1110 14 | NO_ISSUER_IN_SAML_RESPONSE = 1111 15 | NO_USER_IDENTITY_IN_SAML_RESPONSE = 1112 16 | NO_TOKEN_SPECIFIED = 1113 17 | NO_USERNAME_OR_EMAIL_SPECIFIED = 1114 18 | SHOULD_NOT_CREATE_USER = 1115 19 | INACTIVE_USER = 1116 20 | NO_METADATA_URL_OR_FILE = 1117 21 | NO_SAML_CLIENT = 1118 22 | NO_JWT_ALGORITHM = 1119 23 | INVALID_METADATA_URL = 1120 24 | NO_METADATA_URL_ASSOCIATED = 1121 25 | INVALID_REQUEST_METHOD = 1122 26 | CANNOT_DECODE_JWT_TOKEN = 1123 27 | USER_MISMATCH = 1124 28 | NO_JWT_SECRET = 1125 29 | NO_JWT_PRIVATE_KEY = 1126 30 | NO_JWT_PUBLIC_KEY = 1127 31 | INVALID_JWT_ALGORITHM = 1128 32 | NO_USER_ID = 1129 33 | INVALID_TOKEN = 1130 34 | INVALID_NEXT_URL = 1131 35 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | permissions: {} 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | actions: write 12 | contents: read 13 | issues: write 14 | pull-requests: write 15 | steps: 16 | - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 17 | with: 18 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 19 | stale-pr-message: "This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days." 20 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." 21 | close-pr-message: "This PR was closed because it has been stalled for 10 days with no activity." 22 | days-before-issue-stale: 30 23 | days-before-pr-stale: 45 24 | days-before-issue-close: 5 25 | days-before-pr-close: 10 26 | stale-issue-label: "no-issue-activity" 27 | exempt-issue-labels: "awaiting-approval,work-in-progress" 28 | stale-pr-label: "no-pr-activity" 29 | exempt-pr-labels: "awaiting-approval,work-in-progress" 30 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/metadata.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | https://testserver2.com/category/self-certified 14 | 15 | 16 | 17 | 18 | 19 | ... 20 | ... 21 | https://testserver2.com/ 22 | 23 | 24 | SAML Technical Support 25 | mailto:technical-support@example.info 26 | 27 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/metadata2.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | https://testserver2.com/category/self-certified 14 | 15 | 16 | 17 | 18 | 19 | ... 20 | ... 21 | https://testserver2.com/ 22 | 23 | 24 | SAML Technical Support 25 | mailto:technical-support@example.info 26 | 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "grafana-django-saml2-auth" 3 | readme = "README.md" 4 | dynamic = ["version"] 5 | description = "Django SAML2 Authentication Made Easy." 6 | authors = [{ name = "Mostafa Moradian", email = "mostafa@grafana.com" }] 7 | requires-python = ">=3.10" 8 | dependencies = [ 9 | "dictor==0.1.12", 10 | "pyjwt==2.10.1", 11 | "pysaml2==7.5.2", 12 | ] 13 | 14 | [dependency-groups] 15 | dev = [ 16 | "coverage==7.8.0", 17 | "cyclonedx-bom==5.3.0", 18 | "django-stubs==5.1.3", 19 | "mypy==1.15.0", 20 | "pytest==8.3.5", 21 | "pytest-django==4.11.0", 22 | "responses==0.25.7", 23 | "ruff>=0.11.2", 24 | "types-pysaml2==1.0.1", 25 | "types-setuptools==78.1.0.20250329", 26 | ] 27 | 28 | [build-system] 29 | requires = ["hatchling", "uv-dynamic-versioning"] 30 | build-backend = "hatchling.build" 31 | 32 | [tool.hatch.version] 33 | source = "uv-dynamic-versioning" 34 | 35 | [tool.hatch.build.targets.wheel] 36 | packages = ["django_saml2_auth"] 37 | 38 | [tool.ruff] 39 | exclude = [ 40 | "dist", 41 | "build", 42 | "env", 43 | "venv", 44 | ".env", 45 | ".venv", 46 | ".tox", 47 | ".git", 48 | ".mypy_cache", 49 | ".pytest_cache", 50 | "__pycache__", 51 | ".ruff", 52 | ] 53 | line-length = 100 54 | 55 | [tool.pytest.ini_options] 56 | DJANGO_SETTINGS_MODULE = "django_saml2_auth.tests.settings" 57 | pythonpath = "." 58 | filterwarnings = "ignore::DeprecationWarning" 59 | addopts = ["--import-mode=importlib"] 60 | testpaths = ["django_saml2_auth/tests"] 61 | 62 | [tool.mypy] 63 | plugins = ["mypy_django_plugin.main"] 64 | 65 | [tool.django-stubs] 66 | django_settings_module = "django_saml2_auth.tests.settings" 67 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/dummy_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFbTCCA1WgAwIBAgIUbcK0caWcYgQq/PgM/HpXsfGc7xYwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMzExMDkxMzI4MTNaGA8zMDIz 5 | MDMxMjEzMjgxM1owRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx 6 | ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCAiIwDQYJKoZIhvcN 7 | AQEBBQADggIPADCCAgoCggIBAO0hfWkIoYvRBSvSQJazwp2NadhPCJEPliY0ZgKu 8 | dQibzV1oav6DdxZWxs3ys3HKpUnfRTsMXMXzYFJv3M17X8kAsEAfjLKIC3POV/Og 9 | 73fW7T/2uJubIE0bI6whe44/4vV6JVKuZUf7N/eD2k0x9t7O+ljITdnFyNbwLJ24 10 | ZoVSB9VhhAN+gVlR+D9yr5NwcWSVSnn9wxKh5cHpNu85g/dpQ7sA8QNSQGgJ763V 11 | WiITxGQ1S13+RKRDdtzaahjkEezh0nCeVBypQ2u4zMj3jEVgnSqcxIaGoSyRlsr4 12 | kyoeFVHFJq3vVOMHa21luPaDsskssBMu13udVUmsaiEQc4Z7ItlHeXgQc0cy6N94 13 | uZw+qw+CMRWvZGsyKWuvNfQu/ZAME8MrhveLot9pcf2PFXLG+kitE741m0A1JP3v 14 | xRaGRHU4L0fkBalTVUncLo6hBAvgH+uN+Dl2p7KnIanHgMXQXv/UdRyzuJ5E7q+B 15 | yHnwXwNRcCOWrOFe07yQetc5f+Z8+p2X6lkjgMD+d6IrKIsYCMU1ZMHi+oWkSbei 16 | oDx/kk7xPnNLM1hbmQPNrbt19M49rGg6CN8Z6vjVavdJ5Rpj0Tq13JWA52eJu/NT 17 | wpxYaWeh7WkzhHAS9bgyOX/ot9iJSPicLdrl5qMkwmPqi8UyXrVLA2LCG0SH2Oz5 18 | YT8TAgMBAAGjUzBRMB0GA1UdDgQWBBQzOZToKlK9pjiv6JG78CLq/+GJuTAfBgNV 19 | HSMEGDAWgBQzOZToKlK9pjiv6JG78CLq/+GJuTAPBgNVHRMBAf8EBTADAQH/MA0G 20 | CSqGSIb3DQEBCwUAA4ICAQAygpKdXrSPYdTSuLfDXHAo+CPSynNFBUUbbQta23r+ 21 | ucJVc79fgIT+lZbXm35ddQ2uhCuZuQy+K2JSBv7Zcr7xii89YyMkHGKINvJVhjgG 22 | aZQARrdWcd1c8DnSfC144TITDFC2uqX0L2f6m/V//J8y7Dwetqh13nzKXE8xmWc5 23 | fmwiULXQrJ1cqn1cEB/1y1rQOT+bAbsJ6gzpSyxf8gRklKYQmkPvATvOOg+GK1d3 24 | GeQLhw6KcDql1d2VnHb7vQRow7Uidtxi7lKcj6k4R+7hg8BBNtrsHH0GsGCfun8O 25 | +VxtS+YT6xM7LYwuyTEtcHz1pyqyIpFBYsyNm8WH/F9i1Is1Jj5om5Zx6inL31YV 26 | RRKujvvRjRe3g3uZY15p5/HHNK5riPkVZRPT9qVPDxnScjgaI5EhLw173sEt6ktG 27 | 7zrlC7yZFpNMkGSs5SkT8lUQTmGr2gD5b02N4UNdhCF+WZOmMLjQAvinMGfdjqek 28 | 3e6llupoyNOzG+4LvI/HzVHqg6WjVO5QSP/4gt21SSgUo7mHa0GQMymVmkCrWEMY 29 | +PhpuNE5fv9CGyi22f+LZ988jGhpHApzrdGBY/M3h7k4mD7Ap/a8J3inJPLKOgIG 30 | z1VT29ZzR8R7NpkoJV1zX9/wFUf5lZMi3UJPuj/LPOf0jcoZbV8B/E7ydf6akm69 31 | Xg== 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guide 2 | 3 | Thank you for investing your time in contributing to our project! Any contribution you make will be reflected on [authors](AUTHORS.md) ✨. 4 | 5 | 6 | 7 | ## New Contributor Guide 8 | 9 | To get an overview of the project, read the [README](README.md). Here are some resources to help you get started with open source contributions: 10 | 11 | - [Security Assertion Markup Language](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) 12 | - [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0) 13 | - [SAML metadata](https://en.wikipedia.org/wiki/SAML_metadata) 14 | 15 | This library is tested against these SAML SSO identity providers. You can probably open development accounts on these platforms to test your Django with SAML SSO. 16 | 17 | - Okta 18 | - Azure Active Directory 19 | - PingOne 20 | - Auth0 (doesn't support custom attributes) 21 | 22 | For debugging your setup, you can use SAML-tracer add-on on [Firefox](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/) or extension on [Chrome](https://chrome.google.com/webstore/detail/saml-tracer/mpdajninpobndbfcldcmbpnnbhibjmch?hl=en), which will help you capture SAML SSO traffic and shows you what is passed around in the HTTP messages. 23 | 24 | Read the [tests](django_saml2_auth/tests) to learn more about settings and how each function or endpoint works. And when you open a PR, please add tests and documentation. You can also add your name to the list of [authors](AUTHORS.md). When the PR is ready, mention for the review. 25 | 26 | ## How to Contribute 27 | 28 | 1. Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug. 29 | 2. Fork [the repository](http://github.com/loadimpact/django-saml2-auth) on GitHub to start making your changes to the **master** branch (or branch off of it). 30 | 3. Write a test which shows that the bug was fixed or that the feature works as expected. 31 | 4. Send a pull request and bug the maintainer ([@mostafa](https://github.com/mostafa)) until it gets merged and published. :) Make sure to add yourself to [authors](AUTHORS.md). 32 | 33 | ## When you raise an issue or open a PR 34 | 35 | Please note this library is mission-critical and supports almost all django versions since 2.2.x. We need to be extremely careful when merging any changes. 36 | 37 | The support for new versions of django are welcome and I'll make best effort to make it latest django compatible. 38 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | This project is currently maintained by Mostafa Moradian and various contributors: 2 | 3 | # Django SAML2 Auth 4 | 5 | - Original author: 6 | - [Li Fang](https://github.com/fangli) 7 | 8 | - Current maintainer: 9 | - [Mostafa Moradian](https://github.com/mostafa) (k6.io & Grafana Labs) 10 | 11 | # Dependencies 12 | 13 | PySAML2: [c00kiemon5ter](https://github.com/c00kiemon5ter) and other contributors. 14 | 15 | PyJWT: [José Padilla](https://github.com/jpadilla) (Auth0) and other contributors. 16 | 17 | cryptography: [Paul Kehrer](https://github.com/reaperhulk) and other contributors. 18 | 19 | dictor: [perfecto25](https://github.com/perfecto25) and other contributors. 20 | 21 | # Test dependencies 22 | 23 | django: [various contributors](https://github.com/django/django/blob/master/AUTHORS). 24 | 25 | pytest: Holger Krekel and [other contributors](https://github.com/pytest-dev/pytest/blob/master/AUTHORS). 26 | 27 | pytest-django: Ben Firshman, Andreas Pelme and [other contributors](https://github.com/pytest-dev/pytest-django/blob/master/AUTHORS). 28 | 29 | responses: [markstory](https://github.com/markstory) and other contributors. 30 | 31 | # Contributors 32 | 33 | If your code or changes are here, but you are not mentioned, please open 34 | an issue. 35 | 36 | - [Mostafa Moradian](https://github.com/mostafa) (k6.io & Grafana Labs) 37 | - [Rafael Muñoz Cárdenas](https://github.com/Menda) (k6.io & Grafana Labs) 38 | - [DSpeichert](https://github.com/DSpeichert) 39 | - [jacobh](https://github.com/jacobh) 40 | - [Gene Wood](http://github.com/gene1wood/) 41 | - [Terry](https://github.com/tpeng) 42 | - [Tim Pierce](https://github.com/qwrrty/) (Adobe Systems) 43 | - [Tonymke](https://github.com/tonymke/) 44 | - [pintor](https://github.com/pintor) 45 | - [BaconAndEggs](https://github.com/BaconAndEggs) 46 | - [Ryan Mahaffey](https://github.com/mahaffey) 47 | - [ayr-ton](https://github.com/ayr-ton) 48 | - [kevPo](https://github.com/kevPo) 49 | - [chriskj](https://github.com/chriskj) 50 | - [Griffin J Rademacher](https://github.com/favorable-mutation) 51 | - [Akshit Dhar](https://github.com/akshit-wwstay) 52 | - [Jean Vincent](https://github.com/jean-sh) 53 | - [Søren Howe Gersager](https://github.com/syre) 54 | - [Gabrio Mauri](https://github.com/sgabb) 55 | - [Hugh Enxing](https://github.com/henxing) (CVision AI) 56 | - [Tamara Nocentini](https://github.com/TamaraNocentini) 57 | - [Paolo Romolini](https://github.com/paoloromolini) 58 | - [Uraiz Ali](https://github.com/UraizAli) 59 | - [Santiago Gandolfo](https://github.com/santigandolfo) 60 | - [Greg Wong](https://github.com/gregorywong) 61 | - [Michael V. Battista](https://github.com/mvbattista) 62 | - [William Abbott](https://github.com/wrabit) 63 | - [Henry Harutyunyan](https://github.com/henryh9n) (Revolut) 64 | - [Noppanut Ploywong](https://github.com/noppanut15) 65 | - [Mohammed Almeshal](https://github.com/MohammedAlmeshal) -------------------------------------------------------------------------------- /django_saml2_auth/tests/dummy_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDtIX1pCKGL0QUr 3 | 0kCWs8KdjWnYTwiRD5YmNGYCrnUIm81daGr+g3cWVsbN8rNxyqVJ30U7DFzF82BS 4 | b9zNe1/JALBAH4yyiAtzzlfzoO931u0/9ribmyBNGyOsIXuOP+L1eiVSrmVH+zf3 5 | g9pNMfbezvpYyE3ZxcjW8CyduGaFUgfVYYQDfoFZUfg/cq+TcHFklUp5/cMSoeXB 6 | 6TbvOYP3aUO7APEDUkBoCe+t1VoiE8RkNUtd/kSkQ3bc2moY5BHs4dJwnlQcqUNr 7 | uMzI94xFYJ0qnMSGhqEskZbK+JMqHhVRxSat71TjB2ttZbj2g7LJLLATLtd7nVVJ 8 | rGohEHOGeyLZR3l4EHNHMujfeLmcPqsPgjEVr2RrMilrrzX0Lv2QDBPDK4b3i6Lf 9 | aXH9jxVyxvpIrRO+NZtANST978UWhkR1OC9H5AWpU1VJ3C6OoQQL4B/rjfg5dqey 10 | pyGpx4DF0F7/1HUcs7ieRO6vgch58F8DUXAjlqzhXtO8kHrXOX/mfPqdl+pZI4DA 11 | /neiKyiLGAjFNWTB4vqFpEm3oqA8f5JO8T5zSzNYW5kDza27dfTOPaxoOgjfGer4 12 | 1Wr3SeUaY9E6tdyVgOdnibvzU8KcWGlnoe1pM4RwEvW4Mjl/6LfYiUj4nC3a5eaj 13 | JMJj6ovFMl61SwNiwhtEh9js+WE/EwIDAQABAoICAAG7mwGuodjBr3lA1BsALGc6 14 | CyzgoZADOMN2xEQv3h6pP91RrBvmFK/KMTHHq8Cr+c9L4vICUDTFhY3CyGNfMYS7 15 | XCx6X3wK2xw3NdStnSB9F51jx9cLfrdQlriHFjpCvRQb+JnKwGZO75IHYUCQ++8N 16 | 4o+vtHGy7KE8wnrw7YagpdxM/4JKNEgRudWYY+x63l9g8LsQIaHyqkZM7OWyOGag 17 | Wuo0XP9z5FTF1CscADmG/uwyiq3zimWiqd4Uw5OKdXlPaI7UpwJn5xEi9CL4pU4m 18 | Awh6TTT+z2RpfBDvOtn12gYXJ0nh7GfZXg+DkKLlPHqrGm5oCyJsf++6kI0JAoj6 19 | Le/BAw0Oq0Lvhp9fj/3t6Vc5tzWqqh1jsub1EFO+i0QjTcu/i1CYjgCtA+YV2nVe 20 | 65VwKEQv520oAMPkE0V2ISPW43jEFKvJ1tr2/c2TFM0pjQhliicEslbLobkLmqHD 21 | I368FqmQzIUV9Ht613X2+wBMm2L3BlY0Q67Ufr7wTKaHQJ7SO+dzHU0QZdHNsswv 22 | VxzgvcJkmjJsbGQPZoJxQJN8QCMLtGJRT42VO0RkRR+86Kt9lwOvXbcSikQ4rqc2 23 | D7vu/RfJSHiQvaqr4/ak+5FFO6mEIfnsVuquoQ89HOqQycyQIayCILsp4kdlsXGI 24 | aFpHOBOszqMIrVfnLPMZAoIBAQD3TLYOfZg3mbVxfkdglF2rBOzk9cpzPpz/rMpg 25 | mbyGv6UQb67a1HL1zDNLA/TZ7FxLORjC6i/e6KD6NsUuLaO+RiIwlK7K3hh0jmRe 26 | xzG3i3dXWDEOdHdqyDVIV+KvjDAk/Ze3WNfGc5fMafrgBiboHG+0eI4Z+DiBw14O 27 | eJWiquR0qqmffGj1XaxUD/Cy0WWhAuW9BrYYDnsJnluulEfUKGJGR1ajeua3e2jF 28 | dLkwSmxxQwCT2GDUpdI2rxZHyaePDPLZ+afAwSozL8Rf8+wrUYS30mLKMjS+Mdai 29 | iW9vS2FkagtR1lbKvuwc7s+4shx73TQGzP16U3unCJOIHB5pAoIBAQD1eTGasJeg 30 | 9gRrSsOTmmiXFtXYaYQvIv5r76GPpCpMsAJK5imD5r/1R+NvS4CH9846N/KYnQ3E 31 | oKqyoc6Xv1jAaI7qOeuEFOEsGV5I4Api+TJh9/G4z4Gy2mZqidYbMZgiely3wlID 32 | L/z068QiKQdw9G4/SnqGNqLZ0pwzJ31a/nLbkRzIo+BSloKMyMdQXlCkaQI/GQCU 33 | CTEXCN3BRlo0CwaXc7D3U5+CAVzrgBGo5Nj8CRlXR0jZODaRMGlYPG1e9c6yYdSh 34 | Xq8FJCazAhSGyJtgVsbQ2qcDwc9YtG1UgR92rnD/oWWuGw9rrPkIyoYYHyC+IoRQ 35 | hyyHX+UTDHobAoIBAQDNycAV/t2UJwyeDP9Ily37CnY3cXGuxQPQnvEpwcToPMIX 36 | E1jmMLQZZnuoiPpP/igvUKwSRt7fF6YdkUY0TzZbN/Fri86IcpjXJUbQt+HfYudE 37 | f9cSuEhHS6NLOBcjDf1iSsTdhcjJE5fWOrrRgU0PCdrKyyc05SHgmbrDQAUFAEBr 38 | 9TiBxv1wcSreKQWbSDTR759N1S/ihOpN7sFMXYgIPDLLWMH3+GXVeZSN+7u/O69R 39 | 8PeiEAVD71kmuDxKMLyGhbfxO5clB5keTzmSv2BgC83tSd17dJv/SWnah5N7gbbh 40 | 4Hza9Qn0XTwON4wTneOmD0UkA6FLEf1r2e8q7HtBAoIBAQDf6fYymeUmYWN0j1VJ 41 | ne7L63uTleSKrswPnx3rjh87ps3gjoTOGb1+O14eFmwfGw9WEdTMG28Erl4m5ewy 42 | hcuqb3X1+HF6ISWo+VcE+MDguVmY/ffT0g0IHaqQgjz4v0t8H8vVn376A1sl+q4F 43 | TxHHml+6gfCz3sC8Gx56lwoE59fTq1HrP3kPPNXHIBqXmADNiDARaHgbkSrjTSYi 44 | +E6t7GTN4C1L3k7A4wdkloUFYAMCHDauY4rzAhDcbaGaaDyIA4bRNuYjcOALu4dF 45 | gJ9Ct1jsDxv6RYlVpwPBcYvNKp+Nvd+7fvjmUS7G1JixyTN6a2KNraSuZC9dKT8n 46 | GhmDAoIBAAVlPzIDqH3RTgrM59Ox7nlucru7t1UV/r8EtB35y4JF0CVJY18qM+C9 47 | JOmQ4NpkF1JrOFR4osduDSL/me2LpF4/WgxPG7cIr34c7+VDaf/ke5TE/t9IR0bS 48 | BDQPRYfToO6pFM/cY6nUVppTlhRJ2WAPzkoGC34pmDfv0OLdrvit5OXNXJQRDSyA 49 | wM1KPxnLvLdEoKtrjVXeIhg1GgjvhtFO+O87NIe9Pu9Bb/VNR698WUFxSwvXTSIZ 50 | 53nlPHbnFxtERXf/xdD5eXyYqJR/Z3e4JZ+EjQcX8bHrDIpEBYZD2kLdHHZ9Q7HV 51 | K1a3lfdFi6tz5qORy3yofYqvqi6wEko= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests. 3 | """ 4 | 5 | import os 6 | from typing import List 7 | 8 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | SECRET_KEY = "SECRET" 10 | DEBUG = True 11 | ALLOWED_HOSTS: List[str] = [] 12 | INSTALLED_APPS = [ 13 | "django.contrib.admin", 14 | "django.contrib.auth", 15 | "django.contrib.contenttypes", 16 | "django.contrib.sessions", 17 | "django.contrib.messages", 18 | "django.contrib.staticfiles", 19 | "django_saml2_auth", 20 | ] 21 | MIDDLEWARE = [ 22 | "django.middleware.security.SecurityMiddleware", 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 29 | ] 30 | ROOT_URLCONF = "django_saml2_auth.urls" 31 | TEMPLATES = [ 32 | { 33 | "BACKEND": "django.template.backends.django.DjangoTemplates", 34 | "DIRS": [BASE_DIR + "/templates"], 35 | "APP_DIRS": True, 36 | "OPTIONS": { 37 | "context_processors": [ 38 | "django.template.context_processors.debug", 39 | "django.template.context_processors.request", 40 | "django.contrib.auth.context_processors.auth", 41 | "django.contrib.messages.context_processors.messages", 42 | ], 43 | }, 44 | }, 45 | ] 46 | WSGI_APPLICATION = "django_saml2_auth.wsgi.application" 47 | DATABASES = { 48 | "default": { 49 | "ENGINE": "django.db.backends.sqlite3", 50 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 51 | } 52 | } 53 | AUTH_PASSWORD_VALIDATORS = [ 54 | { 55 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 56 | }, 57 | { 58 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 59 | }, 60 | { 61 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 62 | }, 63 | { 64 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 65 | }, 66 | ] 67 | LANGUAGE_CODE = "en-us" 68 | TIME_ZONE = "UTC" 69 | USE_I18N = True 70 | USE_L10N = True 71 | USE_TZ = True 72 | STATIC_URL = "/static/" 73 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") 74 | SAML2_AUTH = { 75 | "DEFAULT_NEXT_URL": "http://app.example.com/account/login", 76 | "CREATE_USER": True, 77 | "CREATE_GROUPS": False, 78 | "NEW_USER_PROFILE": { 79 | "USER_GROUPS": [], 80 | "ACTIVE_STATUS": True, 81 | "STAFF_STATUS": False, 82 | "SUPERUSER_STATUS": False, 83 | }, 84 | "ATTRIBUTES_MAP": { 85 | "email": "user.email", 86 | "username": "user.username", 87 | "first_name": "user.first_name", 88 | "last_name": "user.last_name", 89 | "token": "token", 90 | }, 91 | "TRIGGER": { 92 | "BEFORE_LOGIN": "django_saml2_auth.tests.test_user.saml_user_setup", 93 | "GET_METADATA_AUTO_CONF_URLS": "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls", 94 | }, 95 | "ASSERTION_URL": "https://api.example.com", 96 | "ENTITY_ID": "https://api.example.com/sso/acs/", 97 | "NAME_ID_FORMAT": "user.email", 98 | "USE_JWT": True, 99 | "JWT_SECRET": "JWT_SECRET", 100 | "JWT_EXP": 60, 101 | "JWT_ALGORITHM": "HS256", 102 | "FRONTEND_URL": "https://app.example.com/account/login/saml", 103 | "LOGIN_CASE_SENSITIVE": False, 104 | "WANT_ASSERTIONS_SIGNED": True, 105 | "WANT_RESPONSE_SIGNED": True, 106 | "ALLOWED_REDIRECT_HOSTS": [ 107 | "https://app.example.com", 108 | "https://api.example.com", 109 | "https://example.com", 110 | ], 111 | "TOKEN_REQUIRED": True, 112 | } 113 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | pull_request: 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | test: 15 | name: Test django-saml2-auth 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | versions: 20 | - { "djangoVersion": "4.2.24", "pythonVersion": "3.10" } 21 | - { "djangoVersion": "4.2.24", "pythonVersion": "3.11" } 22 | - { "djangoVersion": "4.2.24", "pythonVersion": "3.12" } 23 | - { "djangoVersion": "5.1.12", "pythonVersion": "3.10" } 24 | - { "djangoVersion": "5.1.12", "pythonVersion": "3.11" } 25 | - { "djangoVersion": "5.1.12", "pythonVersion": "3.12" } 26 | - { "djangoVersion": "5.1.12", "pythonVersion": "3.13" } 27 | - { "djangoVersion": "5.2.6", "pythonVersion": "3.10" } 28 | - { "djangoVersion": "5.2.6", "pythonVersion": "3.11" } 29 | - { "djangoVersion": "5.2.6", "pythonVersion": "3.12" } 30 | - { "djangoVersion": "5.2.6", "pythonVersion": "3.13" } 31 | permissions: 32 | id-token: write 33 | contents: read 34 | steps: 35 | - name: Checkout 🛎️ 36 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 37 | with: 38 | persist-credentials: false 39 | - name: Set up Python 🐍 40 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 41 | with: 42 | python-version: ${{ matrix.versions.pythonVersion }} 43 | - name: Install uv 44 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 45 | with: 46 | enable-cache: false 47 | - name: Install xmlsec1 📦 48 | run: sudo apt-get install xmlsec1 49 | - name: Install dependencies 📦 50 | run: | 51 | uv sync 52 | uv add Django==${{ matrix.versions.djangoVersion }} 53 | - name: Check types, syntax and duckstrings 🦆 54 | run: | 55 | uv run python -m mypy --explicit-package-bases . 56 | uv run python -m ruff check . 57 | - name: Test Django ${{ matrix.versions.djangoVersion }} with coverage 🧪 58 | run: | 59 | uv run coverage run --source=django_saml2_auth -m pytest . && uv run coverage lcov -o coverage.lcov 60 | - name: Submit coverage report to Coveralls 📈 61 | uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 62 | with: 63 | github-token: ${{ secrets.GITHUB_TOKEN }} 64 | path-to-lcov: ./coverage.lcov 65 | flag-name: run-${{ join(matrix.versions.*, '-') }} 66 | parallel: true 67 | finish: 68 | needs: test 69 | if: ${{ always() }} 70 | runs-on: ubuntu-latest 71 | permissions: 72 | contents: read 73 | steps: 74 | - name: Coveralls Finished 75 | uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 76 | with: 77 | parallel-finished: true 78 | build: 79 | name: Build and Push django-saml2-auth to PyPI 80 | runs-on: ubuntu-latest 81 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} 82 | needs: test 83 | env: 84 | python-version: "3.10" 85 | permissions: 86 | contents: write 87 | id-token: write 88 | steps: 89 | - name: Checkout 🛎️ 90 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 91 | with: 92 | persist-credentials: false 93 | - name: Set up Python 🐍 94 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 95 | with: 96 | python-version: ${{ env.python-version }} 97 | - name: Install uv 98 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 99 | with: 100 | enable-cache: false 101 | - name: Install xmlsec1 📦 102 | run: sudo apt-get install xmlsec1 103 | - name: Install dependencies 📦 104 | run: | 105 | uv sync 106 | uv pip install build cyclonedx-bom twine 107 | - name: Generate CycloneDX SBOM artifacts 📃 108 | run: | 109 | uv run python -m cyclonedx_py env --pyproject pyproject.toml --of JSON -o django-saml2-auth-${GITHUB_REF_NAME}.cyclonedx.json 110 | - name: Build package 🏗️ 111 | run: | 112 | uv run python -m build 113 | - name: Publish to PyPI 📦 114 | run: | 115 | uv run python -m twine upload --username __token__ --password ${{ secrets.PYPI_API_TOKEN }} dist/* 116 | - name: Create release and add artifacts 🚀 117 | uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 118 | with: 119 | files: | 120 | dist/*.tar.gz 121 | dist/*.whl 122 | django-saml2-auth-${{ github.ref_name }}.cyclonedx.json 123 | draft: false 124 | prerelease: false 125 | tag_name: ${{ github.ref_name }} 126 | name: ${{ github.ref_name }} 127 | generate_release_notes: true 128 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for utils.py 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest, HttpResponse 7 | from django.urls import NoReverseMatch 8 | from pytest_django.fixtures import SettingsWrapper 9 | 10 | from django_saml2_auth.exceptions import SAMLAuthError 11 | from django_saml2_auth.utils import ( 12 | exception_handler, 13 | get_reverse, 14 | run_hook, 15 | is_jwt_well_formed, 16 | ) 17 | 18 | 19 | def divide(a: int, b: int = 1) -> int: 20 | """Simple division function for testing run_hook 21 | 22 | Args: 23 | a (int): Dividend 24 | b (int, optional): Divisor. Defaults to 1. 25 | 26 | Returns: 27 | int: Quotient 28 | """ 29 | return int(a / b) 30 | 31 | 32 | def hello(_: HttpRequest) -> HttpResponse: 33 | """Simple view function for testing exception_handler 34 | 35 | Args: 36 | _ (HttpRequest): Incoming HTTP request (not used) 37 | 38 | Returns: 39 | HttpResponse: Outgoing HTTP response 40 | """ 41 | return HttpResponse(content="Hello, world!") 42 | 43 | 44 | def goodbye(_: HttpRequest) -> HttpResponse: 45 | """Simple view function for testing exception_handler 46 | 47 | Args: 48 | _ (HttpRequest): Incoming HTTP request (not used) 49 | 50 | Raises: 51 | SAMLAuthError: Goodbye, world! 52 | """ 53 | raise SAMLAuthError( 54 | "Goodbye, world!", 55 | extra={ 56 | "exc": RuntimeError("World not found!"), 57 | "exc_type": RuntimeError, 58 | "error_code": 0, 59 | "reason": "Internal world error!", 60 | "status_code": 500, 61 | }, 62 | ) 63 | 64 | 65 | def test_run_hook_success(): 66 | """Test run_hook function against divide function imported from current module.""" 67 | result = run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=2) 68 | assert result == 1 69 | 70 | 71 | def test_run_hook_no_function_path(): 72 | """Test run_hook function by passing invalid function path and checking if it raises.""" 73 | with pytest.raises(SAMLAuthError) as exc_info: 74 | run_hook("") 75 | run_hook(None) 76 | 77 | assert str(exc_info.value) == "function_path isn't specified" 78 | 79 | 80 | def test_run_hook_nothing_to_import(): 81 | """Test run_hook function by passing function name only (no path) and checking if it raises.""" 82 | with pytest.raises(SAMLAuthError) as exc_info: 83 | run_hook("divide") 84 | 85 | assert str(exc_info.value) == "There's nothing to import. Check your hook's import path!" 86 | 87 | 88 | def test_run_hook_import_error(): 89 | """Test run_hook function by passing correct path, but nonexistent function and 90 | checking if it raises.""" 91 | with pytest.raises(SAMLAuthError) as exc_info: 92 | run_hook("django_saml2_auth.tests.test_utils.nonexistent_divide", 2, b=2) 93 | 94 | assert str(exc_info.value) == ( 95 | "module 'django_saml2_auth.tests.test_utils' has no attribute 'nonexistent_divide'" 96 | ) 97 | assert isinstance(exc_info.value.extra["exc"], AttributeError) 98 | assert exc_info.value.extra["exc_type"] is AttributeError 99 | 100 | 101 | def test_run_hook_division_by_zero(): 102 | """Test function imported by run_hook to verify if run_hook correctly captures the exception.""" 103 | with pytest.raises(SAMLAuthError) as exc_info: 104 | run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=0) 105 | 106 | assert str(exc_info.value) == "division by zero" 107 | # Actually a ZeroDivisionError wrapped in SAMLAuthError 108 | assert isinstance(exc_info.value.extra["exc"], ZeroDivisionError) 109 | assert exc_info.value.extra["exc_type"] is ZeroDivisionError 110 | 111 | 112 | def test_get_reverse_success(): 113 | """Test get_reverse with existing view.""" 114 | result = get_reverse("acs") 115 | assert result == "/acs/" 116 | 117 | 118 | def test_get_reverse_no_reverse_match(): 119 | """Test get_reverse with nonexistent view.""" 120 | with pytest.raises(SAMLAuthError) as exc_info: 121 | get_reverse("nonexistent_view") 122 | 123 | assert str(exc_info.value) == "We got a URL reverse issue: ['nonexistent_view']" 124 | assert issubclass(exc_info.value.extra["exc_type"], NoReverseMatch) 125 | 126 | 127 | def test_exception_handler_success(): 128 | """Test exception_handler decorator with a normal view function that returns response.""" 129 | decorated_hello = exception_handler(hello) 130 | result = decorated_hello(HttpRequest()) 131 | assert result.content.decode("utf-8") == "Hello, world!" 132 | 133 | 134 | def test_exception_handler_handle_exception(): 135 | """Test exception_handler decorator with a view function that raises exception and see if the 136 | exception_handler catches and returns the correct errors response.""" 137 | decorated_goodbye = exception_handler(goodbye) 138 | result = decorated_goodbye(HttpRequest()) 139 | contents = result.content.decode("utf-8") 140 | assert result.status_code == 500 141 | assert "Reason: Internal world error!" in contents 142 | 143 | 144 | def test_exception_handler_diabled_success(settings: SettingsWrapper): 145 | """Test exception_handler decorator in disabled state with a valid function.""" 146 | settings.SAML2_AUTH["DISABLE_EXCEPTION_HANDLER"] = True 147 | 148 | decorated_hello = exception_handler(hello) 149 | result = decorated_hello(HttpRequest()) 150 | assert result.content.decode("utf-8") == "Hello, world!" 151 | 152 | 153 | def test_exception_handler_disabled_on_exception(settings: SettingsWrapper): 154 | """Test exception_handler decorator in a disabled state to make sure it raises the 155 | exception.""" 156 | settings.SAML2_AUTH["DISABLE_EXCEPTION_HANDLER"] = True 157 | 158 | decorated_goodbye = exception_handler(goodbye) 159 | with pytest.raises(SAMLAuthError): 160 | decorated_goodbye(HttpRequest()) 161 | 162 | 163 | def test_jwt_well_formed(): 164 | """Test if passed RelayState is a well formed JWT""" 165 | token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MjQyIiwibmFtZSI6Ikplc3NpY2EgVGVtcG9yYWwiLCJuaWNrbmFtZSI6Ikplc3MifQ.EDkUUxaM439gWLsQ8a8mJWIvQtgZe0et3O3z4Fd_J8o" # noqa 166 | res = is_jwt_well_formed(token) # True 167 | assert res is True 168 | res = is_jwt_well_formed("/") # False 169 | assert res is False 170 | -------------------------------------------------------------------------------- /django_saml2_auth/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for dealing with various parts of the library. 2 | E.g. creating SAML client, creating user, exception handling, etc. 3 | """ 4 | 5 | import base64 6 | from functools import wraps 7 | from importlib import import_module 8 | import logging 9 | from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple, Union 10 | 11 | from dictor import dictor # type: ignore 12 | from django.conf import settings 13 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 14 | from django.shortcuts import render 15 | from django.urls import NoReverseMatch, reverse 16 | from django.utils.module_loading import import_string 17 | from django_saml2_auth.errors import ( 18 | EMPTY_FUNCTION_PATH, 19 | GENERAL_EXCEPTION, 20 | IMPORT_ERROR, 21 | NO_REVERSE_MATCH, 22 | PATH_ERROR, 23 | ) 24 | from django_saml2_auth.exceptions import SAMLAuthError 25 | 26 | 27 | def run_hook( 28 | function_path: str, 29 | *args: Optional[Tuple[Any]], 30 | **kwargs: Optional[Mapping[str, Any]], 31 | ) -> Optional[Any]: 32 | """Runs a hook function with given args and kwargs. For example, given 33 | "models.User.create_new_user", the "create_new_user" function is imported from 34 | the "models.User" module and run with args and kwargs. Functions can be 35 | imported directly from modules, without having to be inside any class. 36 | 37 | Args: 38 | function_path (str): A path to a hook function, 39 | e.g. models.User.create_new_user (static method) 40 | 41 | Raises: 42 | SAMLAuthError: function_path isn't specified 43 | SAMLAuthError: There's nothing to import. Check your hook's import path! 44 | SAMLAuthError: Import error 45 | SAMLAuthError: Re-raise any exception caused by the called function 46 | 47 | Returns: 48 | Optional[Any]: Any result returned from running the hook function. None is returned in case 49 | of any exceptions, errors in arguments and related issues. 50 | """ 51 | if not function_path: 52 | raise SAMLAuthError( 53 | "function_path isn't specified", 54 | extra={ 55 | "exc_type": ValueError, 56 | "error_code": EMPTY_FUNCTION_PATH, 57 | "reason": "There was an error processing your request.", 58 | "status_code": 500, 59 | }, 60 | ) 61 | 62 | path = function_path.split(".") 63 | if len(path) < 2: 64 | # Nothing to import 65 | raise SAMLAuthError( 66 | "There's nothing to import. Check your hook's import path!", 67 | extra={ 68 | "exc_type": ValueError, 69 | "error_code": PATH_ERROR, 70 | "reason": "There was an error processing your request.", 71 | "status_code": 500, 72 | }, 73 | ) 74 | 75 | module_path = ".".join(path[:-1]) 76 | result = None 77 | try: 78 | cls = import_module(module_path) 79 | except ModuleNotFoundError: 80 | try: 81 | cls = import_string(module_path) 82 | except ImportError as exc: 83 | raise SAMLAuthError( 84 | str(exc), 85 | extra={ 86 | "exc": exc, 87 | "exc_type": type(exc), 88 | "error_code": IMPORT_ERROR, 89 | "reason": "There was an error processing your request.", 90 | "status_code": 500, 91 | }, 92 | ) 93 | try: 94 | result = getattr(cls, path[-1])(*args, **kwargs) 95 | except SAMLAuthError as exc: 96 | # Re-raise the exception 97 | raise exc 98 | except AttributeError as exc: 99 | raise SAMLAuthError( 100 | str(exc), 101 | extra={ 102 | "exc": exc, 103 | "exc_type": type(exc), 104 | "error_code": IMPORT_ERROR, 105 | "reason": "There was an error processing your request.", 106 | "status_code": 500, 107 | }, 108 | ) 109 | except Exception as exc: 110 | raise SAMLAuthError( 111 | str(exc), 112 | extra={ 113 | "exc": exc, 114 | "exc_type": type(exc), 115 | "error_code": GENERAL_EXCEPTION, 116 | "reason": "There was an error processing your request.", 117 | "status_code": 500, 118 | }, 119 | ) 120 | 121 | return result 122 | 123 | 124 | def get_reverse(objects: Union[Any, Iterable[Any]]) -> Optional[str]: 125 | """Given one or a list of views/urls(s), returns the corresponding URL to that view. 126 | 127 | Args: 128 | objects (Union[Any, Iterable[Any]]): One or many views/urls representing a resource 129 | 130 | Raises: 131 | SAMLAuthError: We got a URL reverse issue: [...] 132 | 133 | Returns: 134 | Optional[str]: The URL to the resource or None. 135 | """ 136 | if not isinstance(objects, (list, tuple)): 137 | objects = [objects] 138 | 139 | for obj in objects: 140 | try: 141 | return reverse(obj) 142 | except NoReverseMatch: 143 | pass 144 | raise SAMLAuthError( 145 | f"We got a URL reverse issue: {str(objects)}", 146 | extra={ 147 | "exc_type": NoReverseMatch, 148 | "error_code": NO_REVERSE_MATCH, 149 | "reason": "There was an error processing your request.", 150 | "status_code": 500, 151 | }, 152 | ) 153 | 154 | 155 | def exception_handler( 156 | function: Callable[..., Union[HttpResponse, HttpResponseRedirect]], 157 | ) -> Callable[..., Union[HttpResponse, HttpResponseRedirect]]: 158 | """This decorator can be used by view function to handle exceptions 159 | 160 | Args: 161 | function (Callable[..., Union[HttpResponse, HttpResponseRedirect]]): 162 | View function to decorate 163 | 164 | Returns: 165 | Callable[..., Union[HttpResponse, HttpResponseRedirect]]: 166 | Decorated view function with exception handling 167 | """ 168 | 169 | if dictor(settings.SAML2_AUTH, "DISABLE_EXCEPTION_HANDLER", False): 170 | return function 171 | 172 | def handle_exception(exc: Exception, request: HttpRequest) -> HttpResponse: 173 | """Render page with exception details 174 | 175 | Args: 176 | exc (Exception): An exception 177 | request (HttpRequest): Incoming http request object 178 | 179 | Returns: 180 | HttpResponse: Rendered error page with details 181 | """ 182 | logger = logging.getLogger(__name__) 183 | if dictor(settings.SAML2_AUTH, "DEBUG", False): 184 | # Log the exception with traceback 185 | logger.exception(exc) 186 | else: 187 | # Log the exception without traceback 188 | logger.debug(exc) 189 | 190 | context: Optional[Dict[str, Any]] = exc.extra if isinstance(exc, SAMLAuthError) else {} 191 | if isinstance(exc, SAMLAuthError) and exc.extra: 192 | status = exc.extra.get("status_code") 193 | else: 194 | status = 500 195 | 196 | return render(request, "django_saml2_auth/error.html", context=context, status=status) 197 | 198 | @wraps(function) 199 | def wrapper(request: HttpRequest) -> HttpResponse: 200 | """Decorated function is wrapped and called here 201 | 202 | Args: 203 | request ([type]): [description] 204 | 205 | Returns: 206 | HttpResponse: Either a redirect or a response with error details 207 | """ 208 | result = None 209 | try: 210 | result = function(request) 211 | except (SAMLAuthError, Exception) as exc: 212 | result = handle_exception(exc, request) 213 | return result 214 | 215 | return wrapper 216 | 217 | 218 | def is_jwt_well_formed(jwt: str): 219 | """Check if JWT is well formed 220 | 221 | Args: 222 | jwt (str): Json Web Token 223 | 224 | Returns: 225 | Boolean: True if JWT is well formed, otherwise False 226 | """ 227 | if isinstance(jwt, str): 228 | # JWT should contain three segments, separated by two period ('.') characters. 229 | jwt_segments = jwt.split(".") 230 | if len(jwt_segments) == 3: 231 | jose_header = jwt_segments[0] 232 | # base64-encoded string length should be a multiple of 4 233 | if len(jose_header) % 4 == 0: 234 | try: 235 | jh_decoded = base64.b64decode(jose_header).decode("utf-8") 236 | if jh_decoded and jh_decoded.find("JWT") > -1: 237 | return True 238 | except Exception: 239 | return False 240 | # If tests not passed return False 241 | return False 242 | -------------------------------------------------------------------------------- /django_saml2_auth/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | 4 | """Endpoints for SAML SSO login""" 5 | 6 | import urllib.parse as urlparse 7 | from typing import Optional, Union 8 | from urllib.parse import unquote 9 | 10 | from dictor import dictor # type: ignore 11 | from django.conf import settings 12 | from django.contrib.auth import login, logout 13 | from django.contrib.auth.decorators import login_required 14 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 15 | from django.shortcuts import render 16 | from django.template import TemplateDoesNotExist 17 | 18 | try: 19 | from django.utils.http import url_has_allowed_host_and_scheme as is_safe_url 20 | except ImportError: 21 | from django.utils.http import is_safe_url # type: ignore 22 | 23 | from django.views.decorators.csrf import csrf_exempt 24 | from django_saml2_auth.errors import ( 25 | INACTIVE_USER, 26 | INVALID_NEXT_URL, 27 | INVALID_REQUEST_METHOD, 28 | INVALID_TOKEN, 29 | USER_MISMATCH, 30 | ) 31 | from django_saml2_auth.exceptions import SAMLAuthError 32 | from django_saml2_auth.saml import ( 33 | decode_saml_response, 34 | extract_user_identity, 35 | get_assertion_url, 36 | get_default_next_url, 37 | get_saml_client, 38 | ) 39 | from django_saml2_auth.user import ( 40 | create_custom_or_default_jwt, 41 | decode_custom_or_default_jwt, 42 | get_or_create_user, 43 | get_user_id, 44 | ) 45 | from django_saml2_auth.utils import ( 46 | exception_handler, 47 | get_reverse, 48 | is_jwt_well_formed, 49 | run_hook, 50 | ) 51 | 52 | 53 | @login_required 54 | def welcome(request: HttpRequest) -> Union[HttpResponse, HttpResponseRedirect]: 55 | """Default welcome page 56 | 57 | Args: 58 | request (HttpRequest): Django request object. 59 | 60 | Returns: 61 | Union[HttpResponse, HttpResponseRedirect]: Django response or redirect object. 62 | """ 63 | try: 64 | return render(request, "django_saml2_auth/welcome.html", {"user": request.user}) 65 | except TemplateDoesNotExist: 66 | default_next_url = get_default_next_url() 67 | return ( 68 | HttpResponseRedirect(default_next_url) 69 | if default_next_url 70 | else HttpResponseRedirect("/") 71 | ) 72 | 73 | 74 | def denied(request: HttpRequest) -> HttpResponse: 75 | """Default access denied page 76 | 77 | Args: 78 | request (HttpRequest): Django request object. 79 | 80 | Returns: 81 | HttpResponse: Render access denied page. 82 | """ 83 | return render(request, "django_saml2_auth/denied.html") 84 | 85 | 86 | @csrf_exempt 87 | @exception_handler 88 | def acs(request: HttpRequest): 89 | """Assertion Consumer Service is SAML terminology for the location at a ServiceProvider that 90 | accepts messages (or SAML artifacts) for the purpose of establishing a session 91 | based on an assertion. Assertion is a signed authentication request from identity provider (IdP) 92 | to acs endpoint. 93 | 94 | Args: 95 | request (HttpRequest): Incoming request from identity provider (IdP) for authentication 96 | 97 | Exceptions: 98 | SAMLAuthError: The target user is inactive. 99 | 100 | Returns: 101 | HttpResponseRedirect: Redirect to various endpoints: denied, welcome or next_url (e.g. 102 | the front-end app) 103 | 104 | Notes: 105 | https://wiki.shibboleth.net/confluence/display/CONCEPT/AssertionConsumerService 106 | """ 107 | saml2_auth_settings = settings.SAML2_AUTH 108 | 109 | authn_response = decode_saml_response(request, acs) 110 | # decode_saml_response() will raise SAMLAuthError if the response is invalid, 111 | # so we can safely ignore the type check here. 112 | user = extract_user_identity(authn_response) # type: ignore 113 | 114 | next_url = request.session.get("login_next_url") 115 | 116 | # A RelayState is an HTTP parameter that can be included as part of the SAML request 117 | # and SAML response; usually is meant to be an opaque identifier that is passed back 118 | # without any modification or inspection, and it is used to specify additional information 119 | # to the SP or the IdP. 120 | # If RelayState params is passed, it could be JWT token that identifies the user trying to 121 | # login via sp_initiated_login endpoint, or it could be a URL used for redirection. 122 | relay_state = request.POST.get("RelayState") 123 | relay_state_is_token = is_jwt_well_formed(relay_state) if relay_state else False 124 | if next_url is None and relay_state and not relay_state_is_token: 125 | next_url = relay_state 126 | elif next_url is None: 127 | next_url = get_default_next_url() 128 | 129 | if relay_state and relay_state_is_token: 130 | redirected_user_id = decode_custom_or_default_jwt(relay_state) 131 | 132 | # This prevents users from entering an email on the SP, but use a different email on IdP 133 | if get_user_id(user) != redirected_user_id: 134 | raise SAMLAuthError( 135 | "The user identifier doesn't match.", 136 | extra={ 137 | "exc_type": ValueError, 138 | "error_code": USER_MISMATCH, 139 | "reason": "User identifier mismatch.", 140 | "status_code": 403, 141 | }, 142 | ) 143 | 144 | is_new_user, target_user = get_or_create_user(user) 145 | 146 | before_login_trigger = dictor(saml2_auth_settings, "TRIGGER.BEFORE_LOGIN") 147 | if before_login_trigger: 148 | run_hook(before_login_trigger, user) # type: ignore 149 | 150 | request.session.flush() 151 | 152 | if target_user.is_active: 153 | # Try to load from the `AUTHENTICATION_BACKENDS` setting in settings.py 154 | if hasattr(settings, "AUTHENTICATION_BACKENDS") and settings.AUTHENTICATION_BACKENDS: 155 | model_backend = settings.AUTHENTICATION_BACKENDS[0] 156 | else: 157 | model_backend = "django.contrib.auth.backends.ModelBackend" 158 | 159 | login(request, target_user, model_backend) 160 | 161 | after_login_trigger = dictor(saml2_auth_settings, "TRIGGER.AFTER_LOGIN") 162 | if after_login_trigger: 163 | run_hook(after_login_trigger, request.session, user) # type: ignore 164 | else: 165 | raise SAMLAuthError( 166 | "The target user is inactive.", 167 | extra={ 168 | "exc_type": Exception, 169 | "error_code": INACTIVE_USER, 170 | "reason": "User is inactive.", 171 | "status_code": 500, 172 | }, 173 | ) 174 | 175 | use_jwt = dictor(saml2_auth_settings, "USE_JWT", False) 176 | if use_jwt: 177 | # Create a new JWT token for IdP-initiated login (acs) 178 | jwt_token = create_custom_or_default_jwt(target_user) 179 | custom_token_query_trigger = dictor(saml2_auth_settings, "TRIGGER.CUSTOM_TOKEN_QUERY") 180 | query = "" # Initialize query variable 181 | if custom_token_query_trigger: 182 | query_result = run_hook(custom_token_query_trigger, jwt_token) 183 | query = query_result if query_result is not None else "" 184 | 185 | # Use JWT auth to send token to frontend 186 | frontend_url = dictor(saml2_auth_settings, "FRONTEND_URL", next_url) 187 | custom_frontend_url_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_CUSTOM_FRONTEND_URL") 188 | if custom_frontend_url_trigger: 189 | frontend_url = run_hook(custom_frontend_url_trigger, relay_state) # type: ignore 190 | 191 | # Parse the frontend URL to handle query parameters properly 192 | try: 193 | parsed_url = urlparse.urlparse(frontend_url) 194 | if not custom_token_query_trigger: 195 | # Default behavior: add JWT token to existing query parameters 196 | existing_query = urlparse.parse_qs(parsed_url.query) 197 | existing_query.setdefault("token", []).append(jwt_token) 198 | query_string = urlparse.urlencode(existing_query, doseq=True) 199 | new_parse = parsed_url._replace(query=query_string) 200 | destination_url = urlparse.urlunparse(new_parse) 201 | else: 202 | # Custom: merge custom query with existing query parameters 203 | existing_query = urlparse.parse_qs(parsed_url.query) 204 | custom_query = urlparse.parse_qs(query.lstrip("?")) 205 | existing_query.update(custom_query) 206 | query_string = urlparse.urlencode(existing_query, doseq=True) 207 | new_parse = parsed_url._replace(query=query_string) 208 | destination_url = urlparse.urlunparse(new_parse) 209 | except (ValueError, TypeError): 210 | # If URL parsing fails, fall back to simple string concatenation to 211 | # maintain backward compatibility with the old behavior 212 | destination_url = frontend_url + query 213 | 214 | return HttpResponseRedirect(destination_url) 215 | 216 | def redirect(redirect_url: Optional[str] = None) -> HttpResponseRedirect: 217 | """Redirect to the redirect_url or the root page. 218 | 219 | Args: 220 | redirect_url (str, optional): Redirect URL. Defaults to None. 221 | 222 | Returns: 223 | HttpResponseRedirect: Redirect to the redirect_url or the root page. 224 | """ 225 | if redirect_url: 226 | return HttpResponseRedirect(redirect_url) 227 | else: 228 | return HttpResponseRedirect("/") 229 | 230 | if is_new_user: 231 | try: 232 | return render(request, "django_saml2_auth/welcome.html", {"user": request.user}) 233 | except TemplateDoesNotExist: 234 | return redirect(next_url) 235 | else: 236 | return redirect(next_url) 237 | 238 | 239 | @exception_handler 240 | def sp_initiated_login(request: HttpRequest) -> HttpResponseRedirect: 241 | """This view is called by the SP to initiate a login to IdP, aka. SP-initiated SAML SSP. 242 | 243 | Args: 244 | request (HttpRequest): Incoming request from service provider (SP) for authentication 245 | 246 | Returns: 247 | HttpResponseRedirect: Redirect to the IdP login endpoint 248 | """ 249 | # User must be created first by the IdP-initiated SSO (acs) 250 | if request.method == "GET": 251 | token = request.GET.get("token") 252 | if token: 253 | user_id = decode_custom_or_default_jwt(token) 254 | if not user_id: 255 | raise SAMLAuthError( 256 | "The token is invalid.", 257 | extra={ 258 | "exc_type": ValueError, 259 | "error_code": INVALID_TOKEN, 260 | "reason": "The token is invalid.", 261 | "status_code": 403, 262 | }, 263 | ) 264 | saml_client = get_saml_client(get_assertion_url(request), acs, user_id) 265 | jwt_token = create_custom_or_default_jwt(user_id) 266 | _, info = saml_client.prepare_for_authenticate( # type: ignore 267 | sign=False, relay_state=jwt_token 268 | ) 269 | redirect_url = dict(info["headers"]).get("Location", "") 270 | if not redirect_url: 271 | return HttpResponseRedirect( 272 | get_reverse([denied, "denied", "django_saml2_auth:denied"]) # type: ignore 273 | ) 274 | return HttpResponseRedirect(redirect_url) 275 | else: 276 | raise SAMLAuthError( 277 | "Request method is not supported.", 278 | extra={ 279 | "exc_type": Exception, 280 | "error_code": INVALID_REQUEST_METHOD, 281 | "reason": "Request method is not supported.", 282 | "status_code": 404, 283 | }, 284 | ) 285 | return HttpResponseRedirect(get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore 286 | 287 | 288 | @exception_handler 289 | def signin(request: HttpRequest) -> HttpResponseRedirect: 290 | """Custom sign-in view for SP-initiated SSO. This will be deprecated in the future 291 | in favor of sp_initiated_login. 292 | 293 | Args: 294 | request (HttpRequest): Incoming request from service provider (SP) for authentication. 295 | 296 | Raises: 297 | SAMLAuthError: The next URL is invalid. 298 | 299 | Returns: 300 | HttpResponseRedirect: Redirect to the IdP login endpoint 301 | """ 302 | saml2_auth_settings = settings.SAML2_AUTH 303 | 304 | next_url = request.GET.get("next") or get_default_next_url() 305 | if not next_url: 306 | raise SAMLAuthError( 307 | "The next URL is invalid.", 308 | extra={ 309 | "exc_type": ValueError, 310 | "error_code": INVALID_NEXT_URL, 311 | "reason": "The next URL is invalid.", 312 | "status_code": 403, 313 | }, 314 | ) 315 | 316 | try: 317 | if "next=" in unquote(next_url): 318 | parsed_next_url = urlparse.parse_qs(urlparse.urlparse(unquote(next_url)).query) 319 | next_url = dictor(parsed_next_url, "next.0") 320 | except Exception: 321 | next_url = request.GET.get("next") or get_default_next_url() 322 | 323 | # Only permit signin requests where the next_url is a safe URL 324 | allowed_hosts = set(dictor(saml2_auth_settings, "ALLOWED_REDIRECT_HOSTS", [])) 325 | url_ok = is_safe_url(next_url, allowed_hosts) 326 | 327 | if not url_ok: 328 | return HttpResponseRedirect(get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore 329 | 330 | request.session["login_next_url"] = next_url 331 | 332 | saml_client = get_saml_client(get_assertion_url(request), acs) 333 | _, info = saml_client.prepare_for_authenticate(relay_state=next_url) # type: ignore 334 | 335 | redirect_url = dict(info["headers"]).get("Location", "") 336 | return HttpResponseRedirect(redirect_url) 337 | 338 | 339 | @exception_handler 340 | def signout(request: HttpRequest) -> HttpResponse: 341 | """Custom sign-out view. 342 | 343 | Args: 344 | request (HttpRequest): Django request object. 345 | 346 | Returns: 347 | HttpResponse: Render the logout page. 348 | """ 349 | logout(request) 350 | return render(request, "django_saml2_auth/signout.html") 351 | -------------------------------------------------------------------------------- /django_saml2_auth/user.py: -------------------------------------------------------------------------------- 1 | """Utility functions for getting or creating user accounts""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | from typing import Any, Dict, Optional, Tuple, Union 5 | 6 | import jwt 7 | from cryptography.hazmat.primitives import serialization 8 | from dictor import dictor # type: ignore 9 | from django.conf import settings 10 | from django.contrib.auth import get_user_model 11 | from django.contrib.auth.models import Group, User 12 | from django_saml2_auth.errors import ( 13 | CANNOT_DECODE_JWT_TOKEN, 14 | CREATE_USER_ERROR, 15 | GROUP_JOIN_ERROR, 16 | INVALID_JWT_ALGORITHM, 17 | NO_JWT_ALGORITHM, 18 | NO_JWT_PRIVATE_KEY, 19 | NO_JWT_PUBLIC_KEY, 20 | NO_JWT_SECRET, 21 | NO_USER_ID, 22 | SHOULD_NOT_CREATE_USER, 23 | ) 24 | from django_saml2_auth.exceptions import SAMLAuthError 25 | from django_saml2_auth.utils import run_hook 26 | from jwt.algorithms import get_default_algorithms, has_crypto, requires_cryptography 27 | from jwt.exceptions import PyJWTError 28 | 29 | 30 | def create_new_user( 31 | email: str, 32 | first_name: Optional[str] = None, 33 | last_name: Optional[str] = None, 34 | **kwargs, 35 | ) -> User: 36 | """Create a new user with the given information 37 | 38 | Args: 39 | email (str): Email 40 | first_name (str): First name 41 | last_name (str): Last name 42 | 43 | Keyword Args: 44 | **kwargs: Additional keyword arguments 45 | 46 | Raises: 47 | SAMLAuthError: There was an error creating the new user. 48 | SAMLAuthError: There was an error joining the user to the group. 49 | 50 | Returns: 51 | User: Returns a new user object, usually a subclass of the the User model 52 | """ 53 | saml2_auth_settings = settings.SAML2_AUTH 54 | user_model = get_user_model() 55 | 56 | is_active = dictor(saml2_auth_settings, "NEW_USER_PROFILE.ACTIVE_STATUS", default=True) 57 | is_staff = dictor(saml2_auth_settings, "NEW_USER_PROFILE.STAFF_STATUS", default=False) 58 | is_superuser = dictor(saml2_auth_settings, "NEW_USER_PROFILE.SUPERUSER_STATUS", default=False) 59 | user_groups = dictor(saml2_auth_settings, "NEW_USER_PROFILE.USER_GROUPS", default=[]) 60 | 61 | if first_name and last_name: 62 | kwargs["first_name"] = first_name 63 | kwargs["last_name"] = last_name 64 | 65 | try: 66 | user = user_model.objects.create_user(email, **kwargs) 67 | user.is_active = is_active 68 | user.is_staff = is_staff 69 | user.is_superuser = is_superuser 70 | user.save() 71 | except Exception as exc: 72 | raise SAMLAuthError( 73 | "There was an error creating the new user.", 74 | extra={ 75 | "exc": exc, 76 | "exc_type": type(exc), 77 | "error_code": CREATE_USER_ERROR, 78 | "reason": "There was an error processing your request.", 79 | "status_code": 500, 80 | }, 81 | ) 82 | 83 | try: 84 | groups = [Group.objects.get(name=group) for group in user_groups] 85 | if groups: 86 | user.groups.set(groups) 87 | except Exception as exc: 88 | raise SAMLAuthError( 89 | "There was an error joining the user to the group.", 90 | extra={ 91 | "exc": exc, 92 | "exc_type": type(exc), 93 | "error_code": GROUP_JOIN_ERROR, 94 | "reason": "There was an error processing your request.", 95 | "status_code": 500, 96 | }, 97 | ) 98 | 99 | user.save() 100 | user.refresh_from_db() 101 | 102 | return user 103 | 104 | 105 | def get_or_create_user(user: Dict[str, Any]) -> Tuple[bool, User]: 106 | """Get or create a new user and optionally add it to one or more group(s) 107 | 108 | Args: 109 | user (Dict[str, Any]): User information 110 | 111 | Raises: 112 | SAMLAuthError: Cannot create user. Missing user_id. 113 | SAMLAuthError: Cannot create user. 114 | 115 | Returns: 116 | Tuple[bool, User]: A tuple containing user creation status and user object 117 | """ 118 | saml2_auth_settings = settings.SAML2_AUTH 119 | user_model = get_user_model() 120 | created = False 121 | 122 | try: 123 | target_user = get_user(user) 124 | except user_model.DoesNotExist: 125 | should_create_new_user = dictor(saml2_auth_settings, "CREATE_USER", True) 126 | if should_create_new_user: 127 | user_id = get_user_id(user) 128 | if not user_id: 129 | raise SAMLAuthError( 130 | "Cannot create user. Missing user_id.", 131 | extra={ 132 | "error_code": SHOULD_NOT_CREATE_USER, 133 | "reason": "Cannot create user. Missing user_id.", 134 | "status_code": 400, 135 | }, 136 | ) 137 | target_user = create_new_user(user_id, user["first_name"], user["last_name"]) 138 | 139 | create_user_trigger = dictor(saml2_auth_settings, "TRIGGER.CREATE_USER") 140 | if create_user_trigger: 141 | run_hook(create_user_trigger, user) # type: ignore 142 | 143 | target_user.refresh_from_db() 144 | created = True 145 | else: 146 | raise SAMLAuthError( 147 | "Cannot create user.", 148 | extra={ 149 | "exc_type": Exception, 150 | "error_code": SHOULD_NOT_CREATE_USER, 151 | "reason": "Due to current config, a new user should not be created.", 152 | "status_code": 500, 153 | }, 154 | ) 155 | 156 | # Optionally update this user's group assignments by updating group memberships from SAML groups 157 | # to Django equivalents 158 | group_attribute = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.groups") 159 | group_map = dictor(saml2_auth_settings, "GROUPS_MAP") 160 | 161 | if group_attribute and group_attribute in user["user_identity"]: 162 | groups = [] 163 | 164 | for group_name in user["user_identity"][group_attribute]: 165 | # Group names can optionally be mapped to different names in Django 166 | if group_map and group_name in group_map: 167 | group_name_django = group_map[group_name] 168 | else: 169 | group_name_django = group_name 170 | 171 | try: 172 | groups.append(Group.objects.get(name=group_name_django)) 173 | except Group.DoesNotExist: 174 | should_create_new_groups = dictor(saml2_auth_settings, "CREATE_GROUPS", False) 175 | if should_create_new_groups: 176 | groups.append(Group.objects.create(name=group_name_django)) 177 | 178 | target_user.groups.set(groups) 179 | 180 | return (created, target_user) 181 | 182 | 183 | def get_user_id(user: Union[str, Dict[str, Any]]) -> Optional[str]: 184 | """Get user_id (username or email) from user object 185 | 186 | Args: 187 | user (Union[str, Dict[str, Any]]): A cleaned user info object 188 | 189 | Returns: 190 | Optional[str]: user_id, which is either email or username 191 | """ 192 | user_model = get_user_model() 193 | user_id = None 194 | 195 | if isinstance(user, dict): 196 | user_id = user["email"] if user_model.USERNAME_FIELD == "email" else user["username"] 197 | 198 | if isinstance(user, str): 199 | user_id = user 200 | 201 | return user_id.lower() if user_id else None 202 | 203 | 204 | def get_user(user: Union[str, Dict[str, str]]) -> User: 205 | """Get user from database given a cleaned user info object or a user_id 206 | 207 | Args: 208 | user (Union[str, Dict[str, str]]): Either a user_id (as str) or a cleaned user info object 209 | 210 | Returns: 211 | User: An instance of the User model 212 | """ 213 | saml2_auth_settings = settings.SAML2_AUTH 214 | get_user_custom_method = dictor(saml2_auth_settings, "TRIGGER.GET_USER") 215 | 216 | user_model = get_user_model() 217 | if get_user_custom_method: 218 | found_user = run_hook(get_user_custom_method, user) # type: ignore 219 | if not found_user: 220 | raise user_model.DoesNotExist 221 | else: 222 | return found_user 223 | 224 | user_id = get_user_id(user) 225 | 226 | # Should email be case-sensitive or not. Default is False (case-insensitive). 227 | login_case_sensitive = dictor(saml2_auth_settings, "LOGIN_CASE_SENSITIVE", False) 228 | id_field = ( 229 | user_model.USERNAME_FIELD 230 | if login_case_sensitive 231 | else f"{user_model.USERNAME_FIELD}__iexact" 232 | ) 233 | return user_model.objects.get(**{id_field: user_id}) 234 | 235 | 236 | def validate_jwt_algorithm(jwt_algorithm: str) -> None: 237 | """Validate JWT algorithm 238 | 239 | Args: 240 | jwt_algorithm (str): JWT algorithm 241 | 242 | Raises: 243 | SAMLAuthError: Cannot encode/decode JWT token. Specify an algorithm. 244 | SAMLAuthError: Cannot encode/decode JWT token. Specify a valid algorithm. 245 | """ 246 | if not jwt_algorithm: 247 | raise SAMLAuthError( 248 | "Cannot encode/decode JWT token. Specify an algorithm.", 249 | extra={ 250 | "exc_type": Exception, 251 | "error_code": NO_JWT_ALGORITHM, 252 | "reason": "Cannot create JWT token for login.", 253 | "status_code": 500, 254 | }, 255 | ) 256 | 257 | if jwt_algorithm not in list(get_default_algorithms()): 258 | raise SAMLAuthError( 259 | "Cannot encode/decode JWT token. Specify a valid algorithm.", 260 | extra={ 261 | "exc_type": Exception, 262 | "error_code": INVALID_JWT_ALGORITHM, 263 | "reason": "Cannot encode/decode JWT token for login.", 264 | "status_code": 500, 265 | }, 266 | ) 267 | 268 | 269 | def validate_secret(jwt_algorithm: str, jwt_secret: str) -> None: 270 | """Validate symmetric encryption key 271 | 272 | Args: 273 | jwt_algorithm (str): JWT algorithm 274 | jwt_secret (str): JWT secret 275 | 276 | Raises: 277 | SAMLAuthError: Cannot encode/decode JWT token. Specify a secret. 278 | """ 279 | if jwt_algorithm not in requires_cryptography and not jwt_secret: 280 | raise SAMLAuthError( 281 | "Cannot encode/decode JWT token. Specify a secret.", 282 | extra={ 283 | "exc_type": Exception, 284 | "error_code": NO_JWT_SECRET, 285 | "reason": "Cannot encode/decode JWT token for login.", 286 | "status_code": 500, 287 | }, 288 | ) 289 | 290 | 291 | def validate_private_key(jwt_algorithm: str, jwt_private_key: str) -> None: 292 | """Validate private key 293 | 294 | Args: 295 | jwt_algorithm (str): JWT algorithm 296 | jwt_private_key (str): JWT private key 297 | 298 | Raises: 299 | SAMLAuthError: Cannot encode/decode JWT token. Specify a private key. 300 | """ 301 | if (jwt_algorithm in requires_cryptography and has_crypto) and not jwt_private_key: 302 | raise SAMLAuthError( 303 | "Cannot encode/decode JWT token. Specify a private key.", 304 | extra={ 305 | "exc_type": Exception, 306 | "error_code": NO_JWT_PRIVATE_KEY, 307 | "reason": "Cannot encode/decode JWT token for login.", 308 | "status_code": 500, 309 | }, 310 | ) 311 | 312 | 313 | def validate_public_key(jwt_algorithm: str, jwt_public_key: str) -> None: 314 | """Validate public key 315 | 316 | Args: 317 | jwt_algorithm (str): JWT algorithm 318 | jwt_public_key (str): JWT public key 319 | 320 | Raises: 321 | SAMLAuthError: Cannot encode/decode JWT token. Specify a public key. 322 | """ 323 | if (jwt_algorithm in requires_cryptography and has_crypto) and not jwt_public_key: 324 | raise SAMLAuthError( 325 | "Cannot encode/decode JWT token. Specify a public key.", 326 | extra={ 327 | "exc_type": Exception, 328 | "error_code": NO_JWT_PUBLIC_KEY, 329 | "reason": "Cannot encode/decode JWT token for login.", 330 | "status_code": 500, 331 | }, 332 | ) 333 | 334 | 335 | def create_jwt_token(user_id: str) -> Optional[str]: 336 | """Create a new JWT token 337 | 338 | Args: 339 | user_id (str): User's username or email based on User.USERNAME_FIELD 340 | 341 | Returns: 342 | Optional[str]: JWT token 343 | """ 344 | saml2_auth_settings = settings.SAML2_AUTH 345 | user_model = get_user_model() 346 | 347 | jwt_algorithm = dictor(saml2_auth_settings, "JWT_ALGORITHM") 348 | validate_jwt_algorithm(jwt_algorithm) 349 | 350 | jwt_secret = dictor(saml2_auth_settings, "JWT_SECRET") 351 | validate_secret(jwt_algorithm, jwt_secret) 352 | 353 | jwt_private_key = dictor(saml2_auth_settings, "JWT_PRIVATE_KEY") 354 | validate_private_key(jwt_algorithm, jwt_private_key) 355 | 356 | jwt_private_key_passphrase = dictor(saml2_auth_settings, "JWT_PRIVATE_KEY_PASSPHRASE") 357 | jwt_expiration = dictor(saml2_auth_settings, "JWT_EXP", 60) # default: 1 minute 358 | 359 | payload = { 360 | user_model.USERNAME_FIELD: user_id, 361 | "exp": (datetime.now(tz=timezone.utc) + timedelta(seconds=jwt_expiration)).timestamp(), 362 | } 363 | 364 | # If a passphrase is specified, we need to use a PEM-encoded private key 365 | # to decrypt the private key in order to encode the JWT token. 366 | if jwt_private_key_passphrase: 367 | if isinstance(jwt_private_key, str): 368 | jwt_private_key = jwt_private_key.encode() 369 | if isinstance(jwt_private_key_passphrase, str): 370 | jwt_private_key_passphrase = jwt_private_key_passphrase.encode() 371 | 372 | # load_pem_private_key requires data and password to be in bytes 373 | jwt_private_key = serialization.load_pem_private_key( 374 | data=jwt_private_key, password=jwt_private_key_passphrase 375 | ) 376 | 377 | secret = ( 378 | jwt_secret 379 | if (jwt_secret and jwt_algorithm not in requires_cryptography) 380 | else jwt_private_key 381 | ) 382 | 383 | return jwt.encode(payload, secret, algorithm=jwt_algorithm) 384 | 385 | 386 | def create_custom_or_default_jwt(user: Union[str, User]): 387 | """Create a new JWT token, eventually using custom trigger 388 | 389 | Args: 390 | user (Union[str, User]): User instance or User's username or email 391 | based on User.USERNAME_FIELD 392 | 393 | Raises: 394 | SAMLAuthError: Cannot create JWT token. Specify a user. 395 | 396 | Returns: 397 | Optional[str]: JWT token 398 | """ 399 | saml2_auth_settings = settings.SAML2_AUTH 400 | user_model = get_user_model() 401 | 402 | custom_create_jwt_trigger = dictor(saml2_auth_settings, "TRIGGER.CUSTOM_CREATE_JWT") 403 | 404 | # If user is the id (user_model.USERNAME_FIELD), set it as user_id 405 | user_id: Optional[str] = None 406 | if isinstance(user, str): 407 | user_id = user 408 | 409 | # Check if there is a custom trigger for creating the JWT and URL query 410 | if custom_create_jwt_trigger: 411 | target_user = user 412 | # If user is user_id, get user instance 413 | if user_id: 414 | user_model = get_user_model() 415 | _user = {user_model.USERNAME_FIELD: user_id} 416 | target_user = get_user(_user) 417 | jwt_token = run_hook(custom_create_jwt_trigger, target_user) # type: ignore 418 | else: 419 | # If user_id is not set, retrieve it from user instance 420 | if not user_id: 421 | user_id = getattr(user, user_model.USERNAME_FIELD) 422 | # Create a new JWT token with PyJWT 423 | if not user_id: 424 | raise SAMLAuthError( 425 | "Cannot create JWT token. Specify a user.", 426 | extra={ 427 | "exc_type": Exception, 428 | "error_code": NO_USER_ID, 429 | "reason": "Cannot create JWT token for login.", 430 | "status_code": 500, 431 | }, 432 | ) 433 | jwt_token = create_jwt_token(user_id) 434 | 435 | return jwt_token 436 | 437 | 438 | def decode_jwt_token(jwt_token: str) -> Optional[str]: 439 | """Decode a JWT token 440 | 441 | Args: 442 | jwt_token (str): The token to decode 443 | 444 | Raises: 445 | SAMLAuthError: Cannot decode JWT token. 446 | 447 | Returns: 448 | Optional[str]: A user_id as str or None. 449 | """ 450 | saml2_auth_settings = settings.SAML2_AUTH 451 | 452 | jwt_algorithm = dictor(saml2_auth_settings, "JWT_ALGORITHM") 453 | validate_jwt_algorithm(jwt_algorithm) 454 | 455 | jwt_secret = dictor(saml2_auth_settings, "JWT_SECRET") 456 | validate_secret(jwt_algorithm, jwt_secret) 457 | 458 | jwt_public_key = dictor(saml2_auth_settings, "JWT_PUBLIC_KEY") 459 | validate_public_key(jwt_algorithm, jwt_public_key) 460 | 461 | secret = ( 462 | jwt_secret 463 | if (jwt_secret and jwt_algorithm not in requires_cryptography) 464 | else jwt_public_key 465 | ) 466 | 467 | try: 468 | data = jwt.decode(jwt_token, secret, algorithms=jwt_algorithm) 469 | user_model = get_user_model() 470 | return data[user_model.USERNAME_FIELD] 471 | except PyJWTError as exc: 472 | raise SAMLAuthError( 473 | "Cannot decode JWT token.", 474 | extra={ 475 | "exc": exc, 476 | "exc_type": type(exc), 477 | "error_code": CANNOT_DECODE_JWT_TOKEN, 478 | "reason": "Cannot decode JWT token.", 479 | "status_code": 500, 480 | }, 481 | ) 482 | 483 | 484 | def decode_custom_or_default_jwt(jwt_token: str) -> Optional[str]: 485 | """Decode a JWT token, eventually using custom trigger 486 | 487 | Args: 488 | jwt_token (str): The token to decode 489 | 490 | Raises: 491 | SAMLAuthError: Cannot decode JWT token. 492 | 493 | Returns: 494 | Optional[str]: A user_id as str or None. 495 | """ 496 | saml2_auth_settings = settings.SAML2_AUTH 497 | custom_decode_jwt_trigger = dictor(saml2_auth_settings, "TRIGGER.CUSTOM_DECODE_JWT") 498 | if custom_decode_jwt_trigger: 499 | user_id = run_hook(custom_decode_jwt_trigger, jwt_token) # type: ignore 500 | else: 501 | user_id = decode_jwt_token(jwt_token) 502 | return user_id 503 | -------------------------------------------------------------------------------- /django_saml2_auth/saml.py: -------------------------------------------------------------------------------- 1 | """Utility functions for various SAML client functions.""" 2 | 3 | import base64 4 | from typing import Any, Callable, Dict, Mapping, Optional, Union 5 | 6 | from dictor import dictor # type: ignore 7 | from django.conf import settings 8 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 9 | from django.urls import NoReverseMatch 10 | from django_saml2_auth.errors import ( 11 | ERROR_CREATING_SAML_CONFIG_OR_CLIENT, 12 | INVALID_METADATA_URL, 13 | NO_ISSUER_IN_SAML_RESPONSE, 14 | NO_METADATA_URL_ASSOCIATED, 15 | NO_METADATA_URL_OR_FILE, 16 | NO_NAME_ID_IN_SAML_RESPONSE, 17 | NO_SAML_CLIENT, 18 | NO_SAML_RESPONSE_FROM_CLIENT, 19 | NO_SAML_RESPONSE_FROM_IDP, 20 | NO_TOKEN_SPECIFIED, 21 | NO_USER_IDENTITY_IN_SAML_RESPONSE, 22 | NO_USERNAME_OR_EMAIL_SPECIFIED, 23 | ) 24 | from django_saml2_auth.exceptions import SAMLAuthError 25 | from django_saml2_auth.utils import get_reverse, run_hook 26 | from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity 27 | from saml2.client import Saml2Client 28 | from saml2.config import Config as Saml2Config 29 | from saml2.httpbase import HTTPBase 30 | from saml2.mdstore import MetaDataExtern 31 | from saml2.response import AuthnResponse 32 | 33 | 34 | def get_assertion_url(request: HttpRequest) -> str: 35 | """Extract protocol and domain name from request, if ASSERTION_URL is not specified in settings, 36 | otherwise the ASSERTION_URL is returned. 37 | 38 | Args: 39 | request (HttpRequest): Django request object 40 | 41 | Returns: 42 | str: Either protocol://host or ASSERTION_URL 43 | """ 44 | saml2_auth_settings = settings.SAML2_AUTH 45 | assertion_url = dictor(saml2_auth_settings, "ASSERTION_URL") 46 | if assertion_url: 47 | return assertion_url 48 | 49 | protocol = "https" if request.is_secure() else "http" 50 | host = request.get_host() 51 | return f"{protocol}://{host}" 52 | 53 | 54 | def get_default_next_url() -> Optional[str]: 55 | """Get default next url for redirection, which is either the DEFAULT_NEXT_URL from settings or 56 | admin index. 57 | 58 | Returns: 59 | Optional[str]: Returns default next url for redirection or admin index 60 | """ 61 | saml2_auth_settings = settings.SAML2_AUTH 62 | default_next_url = dictor(saml2_auth_settings, "DEFAULT_NEXT_URL") 63 | if default_next_url: 64 | return default_next_url 65 | 66 | # Lazily evaluate this in case we don't have admin loaded. 67 | return get_reverse("admin:index") 68 | 69 | 70 | def validate_metadata_url(url: str) -> bool: 71 | """Validates metadata URL 72 | 73 | Args: 74 | url (str): Metadata URL 75 | 76 | Returns: 77 | bool: Wether the metadata URL is valid or not 78 | """ 79 | try: 80 | http_client = HTTPBase() 81 | metadata = MetaDataExtern(None, url=url, http=http_client) 82 | metadata.load() 83 | except Exception: 84 | return False 85 | 86 | return True 87 | 88 | 89 | def get_metadata( 90 | user_id: Optional[str] = None, 91 | domain: Optional[str] = None, 92 | saml_response: Optional[str] = None, 93 | ) -> Mapping[str, Any]: 94 | """Returns metadata information, either by running the GET_METADATA_AUTO_CONF_URLS hook function 95 | if available, or by checking and returning a local file path or the METADATA_AUTO_CONF_URL. URLs 96 | are always validated and invalid URLs will be either filtered or raise a SAMLAuthError 97 | exception. 98 | 99 | Args: 100 | user_id (str, optional): If passed, it will be further processed by the 101 | GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding to 102 | the given user identifier, either email or username. Defaults to None. 103 | domain (str, optional): Domain name to get SAML config for 104 | saml_response (str or None): decoded XML SAML response. 105 | 106 | Raises: 107 | SAMLAuthError: No metadata URL associated with the given user identifier. 108 | SAMLAuthError: Invalid metadata URL. 109 | 110 | Returns: 111 | Mapping[str, Any]: Returns a SAML metadata object as dictionary 112 | """ 113 | saml2_auth_settings = settings.SAML2_AUTH 114 | 115 | # If there is a custom trigger, metadata is retrieved directly within the trigger 116 | get_custom_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_CUSTOM_METADATA") 117 | if get_custom_metadata_trigger: 118 | return run_hook(get_custom_metadata_trigger, user_id, domain, saml_response) # type: ignore 119 | 120 | get_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_METADATA_AUTO_CONF_URLS") 121 | if get_metadata_trigger: 122 | metadata_urls = run_hook(get_metadata_trigger, user_id) # type: ignore 123 | if metadata_urls: 124 | # Filter invalid metadata URLs 125 | filtered_metadata_urls = list( 126 | filter(lambda md: validate_metadata_url(md["url"]), metadata_urls) 127 | ) 128 | return {"remote": filtered_metadata_urls} 129 | else: 130 | raise SAMLAuthError( 131 | "No metadata URL associated with the given user identifier.", 132 | extra={ 133 | "exc_type": ValueError, 134 | "error_code": NO_METADATA_URL_ASSOCIATED, 135 | "reason": "There was an error processing your request.", 136 | "status_code": 500, 137 | }, 138 | ) 139 | 140 | metadata_local_file_path = dictor(saml2_auth_settings, "METADATA_LOCAL_FILE_PATH") 141 | if metadata_local_file_path: 142 | return {"local": [metadata_local_file_path]} 143 | else: 144 | single_metadata_url = dictor(saml2_auth_settings, "METADATA_AUTO_CONF_URL") 145 | if validate_metadata_url(single_metadata_url): 146 | return {"remote": [{"url": single_metadata_url}]} 147 | else: 148 | raise SAMLAuthError( 149 | "Invalid metadata URL.", 150 | extra={ 151 | "exc_type": ValueError, 152 | "error_code": INVALID_METADATA_URL, 153 | "reason": "There was an error processing your request.", 154 | "status_code": 500, 155 | }, 156 | ) 157 | 158 | 159 | def get_custom_acs_url() -> Optional[str]: 160 | get_custom_acs_url_hook = dictor(settings.SAML2_AUTH, "TRIGGER.GET_CUSTOM_ASSERTION_URL") 161 | return run_hook(get_custom_acs_url_hook) if get_custom_acs_url_hook else None 162 | 163 | 164 | def get_saml_client( 165 | domain: str, 166 | acs: Callable[..., HttpResponse], 167 | user_id: Optional[str] = None, 168 | saml_response: Optional[str] = None, 169 | ) -> Optional[Saml2Client]: 170 | """Create a new Saml2Config object with the given config and return an initialized Saml2Client 171 | using the config object. The settings are read from django settings key: SAML2_AUTH. 172 | 173 | Args: 174 | domain (str): Domain name to get SAML config for 175 | acs (Callable[..., HttpResponse]): The acs endpoint 176 | user_id (str or None): If passed, it will be further processed by the 177 | GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding 178 | to the given user identifier, either email or username. Defaults to None. 179 | user_id (str or None): User identifier: username or email. Defaults to None. 180 | saml_response (str or None): decoded XML SAML response. 181 | 182 | Raises: 183 | SAMLAuthError: Re-raise any exception raised by Saml2Config or Saml2Client 184 | 185 | Returns: 186 | Optional[Saml2Client]: A Saml2Client or None 187 | """ 188 | get_user_id_from_saml_response = dictor( 189 | settings.SAML2_AUTH, "TRIGGER.GET_USER_ID_FROM_SAML_RESPONSE" 190 | ) 191 | if get_user_id_from_saml_response and saml_response: 192 | user_id = run_hook(get_user_id_from_saml_response, saml_response, user_id) # type: ignore 193 | 194 | metadata = get_metadata(user_id, domain, saml_response) 195 | if metadata and ( 196 | ("local" in metadata and not metadata["local"]) 197 | or ("remote" in metadata and not metadata["remote"]) 198 | ): 199 | raise SAMLAuthError( 200 | "Metadata URL/file is missing.", 201 | extra={ 202 | "exc_type": NoReverseMatch, 203 | "error_code": NO_METADATA_URL_OR_FILE, 204 | "reason": "There was an error processing your request.", 205 | "status_code": 500, 206 | }, 207 | ) 208 | 209 | acs_url = get_custom_acs_url() 210 | if not acs_url: 211 | # get_reverse raises an exception if the view is not found, so we can safely ignore type errors 212 | acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) # type: ignore 213 | 214 | saml2_auth_settings = settings.SAML2_AUTH 215 | 216 | saml_settings: Dict[str, Any] = { 217 | "metadata": metadata, 218 | "allow_unknown_attributes": True, 219 | "debug": saml2_auth_settings.get("DEBUG", False), 220 | "service": { 221 | "sp": { 222 | "endpoints": { 223 | "assertion_consumer_service": [ 224 | (acs_url, BINDING_HTTP_REDIRECT), 225 | (acs_url, BINDING_HTTP_POST), 226 | ], 227 | }, 228 | "allow_unsolicited": True, 229 | "authn_requests_signed": dictor( 230 | saml2_auth_settings, "AUTHN_REQUESTS_SIGNED", default=True 231 | ), 232 | "logout_requests_signed": dictor( 233 | saml2_auth_settings, "LOGOUT_REQUESTS_SIGNED", default=True 234 | ), 235 | "want_assertions_signed": dictor( 236 | saml2_auth_settings, "WANT_ASSERTIONS_SIGNED", default=True 237 | ), 238 | "want_response_signed": dictor( 239 | saml2_auth_settings, "WANT_RESPONSE_SIGNED", default=True 240 | ), 241 | "force_authn": dictor(saml2_auth_settings, "FORCE_AUTHN", default=False), 242 | }, 243 | }, 244 | } 245 | 246 | entity_id = saml2_auth_settings.get("ENTITY_ID") 247 | if entity_id: 248 | saml_settings["entityid"] = entity_id 249 | 250 | name_id_format = saml2_auth_settings.get("NAME_ID_FORMAT") 251 | if name_id_format: 252 | saml_settings["service"]["sp"]["name_id_policy_format"] = name_id_format 253 | 254 | accepted_time_diff = saml2_auth_settings.get("ACCEPTED_TIME_DIFF") 255 | if accepted_time_diff: 256 | saml_settings["accepted_time_diff"] = accepted_time_diff 257 | 258 | # Enable logging with a custom logger. See below for more details: 259 | # https://pysaml2.readthedocs.io/en/latest/howto/config.html?highlight=debug#logging 260 | logging = saml2_auth_settings.get("LOGGING") 261 | if logging: 262 | saml_settings["logging"] = logging 263 | 264 | key_file = saml2_auth_settings.get("KEY_FILE") 265 | if key_file: 266 | saml_settings["key_file"] = key_file 267 | 268 | cert_file = saml2_auth_settings.get("CERT_FILE") 269 | if cert_file: 270 | saml_settings["cert_file"] = cert_file 271 | 272 | encryption_keypairs = saml2_auth_settings.get("ENCRYPTION_KEYPAIRS") 273 | if encryption_keypairs: 274 | saml_settings["encryption_keypairs"] = encryption_keypairs 275 | elif key_file and cert_file: 276 | saml_settings["encryption_keypairs"] = [ 277 | { 278 | "key_file": key_file, 279 | "cert_file": cert_file, 280 | } 281 | ] 282 | 283 | try: 284 | sp_config = Saml2Config() 285 | sp_config.load(saml_settings) 286 | saml_client = Saml2Client(config=sp_config) 287 | return saml_client 288 | except Exception as exc: 289 | raise SAMLAuthError( 290 | str(exc), 291 | extra={ 292 | "exc": exc, 293 | "exc_type": type(exc), 294 | "error_code": ERROR_CREATING_SAML_CONFIG_OR_CLIENT, 295 | "reason": "There was an error processing your request.", 296 | "status_code": 500, 297 | }, 298 | ) 299 | 300 | 301 | def decode_saml_response( 302 | request: HttpRequest, acs: Callable[..., HttpResponse] 303 | ) -> Union[HttpResponseRedirect, Optional[AuthnResponse], None]: 304 | """Given a request, the authentication response inside the SAML response body is parsed, 305 | decoded and returned. If there are any issues parsing the request, the identity or the issuer, 306 | an exception is raised. 307 | 308 | Args: 309 | request (HttpRequest): Django request object from identity provider (IdP) 310 | acs (Callable[..., HttpResponse]): The acs endpoint 311 | 312 | Raises: 313 | SAMLAuthError: There was no response from SAML client. 314 | SAMLAuthError: There was no response from SAML identity provider. 315 | SAMLAuthError: No name_id in SAML response. 316 | SAMLAuthError: No issuer/entity_id in SAML response. 317 | SAMLAuthError: No user identity in SAML response. 318 | 319 | Returns: 320 | Union[HttpResponseRedirect, Optional[AuthnResponse], None]: Returns an AuthnResponse 321 | object for extracting user identity from. 322 | """ 323 | response = request.POST.get("SAMLResponse") or None 324 | if not response: 325 | raise SAMLAuthError( 326 | "There was no response from SAML client.", 327 | extra={ 328 | "exc_type": ValueError, 329 | "error_code": NO_SAML_RESPONSE_FROM_CLIENT, 330 | "reason": "There was an error processing your request.", 331 | "status_code": 500, 332 | }, 333 | ) 334 | 335 | try: 336 | saml_response = base64.b64decode(response).decode("UTF-8") 337 | except Exception: 338 | saml_response = None 339 | saml_client = get_saml_client(get_assertion_url(request), acs, saml_response=saml_response) 340 | if not saml_client: 341 | raise SAMLAuthError( 342 | "There was an error creating the SAML client.", 343 | extra={ 344 | "exc_type": ValueError, 345 | "error_code": NO_SAML_CLIENT, 346 | "reason": "There was an error processing your request.", 347 | "status_code": 500, 348 | }, 349 | ) 350 | 351 | authn_response = saml_client.parse_authn_request_response(response, entity.BINDING_HTTP_POST) 352 | if not authn_response: 353 | raise SAMLAuthError( 354 | "There was no response from SAML identity provider.", 355 | extra={ 356 | "exc_type": ValueError, 357 | "error_code": NO_SAML_RESPONSE_FROM_IDP, 358 | "reason": "There was an error processing your request.", 359 | "status_code": 500, 360 | }, 361 | ) 362 | 363 | if not authn_response.name_id: 364 | raise SAMLAuthError( 365 | "No name_id in SAML response.", 366 | extra={ 367 | "exc_type": ValueError, 368 | "error_code": NO_NAME_ID_IN_SAML_RESPONSE, 369 | "reason": "There was an error processing your request.", 370 | "status_code": 500, 371 | }, 372 | ) 373 | 374 | if not authn_response.issuer(): 375 | raise SAMLAuthError( 376 | "No issuer/entity_id in SAML response.", 377 | extra={ 378 | "exc_type": ValueError, 379 | "error_code": NO_ISSUER_IN_SAML_RESPONSE, 380 | "reason": "There was an error processing your request.", 381 | "status_code": 500, 382 | }, 383 | ) 384 | 385 | if not authn_response.get_identity(): 386 | raise SAMLAuthError( 387 | "No user identity in SAML response.", 388 | extra={ 389 | "exc_type": ValueError, 390 | "error_code": NO_USER_IDENTITY_IN_SAML_RESPONSE, 391 | "reason": "There was an error processing your request.", 392 | "status_code": 500, 393 | }, 394 | ) 395 | 396 | return authn_response 397 | 398 | 399 | def extract_user_identity( 400 | authn_response: Union[HttpResponseRedirect, Optional[AuthnResponse], None], 401 | ) -> Dict[str, Optional[Any]]: 402 | """Extract user information from SAML user identity object and optionally 403 | enriches the output with anything that can be extracted from the 404 | authentication response, like issuer, name_id, etc. 405 | 406 | Args: 407 | authn_response (Union[HttpResponseRedirect, Optional[AuthnResponse], None]): 408 | AuthnResponse object for extracting user identity from. 409 | 410 | Raises: 411 | SAMLAuthError: No token specified. 412 | SAMLAuthError: No username or email provided. 413 | 414 | Returns: 415 | Dict[str, Optional[Any]]: Cleaned user information plus user_identity 416 | for backwards compatibility. Also, it can include any custom attributes 417 | that are extracted from the SAML response. 418 | """ 419 | saml2_auth_settings = settings.SAML2_AUTH 420 | 421 | user_identity: Dict[str, Any] = authn_response.get_identity() # type: ignore 422 | 423 | email_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.email", default="user.email") 424 | username_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.username", default="user.username") 425 | firstname_field = dictor( 426 | saml2_auth_settings, "ATTRIBUTES_MAP.first_name", default="user.first_name" 427 | ) 428 | lastname_field = dictor( 429 | saml2_auth_settings, "ATTRIBUTES_MAP.last_name", default="user.last_name" 430 | ) 431 | 432 | user = {} 433 | user["email"] = dictor(user_identity, f"{email_field}|0", pathsep="|") # Path includes "." 434 | user["username"] = dictor(user_identity, f"{username_field}|0", pathsep="|") 435 | user["first_name"] = dictor(user_identity, f"{firstname_field}|0", pathsep="|") 436 | user["last_name"] = dictor(user_identity, f"{lastname_field}|0", pathsep="|") 437 | 438 | token_required = dictor(saml2_auth_settings, "TOKEN_REQUIRED", default=True) 439 | if token_required: 440 | token_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.token", default="token") 441 | user["token"] = dictor(user_identity, f"{token_field}|0", pathsep="|") 442 | 443 | if user["email"]: 444 | user["email"] = user["email"].lower() 445 | if user["username"]: 446 | user["username"] = user["username"].lower() 447 | 448 | # For backwards compatibility 449 | user["user_identity"] = user_identity 450 | 451 | if not user["email"] and not user["username"]: 452 | raise SAMLAuthError( 453 | "No username or email provided.", 454 | extra={ 455 | "exc_type": ValueError, 456 | "error_code": NO_USERNAME_OR_EMAIL_SPECIFIED, 457 | "reason": "Username or email must be configured on the SAML app before logging in.", 458 | "status_code": 422, 459 | }, 460 | ) 461 | 462 | if token_required and not user.get("token"): 463 | raise SAMLAuthError( 464 | "No token specified.", 465 | extra={ 466 | "exc_type": ValueError, 467 | "error_code": NO_TOKEN_SPECIFIED, 468 | "reason": "Token must be configured on the SAML app before logging in.", 469 | "status_code": 422, 470 | }, 471 | ) 472 | 473 | # If there is a custom trigger, user identity is extracted directly within the trigger. 474 | # This is useful when the user identity doesn't include custom attributes to determine 475 | # the organization, project or team that the user belongs to. Hence, the trigger can use 476 | # the user identity from the SAML response along with the whole authentication response. 477 | extract_user_identity_trigger = dictor(saml2_auth_settings, "TRIGGER.EXTRACT_USER_IDENTITY") 478 | if extract_user_identity_trigger: 479 | return run_hook(extract_user_identity_trigger, user, authn_response) # type: ignore 480 | 481 | # If there is no custom trigger, the user identity is returned as is. 482 | return user 483 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/test_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for user.py 3 | """ 4 | 5 | from typing import Any, Dict, Union 6 | 7 | import pytest 8 | from django.contrib.auth import get_user_model 9 | from django.contrib.auth.models import Group, User 10 | from django_saml2_auth.exceptions import SAMLAuthError 11 | from django_saml2_auth.user import ( 12 | create_custom_or_default_jwt, 13 | create_new_user, 14 | decode_custom_or_default_jwt, 15 | get_or_create_user, 16 | get_user, 17 | get_user_id, 18 | ) 19 | from jwt.exceptions import PyJWTError 20 | from pytest_django.fixtures import SettingsWrapper 21 | 22 | 23 | private_key = """-----BEGIN RSA PRIVATE KEY----- 24 | Proc-Type: 4,ENCRYPTED 25 | DEK-Info: DES-EDE3-CBC,098DF8240D954EE0 26 | 27 | uW/IYor+xm5vOLHhDovanTaYWf+N/f+Yae80KJyuXaJ45jVtZBmbhQKy6MIc3pHG 28 | QICO4x8esHkOgkzicnjGjaWscTIEy6yZzOzAGsA8t4uLTKix9TM31QQnaUVVWXtn 29 | mSJN6rY3Qdudgzss47qcFjS+Rhr+X+u8PB3FhOd6Tgkphpl5Vdkb2K+bcbaXuUlN 30 | DUUigoR0+7N5wCJHGKKQd6YRDKvSC4yo0VHbN3Hb55+84m1MGE+pPU8krkmL0u3M 31 | 7b37nOVXq+4bE/2t0UIVwZyz+pAqAC3tRpSKfe8EN/R2VcfRi4QNrtAkLxYaLWCr 32 | SpQA7/qLuEXH1LeUFvDk/SjmCBvNz5vm8hSrj0O5eXN4uIBTg7tdof4KOxozaYKM 33 | 4SSnfEWjwDwGr/fE6Om5wKrwUjOm3ZTkOHEz5AarVFOsrccLFxSl7+RRKCeIlHSl 34 | uAmqBW6pVxS65fwCwocHJ0jVKxEKGz3j++aqmQ0omEhtGWcDcfQMP6sAV1tY/FDd 35 | NkVTD/cv23SeHZtjtCxz0W8/Vsqs6U6HMLTb0uJVVdMBiPnTYPKOeACx6GxnwH/e 36 | VrjKEy9xqxzdo59lExl06ZBLd9x9u2CAhLqUIlqQu4EtGStpcDuEHj8LY7Y2Jt/G 37 | w2IGG72YXrrewCFjgYcvsIjbwnFy/FeDyd5dLK4iT6bzInEm3Eo6MZYImBmD9gkB 38 | 4U8rXXQwXPwPM+rKwRlbP9v+k5Af29Br6L1T+MAczjThlIdikuCvFoRLQ921De83 39 | iFL0LKtbK1sAtmnQBdTYRyWz0MDLJ+7emcXO/NEuK8EogQrLX0wyNXsH3bmXZXzk 40 | sBI8gK80e/4hRYdHqgmU8XTI4PTa3tj29hpZa57nG6Ccd2uUULjBiUOBIe6Tm72D 41 | DMGqY0wQWOn+wMPLBedOGyJdTWJvDlPpiuboCrr8ughkYIt/d6XynKdejCescJLM 42 | t4pwG058EL489Y8O6UAtQYxuj2jrLx7aLxdWxBFjWmKdoDs/p7tOiOcOt6byXybE 43 | xpoPG+h9X/GkoH8PaEqL40JlsNcb8dcaUcw2bBUjALnQ38eYETeoUFIsIhZ6nwtq 44 | NzJlsWwHtaFue8Eh8/SxQ1ctU5U52E7pNm8vTNmjj1wVgSqht1RSfM/L5WyoqLrO 45 | RZTUSqqrDGE31mwpPtEPPUyoGnBroMpJLGoYi03UIn/eSM87gCLBb1Wcsc+BarPf 46 | KSSaCE+F3tpIssN1li5nYnfBtVd1hG6f8iCrZo+Ch+N1EVrYFuFSpUTUNSAZiwD2 47 | hoRVxyVtDsvIZ+rasbcYSQZyPwhGB6vqjhwdJMIQ6nPyeWZYwPp18alcWRv/UIR7 48 | SnEm4NBDCLAXnil2PxCw2c832yTI5/vv8Mi4UvunrUDk2C1ikcwPsPZFMhGYdUxJ 49 | O0QirCOeIhRTTsSWxRx5Ac4BOdFjr+Hj8kQd9y/LGdeZ9XjB2AYirTj6zLZynJNa 50 | cZU/c743apbLxvv6tvkzcM8hI1pYoYBZ+Eu5aSUqKZaUXxgARKnDX99GUVAXutAG 51 | yjwNaiZe1gCKjP7aKZ85+uJZkRvlK/eB/EiNXyhKriKs/vraMeOgtA== 52 | -----END RSA PRIVATE KEY-----""" 53 | 54 | passphrase = "123456" 55 | 56 | unencrypted_private_key = """-----BEGIN RSA PRIVATE KEY----- 57 | MIIEowIBAAKCAQEA5EBnGco/VgirZAUk1qtPR1pkgEMdqBZImTPp6Xf4MDhB7zev 58 | veqlXAdaFcvrG4rdO4jHr5snJzJpY52en27a/z2gtQhR9f/05ZjpX9eKCpNt8c/2 59 | SDf/P+omEbvbvoUSjkyGeWBacRuHHj6C1voUrl1aKokRfV9GaSkN7+lOxCBCjXFn 60 | xEsLlbJZFSsAQPf1282v5SuE2FxMjbs/Xrmjl1Q3h8YsV+vvrRK6/OczPVUb6M4H 61 | KHqIpH5UTjP/44Mr5sbanjEEYCOsxiM7Q8JUEYczyjn/o2j8/w0Lms8LB8tnVpxd 62 | pwp7QoPVRIr1slYlilP/fL6ZzL5k87XUsi4yGwIDAQABAoIBAASCnUbuLyg1DaXx 63 | UBQJ2IwxZhD+woRCxHZ6hyG85COXyP3AHPHkxBW4c3hAykmGCe8WOdPnffORVHHK 64 | eIrv9tXaUuWg33W79AvhZKMnMCwbU63WjShKKvoJV208SBBQstgq/PFDDSZ1A8t+ 65 | MrmqwWPcpl52zOisTEjhrcvS0WKgswFHqveaM4Ss1tR+VNj93r+lKXjsKrUfB1+b 66 | T/+9aEjsGiKtfZ+EtUGyanoI/FnVs45ieUeb+WX8x9fJPXyLGeEWSlBQ8RdT1R00 67 | VnYY37P+M8ITzwpQzosViClfSPk8ljTkhT+EdictTYVXMCKGUTQOTdw5SRH5l90L 68 | YPFS7KECgYEA+QFhPNLWQEcIhEJT5q9Rh73u/jDW76f6UfrxIA59tOtlFPpYvYne 69 | P2BPGbDPpdCIf6ypgGITuKjGhd6WbPIrRZz0g1iijlV5nfD+/fzj1ACTMBkBNHd+ 70 | 1ysHjwlRPqJIFB/3Us0BtcYX+4hz1JHA/z7OBbEwKeIZ6uGcN0xw1vECgYEA6qnH 71 | xErGnHsSTlVHg6k68RQzeiN/8tWAIGcp4QhlC6p5TeN+X8d25pUuWENNcZZNbC5M 72 | hlz8dQ52aB8kVxZkRSOinraUtNoMnVriR3hr4iS9gMVGA+HQR3S5IdnBVnleRUfl 73 | qDM/MxL3Ru0sYp4Fr2ndw4aJPUIVyAeTMVLJ0csCgYEA1KrWBqG3tRw17OfNSrev 74 | tXSFevnxiKv5wizF5fAacvuc0GbkhbULaStzQ2jcYC0Td5/bALhDSbJ0I3+xEAlg 75 | 5cqgltGLvG7KORfMYNatKrL3AtxISCxK27B3ezWk+w6U6wNGM6S98ibm8sBe1U1K 76 | /XUBdqEXlp3yLsZTqnMR6LECgYBRrl9WuCCB/2TT12NZNOLLX5i7fvfecupyXPZ6 77 | 2g0yDljC/9jRRgDhKjRDjMm8K/EvIr6IVn2Z0Trt60ke9zBX0JueWzdP7EZPz37M 78 | GeKTiO5dkE1atJNnC/4VBlMB4qUpwGj0L0JkaMmh6pR0j0SzVkpW8NF8fTBPvDNE 79 | C+ksGQKBgDvsvOc/8OfDVvu3OgUJnACQdKUD56ppYUZ8XLo34vUm2JAXeb5nRAdZ 80 | Fo4X5nN+G7W2lV5W5384zfjN3IREeZtg4ZKw0w+vJrhz6bixBIOfMCu0O9TYT11B 81 | G2RcH+T0kcs7QbXTY4QrMEYYQj4viihDDo3Ndt5eNJKPz2s+F2h3 82 | -----END RSA PRIVATE KEY-----""" 83 | 84 | public_key = """-----BEGIN PUBLIC KEY----- 85 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5EBnGco/VgirZAUk1qtP 86 | R1pkgEMdqBZImTPp6Xf4MDhB7zevveqlXAdaFcvrG4rdO4jHr5snJzJpY52en27a 87 | /z2gtQhR9f/05ZjpX9eKCpNt8c/2SDf/P+omEbvbvoUSjkyGeWBacRuHHj6C1voU 88 | rl1aKokRfV9GaSkN7+lOxCBCjXFnxEsLlbJZFSsAQPf1282v5SuE2FxMjbs/Xrmj 89 | l1Q3h8YsV+vvrRK6/OczPVUb6M4HKHqIpH5UTjP/44Mr5sbanjEEYCOsxiM7Q8JU 90 | EYczyjn/o2j8/w0Lms8LB8tnVpxdpwp7QoPVRIr1slYlilP/fL6ZzL5k87XUsi4y 91 | GwIDAQAB 92 | -----END PUBLIC KEY-----""" 93 | 94 | 95 | def trigger_change_first_name(user: Union[str, Dict[str, str]]) -> None: 96 | """Trigger function to change user's first name. 97 | 98 | Args: 99 | user (Union[str, Dict[str, str]]): User information 100 | """ 101 | _user = get_user(user) 102 | _user.first_name = "CHANGED_FIRSTNAME" 103 | _user.save() 104 | 105 | 106 | def trigger_get_user(user: Dict) -> User: 107 | """Trigger function to get a user. 108 | 109 | Args: 110 | user (Union[str, Dict[str, str]]): User information 111 | """ 112 | user_model = get_user_model() 113 | return user_model.objects.get(email=user["username"]) 114 | 115 | 116 | @pytest.mark.django_db 117 | def test_create_new_user_success(settings: SettingsWrapper): 118 | """Test create_new_user function to verify if it works and correctly joins the user to the 119 | respective group. 120 | 121 | Args: 122 | settings (SettingsWrapper): Fixture for django settings 123 | """ 124 | settings.SAML2_AUTH = { 125 | "NEW_USER_PROFILE": { 126 | "USER_GROUPS": ["users"], 127 | } 128 | } 129 | 130 | # Create a group for the users to join 131 | Group.objects.create(name="users") 132 | user = create_new_user("test@example.com", "John", "Doe") 133 | # It can also be email depending on USERNAME_FIELD setting 134 | assert user.username == "test@example.com" 135 | assert user.is_active is True 136 | assert user.has_usable_password() is False 137 | assert user.groups.get(name="users") == Group.objects.get(name="users") 138 | 139 | 140 | @pytest.mark.django_db 141 | def test_create_new_user_with_dict_success(settings: SettingsWrapper): 142 | """Test create_new_user function to verify if it works and correctly joins the user to the 143 | respective group. 144 | 145 | Args: 146 | settings (SettingsWrapper): Fixture for django settings 147 | """ 148 | settings.SAML2_AUTH = { 149 | "NEW_USER_PROFILE": { 150 | "USER_GROUPS": ["users"], 151 | } 152 | } 153 | 154 | # Create a group for the users to join 155 | Group.objects.create(name="users") 156 | params = {"first_name": "test_John", "last_name": "test_Doe"} 157 | user = create_new_user("user_test@example.com", **params) 158 | # It can also be email depending on USERNAME_FIELD setting 159 | assert user.username == "user_test@example.com" 160 | assert user.is_active is True 161 | assert user.has_usable_password() is False 162 | assert user.groups.get(name="users") == Group.objects.get(name="users") 163 | 164 | 165 | @pytest.mark.django_db 166 | def test_create_new_user_with_dict_success__no_first_and_last_name( 167 | settings: SettingsWrapper, 168 | ): 169 | """Test create_new_user function to verify if it works and correctly joins the user to the 170 | respective group. 171 | 172 | Args: 173 | settings (SettingsWrapper): Fixture for django settings 174 | """ 175 | settings.SAML2_AUTH = { 176 | "NEW_USER_PROFILE": { 177 | "USER_GROUPS": ["users"], 178 | } 179 | } 180 | 181 | # Create a group for the users to join 182 | Group.objects.create(name="users") 183 | # Create a user without first and last name (valid use-case) 184 | user = create_new_user("user_test@example.com") 185 | # It can also be email depending on USERNAME_FIELD setting 186 | assert user.username == "user_test@example.com" 187 | assert user.is_active is True 188 | assert user.has_usable_password() is False 189 | assert user.groups.get(name="users") == Group.objects.get(name="users") 190 | 191 | 192 | @pytest.mark.django_db 193 | def test_create_new_user_no_group_error(settings: SettingsWrapper): 194 | """Test create_new_user function to verify if it creates the user, but fails to join the user 195 | to the respective group. 196 | 197 | Args: 198 | settings (SettingsWrapper): Fixture for django settings 199 | """ 200 | settings.SAML2_AUTH = { 201 | "NEW_USER_PROFILE": { 202 | "USER_GROUPS": ["users"], 203 | } 204 | } 205 | 206 | with pytest.raises(SAMLAuthError) as exc_info: 207 | create_new_user("test@example.com", "John", "Doe") 208 | 209 | assert str(exc_info.value) == "There was an error joining the user to the group." 210 | assert exc_info.value.extra is not None 211 | assert exc_info.value.extra["exc_type"] == Group.DoesNotExist 212 | 213 | 214 | def test_create_new_user_value_error(): 215 | """Test create_new_user function to verify if it raises an exception upon passing invalid value 216 | as user_id.""" 217 | with pytest.raises(SAMLAuthError) as exc_info: 218 | create_new_user("", "John", "Doe") 219 | 220 | assert str(exc_info.value) == "There was an error creating the new user." 221 | assert exc_info.value.extra["exc_type"] is ValueError 222 | 223 | 224 | @pytest.mark.django_db 225 | def test_get_or_create_user_success(settings: SettingsWrapper): 226 | """Test get_or_create_user function to verify if it creates a new user and joins it to the 227 | correct group based on the given SAML group and its mapping with internal groups. 228 | 229 | Args: 230 | settings (SettingsWrapper): Fixture for django settings 231 | """ 232 | settings.SAML2_AUTH = { 233 | "ATTRIBUTES_MAP": { 234 | "groups": "groups", 235 | }, 236 | "GROUPS_MAP": {"consumers": "users"}, 237 | } 238 | 239 | Group.objects.create(name="users") 240 | created, user = get_or_create_user( 241 | { 242 | "username": "test@example.com", 243 | "first_name": "John", 244 | "last_name": "Doe", 245 | "user_identity": { 246 | "user.username": "test@example.com", 247 | "user.first_name": "John", 248 | "user.last_name": "Doe", 249 | "groups": ["consumers"], 250 | }, 251 | } 252 | ) 253 | assert created 254 | assert user.username == "test@example.com" 255 | assert user.is_active is True 256 | assert user.has_usable_password() is False 257 | assert user.groups.get(name="users") == Group.objects.get(name="users") 258 | 259 | 260 | @pytest.mark.django_db 261 | def test_get_or_create_user_trigger_error(settings: SettingsWrapper): 262 | """Test get_or_create_user function to verify if it raises an exception in case the CREATE_USER 263 | trigger function is nonexistent. 264 | 265 | Args: 266 | settings (SettingsWrapper): Fixture for django settings 267 | """ 268 | settings.SAML2_AUTH = { 269 | "TRIGGER": { 270 | "CREATE_USER": "django_saml2_auth.tests.test_user.nonexistent_trigger", 271 | } 272 | } 273 | 274 | with pytest.raises(SAMLAuthError) as exc_info: 275 | get_or_create_user( 276 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 277 | ) 278 | 279 | assert str(exc_info.value) == ( 280 | "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'" 281 | ) 282 | assert exc_info.value.extra is not None 283 | assert isinstance(exc_info.value.extra["exc"], AttributeError) 284 | 285 | 286 | @pytest.mark.django_db 287 | def test_get_user_trigger_error(settings: SettingsWrapper): 288 | """Test get_user function to verify if it raises an exception in case the GET_USER 289 | trigger function is nonexistent. 290 | 291 | Args: 292 | settings (SettingsWrapper): Fixture for django settings 293 | """ 294 | settings.SAML2_AUTH = { 295 | "TRIGGER": { 296 | "GET_USER": "django_saml2_auth.tests.test_user.nonexistent_trigger", 297 | } 298 | } 299 | with pytest.raises(SAMLAuthError) as exc_info: 300 | get_user({"username": "test@example.com", "first_name": "John", "last_name": "Doe"}) 301 | 302 | assert str(exc_info.value) == ( 303 | "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'" 304 | ) 305 | assert exc_info.value.extra is not None 306 | assert isinstance(exc_info.value.extra["exc"], AttributeError) 307 | 308 | 309 | @pytest.mark.django_db 310 | def test_get_user_trigger(settings: SettingsWrapper): 311 | settings.SAML2_AUTH = { 312 | "TRIGGER": { 313 | "GET_USER": "django_saml2_auth.tests.test_user.trigger_get_user", 314 | } 315 | } 316 | user_model = get_user_model() 317 | user_model.objects.create(username="test_example_com", email="test@example.com") 318 | created, user = get_or_create_user( 319 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 320 | ) 321 | assert created is False 322 | assert user.username == "test_example_com" 323 | 324 | 325 | @pytest.mark.django_db 326 | def test_get_or_create_user_trigger_change_first_name(settings: SettingsWrapper): 327 | """Test get_or_create_user function to verify if it correctly triggers the CREATE_USER function 328 | and the trigger updates the user's first name. 329 | 330 | Args: 331 | settings (SettingsWrapper): Fixture for django settings 332 | """ 333 | settings.SAML2_AUTH = { 334 | "TRIGGER": { 335 | "CREATE_USER": "django_saml2_auth.tests.test_user.trigger_change_first_name", 336 | } 337 | } 338 | 339 | created, user = get_or_create_user( 340 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 341 | ) 342 | 343 | assert created 344 | assert user.username == "test@example.com" 345 | assert user.first_name == "CHANGED_FIRSTNAME" 346 | assert user.is_active is True 347 | assert user.has_usable_password() is False 348 | 349 | 350 | @pytest.mark.django_db 351 | def test_get_or_create_user_should_not_create_user(settings: SettingsWrapper): 352 | """Test get_or_create_user function to verify if it raise an exception while creating a new user 353 | is prohibited by settings. 354 | 355 | Args: 356 | settings (SettingsWrapper): Fixture for django settings 357 | """ 358 | settings.SAML2_AUTH = { 359 | "CREATE_USER": False, 360 | } 361 | 362 | with pytest.raises(SAMLAuthError) as exc_info: 363 | get_or_create_user( 364 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 365 | ) 366 | 367 | assert str(exc_info.value) == "Cannot create user." 368 | assert exc_info.value.extra is not None 369 | assert exc_info.value.extra["reason"] == ( 370 | "Due to current config, a new user should not be created." 371 | ) 372 | 373 | 374 | @pytest.mark.django_db 375 | def test_get_or_create_user_should_not_create_group(settings: SettingsWrapper): 376 | """Test get_or_create_user function to verify if it doesn't create a group while creating a new 377 | group is prohibited by settings. 378 | 379 | Args: 380 | settings (SettingsWrapper): Fixture for django settings 381 | """ 382 | settings.SAML2_AUTH = { 383 | "ATTRIBUTES_MAP": { 384 | "groups": "groups", 385 | } 386 | } 387 | 388 | Group.objects.create(name="users") 389 | created, user = get_or_create_user( 390 | { 391 | "username": "test@example.com", 392 | "first_name": "John", 393 | "last_name": "Doe", 394 | "user_identity": { 395 | "user.username": "test@example.com", 396 | "user.first_name": "John", 397 | "user.last_name": "Doe", 398 | "groups": ["users", "consumers"], 399 | }, 400 | } 401 | ) 402 | assert created 403 | assert user.username == "test@example.com" 404 | assert user.is_active is True 405 | assert user.has_usable_password() is False 406 | assert Group.objects.get(name="users") in user.groups.all() 407 | assert Group.objects.filter(name="consumers").exists() is False 408 | assert user.groups.filter(name="consumers").exists() is False 409 | 410 | 411 | @pytest.mark.django_db 412 | def test_get_or_create_user_should_create_group(settings: SettingsWrapper): 413 | """Test get_or_create_user function to verify if it creates a group when creating a new 414 | group is enabled by settings. 415 | 416 | Args: 417 | settings (SettingsWrapper): Fixture for django settings 418 | """ 419 | settings.SAML2_AUTH = { 420 | "CREATE_GROUPS": True, 421 | "ATTRIBUTES_MAP": { 422 | "groups": "groups", 423 | }, 424 | } 425 | 426 | Group.objects.create(name="users") 427 | created, user = get_or_create_user( 428 | { 429 | "username": "test@example.com", 430 | "first_name": "John", 431 | "last_name": "Doe", 432 | "user_identity": { 433 | "user.username": "test@example.com", 434 | "user.first_name": "John", 435 | "user.last_name": "Doe", 436 | "groups": ["users", "consumers"], 437 | }, 438 | } 439 | ) 440 | assert created 441 | assert user.username == "test@example.com" 442 | assert user.is_active is True 443 | assert user.has_usable_password() is False 444 | assert user.groups.get(name="users") == Group.objects.get(name="users") 445 | assert user.groups.get(name="consumers") == Group.objects.get(name="consumers") 446 | 447 | 448 | @pytest.mark.django_db 449 | def test_get_or_create_user_should_create_and_map_group(settings: SettingsWrapper): 450 | """Test get_or_create_user function to verify if it creates a group when creating a new 451 | group is enabled by settings. It also verifies if the group is mapped correctly from 452 | "consumers" in SAML attributes to "customers" in Django. 453 | 454 | Args: 455 | settings (SettingsWrapper): Fixture for django settings 456 | """ 457 | settings.SAML2_AUTH = { 458 | "CREATE_GROUPS": True, 459 | "ATTRIBUTES_MAP": { 460 | "groups": "groups", 461 | }, 462 | "GROUPS_MAP": { 463 | "consumers": "customers", 464 | }, 465 | } 466 | 467 | Group.objects.create(name="users") 468 | created, user = get_or_create_user( 469 | { 470 | "username": "test@example.com", 471 | "first_name": "John", 472 | "last_name": "Doe", 473 | "user_identity": { 474 | "user.username": "test@example.com", 475 | "user.first_name": "John", 476 | "user.last_name": "Doe", 477 | "groups": ["users", "consumers"], 478 | }, 479 | } 480 | ) 481 | assert created 482 | assert user.username == "test@example.com" 483 | assert user.is_active is True 484 | assert user.has_usable_password() is False 485 | assert user.groups.get(name="users") == Group.objects.get(name="users") 486 | assert user.groups.get(name="customers") == Group.objects.get(name="customers") 487 | 488 | 489 | def test_get_user_id_success(): 490 | """Test get_user_id function to verify if it correctly returns the user_id based on the 491 | User.USERNAME_FIELD.""" 492 | assert get_user_id({"username": "test@example.com"}) == "test@example.com" 493 | assert get_user_id("test@example.com") == "test@example.com" 494 | 495 | 496 | @pytest.mark.django_db 497 | def test_get_user_success(): 498 | """Test get_user function by first creating a new user and then trying to fetch it.""" 499 | create_new_user("test@example.com", "John", "Doe") 500 | user_1 = get_user({"username": "test@example.com"}) 501 | user_2 = get_user("test@example.com") 502 | 503 | assert user_1.username == "test@example.com" 504 | assert user_2.username == "test@example.com" 505 | assert user_1 == user_2 506 | 507 | 508 | @pytest.mark.parametrize( 509 | "saml2_settings", 510 | [ 511 | {"JWT_ALGORITHM": "HS256", "JWT_SECRET": "secret"}, 512 | { 513 | "JWT_ALGORITHM": "RS256", 514 | "JWT_PRIVATE_KEY": private_key, 515 | "JWT_PRIVATE_KEY_PASSPHRASE": passphrase, 516 | "JWT_PUBLIC_KEY": public_key, 517 | }, 518 | { 519 | "JWT_ALGORITHM": "RS256", 520 | "JWT_PRIVATE_KEY": unencrypted_private_key, 521 | "JWT_PUBLIC_KEY": public_key, 522 | }, 523 | ], 524 | ) 525 | def test_create_and_decode_jwt_token_success( 526 | settings: SettingsWrapper, saml2_settings: Dict[str, Any] 527 | ): 528 | """Test create_jwt_token and decode_jwt_token functions by verifying if the newly created 529 | JWT token using is valid. 530 | 531 | Args: 532 | settings (SettingsWrapper): Fixture for django settings 533 | saml2_settings (Dict[str, Any]): Fixture for SAML2 settings 534 | """ 535 | settings.SAML2_AUTH = saml2_settings 536 | 537 | jwt_token = create_custom_or_default_jwt("test@example.com") 538 | user_id = decode_custom_or_default_jwt(jwt_token) 539 | assert user_id == "test@example.com" 540 | 541 | 542 | @pytest.mark.parametrize( 543 | "saml2_settings,error_msg", 544 | [ 545 | ( 546 | {"JWT_ALGORITHM": None}, 547 | "Cannot encode/decode JWT token. Specify an algorithm.", 548 | ), 549 | ( 550 | {"JWT_ALGORITHM": "HS256", "JWT_SECRET": None}, 551 | "Cannot encode/decode JWT token. Specify a secret.", 552 | ), 553 | ( 554 | { 555 | "JWT_ALGORITHM": "HS256", 556 | "JWT_SECRET": "", 557 | }, 558 | "Cannot encode/decode JWT token. Specify a secret.", 559 | ), 560 | ( 561 | {"JWT_ALGORITHM": "HS256", "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"}, 562 | "Cannot encode/decode JWT token. Specify a secret.", 563 | ), 564 | ( 565 | { 566 | "JWT_ALGORITHM": "RS256", 567 | }, 568 | "Cannot encode/decode JWT token. Specify a private key.", 569 | ), 570 | ( 571 | {"JWT_ALGORITHM": "RS256", "JWT_SECRET": "A_SECRET_PHRASE"}, 572 | "Cannot encode/decode JWT token. Specify a private key.", 573 | ), 574 | ], 575 | ) 576 | def test_create_jwt_token_with_incorrect_jwt_settings( 577 | settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str 578 | ): 579 | """Test create_jwt_token function by trying to create a JWT token with incorrect settings. 580 | 581 | Args: 582 | settings (SettingsWrapper): Fixture for django settings 583 | saml2_settings (Dict[str, str]): Fixture for SAML2 settings 584 | error_msg (str): Expected error message 585 | """ 586 | settings.SAML2_AUTH = saml2_settings 587 | 588 | with pytest.raises(SAMLAuthError) as exc_info: 589 | create_custom_or_default_jwt("test@example.com") 590 | 591 | assert str(exc_info.value) == error_msg 592 | 593 | 594 | @pytest.mark.parametrize( 595 | "saml2_settings,error_msg", 596 | [ 597 | ( 598 | {"JWT_ALGORITHM": None}, 599 | "Cannot encode/decode JWT token. Specify an algorithm.", 600 | ), 601 | ( 602 | {"JWT_ALGORITHM": "HS256", "JWT_SECRET": None}, 603 | "Cannot encode/decode JWT token. Specify a secret.", 604 | ), 605 | ( 606 | { 607 | "JWT_ALGORITHM": "HS256", 608 | "JWT_SECRET": "", 609 | }, 610 | "Cannot encode/decode JWT token. Specify a secret.", 611 | ), 612 | ( 613 | {"JWT_ALGORITHM": "HS256", "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"}, 614 | "Cannot encode/decode JWT token. Specify a secret.", 615 | ), 616 | ( 617 | {"JWT_ALGORITHM": "HS256", "JWT_SECRET": "secret", "JWT_EXP": -60}, 618 | "Cannot decode JWT token.", 619 | ), 620 | ( 621 | { 622 | "JWT_ALGORITHM": "RS256", 623 | }, 624 | "Cannot encode/decode JWT token. Specify a public key.", 625 | ), 626 | ( 627 | {"JWT_ALGORITHM": "RS256", "JWT_SECRET": "A_SECRET_PHRASE"}, 628 | "Cannot encode/decode JWT token. Specify a public key.", 629 | ), 630 | ], 631 | ) 632 | def test_decode_jwt_token_with_incorrect_jwt_settings( 633 | settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str 634 | ): 635 | """Test decode_jwt_token function by trying to create a JWT token with incorrect settings. 636 | 637 | Args: 638 | settings (SettingsWrapper): Fixture for django settings 639 | saml2_settings (Dict[str, str]): Fixture for SAML2 settings 640 | error_msg (str): Expected error message 641 | """ 642 | settings.SAML2_AUTH = saml2_settings 643 | 644 | with pytest.raises(SAMLAuthError) as exc_info: 645 | decode_custom_or_default_jwt("WHATEVER") 646 | 647 | assert str(exc_info.value) == error_msg 648 | 649 | 650 | def test_decode_jwt_token_failure(): 651 | """Test decode_jwt_token function by passing an invalid JWT token (None, in this case).""" 652 | with pytest.raises(SAMLAuthError) as exc_info: 653 | decode_custom_or_default_jwt(None) 654 | 655 | assert str(exc_info.value) == "Cannot decode JWT token." 656 | assert isinstance(exc_info.value.extra["exc"], PyJWTError) 657 | -------------------------------------------------------------------------------- /django_saml2_auth/tests/test_saml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for saml.py 3 | """ 4 | 5 | from typing import Any, Dict, Optional, List, Mapping, Union 6 | 7 | import pytest 8 | import responses 9 | from django.contrib.sessions.middleware import SessionMiddleware 10 | from unittest.mock import MagicMock 11 | from django.http import HttpRequest 12 | from django.test.client import RequestFactory, Client 13 | from django.urls import NoReverseMatch 14 | from saml2 import BINDING_HTTP_POST 15 | 16 | from django_saml2_auth.errors import INACTIVE_USER 17 | from django_saml2_auth.exceptions import SAMLAuthError 18 | from django_saml2_auth.saml import ( 19 | decode_saml_response, 20 | extract_user_identity, 21 | get_assertion_url, 22 | get_default_next_url, 23 | get_metadata, 24 | get_saml_client, 25 | validate_metadata_url, 26 | ) 27 | from django_saml2_auth.views import acs 28 | from pytest_django.fixtures import SettingsWrapper 29 | from saml2.client import Saml2Client 30 | from saml2.response import AuthnResponse 31 | from django_saml2_auth import user 32 | 33 | 34 | GET_METADATA_AUTO_CONF_URLS = "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls" 35 | METADATA_URL1 = "https://testserver1.com/saml/sso/metadata" 36 | METADATA_URL2 = "https://testserver2.com/saml/sso/metadata" 37 | # Ref: https://en.wikipedia.org/wiki/SAML_metadata#Entity_metadata 38 | METADATA1 = b""" 39 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | https://testserver1.com/category/self-certified 52 | 53 | 54 | 55 | 56 | 57 | ... 58 | ... 59 | https://testserver1.com/ 60 | 61 | 62 | SAML Technical Support 63 | mailto:technical-support@example.info 64 | 65 | """ 66 | METADATA2 = b""" 67 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | https://testserver2.com/category/self-certified 80 | 81 | 82 | 83 | 84 | 85 | ... 86 | ... 87 | https://testserver2.com/ 88 | 89 | 90 | SAML Technical Support 91 | mailto:technical-support@example.info 92 | 93 | """ 94 | DOMAIN_PATH_MAP = { 95 | "example.org": "django_saml2_auth/tests/metadata.xml", 96 | "example.com": "django_saml2_auth/tests/metadata2.xml", 97 | "api.example.com": "django_saml2_auth/tests/metadata.xml", 98 | } 99 | 100 | 101 | def get_metadata_auto_conf_urls( 102 | user_id: Optional[str] = None, 103 | ) -> List[Optional[Mapping[str, str]]]: 104 | """Fixture for returning metadata autoconf URL(s) based on the user_id. 105 | 106 | Args: 107 | user_id (str, optional): User identifier: username or email. Defaults to None. 108 | 109 | Returns: 110 | list: Either an empty list or a list of valid metadata URL(s) 111 | """ 112 | if user_id == "nonexistent_user@example.com": 113 | return [] 114 | if user_id == "test@example.com": 115 | return [{"url": METADATA_URL1}] 116 | return [{"url": METADATA_URL1}, {"url": METADATA_URL2}] 117 | 118 | 119 | def get_custom_assertion_url(): 120 | return "https://example.com/custom-tenant/acs" 121 | 122 | 123 | GET_CUSTOM_ASSERTION_URL = "django_saml2_auth.tests.test_saml.get_custom_assertion_url" 124 | 125 | 126 | def mock_extract_user_identity( 127 | user: Dict[str, Optional[Any]], authn_response: AuthnResponse 128 | ) -> Dict[str, Optional[Any]]: 129 | """Fixture for enriching user identity information with SAML attributes. 130 | 131 | Args: 132 | authn_response (AuthnResponse): A parsed SAML response 133 | 134 | Returns: 135 | dict: keys are SAML attributes and values are lists of attribute values 136 | """ 137 | return { 138 | "user.username": ["test@example.com"], 139 | "user.email": ["test@example.com"], 140 | "user.first_name": ["John"], 141 | "user.last_name": ["Doe"], 142 | "issuer": authn_response.issuer(), # Extra attribute 143 | } 144 | 145 | 146 | def get_user_identity() -> Mapping[str, List[str]]: 147 | """Fixture for returning user identity produced by pysaml2. 148 | 149 | Returns: 150 | dict: keys are SAML attributes and values are lists of attribute values 151 | """ 152 | return { 153 | "user.username": ["test@example.com"], 154 | "user.email": ["test@example.com"], 155 | "user.first_name": ["John"], 156 | "user.last_name": ["Doe"], 157 | "token": ["TOKEN"], 158 | } 159 | 160 | 161 | def get_user_identify_with_slashed_keys() -> Mapping[str, List[str]]: 162 | """Fixture for returning user identity produced by pysaml2 with slashed, claim-like keys. 163 | 164 | Returns: 165 | dict: keys are SAML attributes and values are lists of attribute values 166 | """ 167 | return { 168 | "http://schemas.org/user/username": ["test@example.com"], 169 | "http://schemas.org/user/claim2.0/email": ["test@example.com"], 170 | "http://schemas.org/user/claim2.0/first_name": ["John"], 171 | "http://schemas.org/user/claim2.0/last_name": ["Doe"], 172 | "http://schemas.org/auth/server/token": ["TOKEN"], 173 | } 174 | 175 | 176 | def mock_parse_authn_request_response( 177 | self: Saml2Client, 178 | response: AuthnResponse, 179 | binding: str, 180 | slash_keys: bool = False, 181 | ) -> "MockAuthnResponse": # type: ignore # noqa: F821 182 | """Mock function to return an mocked instance of AuthnResponse. 183 | 184 | Returns: 185 | MockAuthnResponse: A mocked instance of AuthnResponse 186 | """ 187 | 188 | class MockAuthnRequest: 189 | """Mock class for AuthnRequest.""" 190 | 191 | name_id = "Username" 192 | 193 | @staticmethod 194 | def issuer(): 195 | """Mock function for AuthnRequest.issuer().""" 196 | return METADATA_URL1 197 | 198 | @staticmethod 199 | def get_identity(): 200 | """Mock function for AuthnRequest.get_identity().""" 201 | if slash_keys: 202 | return get_user_identify_with_slashed_keys() 203 | return get_user_identity() 204 | 205 | return MockAuthnRequest() 206 | 207 | 208 | def test_get_assertion_url_success(): 209 | """Test get_assertion_url function to verify if it correctly returns the default assertion URL.""" 210 | assertion_url = get_assertion_url(HttpRequest()) 211 | assert assertion_url == "https://api.example.com" 212 | 213 | 214 | def test_get_assertion_url_no_assertion_url(settings: SettingsWrapper): 215 | """Test get_assertion_url function to verify if it correctly returns the server's assertion URL 216 | based on the incoming request. 217 | 218 | Args: 219 | settings (SettingsWrapper): Fixture for django settings 220 | """ 221 | settings.SAML2_AUTH["ASSERTION_URL"] = None 222 | get_request = RequestFactory().get("/acs/") 223 | assertion_url = get_assertion_url(get_request) 224 | assert assertion_url == "http://testserver" 225 | 226 | 227 | def test_get_default_next_url_success(): 228 | """Test get_default_next_url to verify if it returns the correct default next URL.""" 229 | default_next_url = get_default_next_url() 230 | assert default_next_url == "http://app.example.com/account/login" 231 | 232 | 233 | def test_get_default_next_url_no_default_next_url(settings: SettingsWrapper): 234 | """Test get_default_next_url function with no default next url for redirection to see if it 235 | returns the admin:index route. 236 | 237 | Args: 238 | settings (SettingsWrapper): Fixture for django settings 239 | """ 240 | settings.SAML2_AUTH["DEFAULT_NEXT_URL"] = None 241 | with pytest.raises(SAMLAuthError) as exc_info: 242 | get_default_next_url() 243 | 244 | # This doesn't happen on a real instance, unless you don't have "admin:index" route 245 | assert str(exc_info.value) == "We got a URL reverse issue: ['admin:index']" 246 | assert exc_info.value.extra is not None 247 | assert issubclass(exc_info.value.extra["exc_type"], NoReverseMatch) 248 | 249 | 250 | @responses.activate 251 | def test_validate_metadata_url_success(): 252 | """Test validate_metadata_url function to verify a valid metadata URL.""" 253 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 254 | result = validate_metadata_url(METADATA_URL1) 255 | assert result 256 | 257 | 258 | @responses.activate 259 | def test_validate_metadata_url_failure(): 260 | """Test validate_metadata_url function to verify if it correctly identifies an invalid metadata 261 | URL.""" 262 | responses.add(responses.GET, METADATA_URL1) 263 | result = validate_metadata_url(METADATA_URL1) 264 | assert result is False 265 | 266 | 267 | @responses.activate 268 | def test_get_metadata_success_with_single_metadata_url(settings: SettingsWrapper): 269 | """Test get_metadata function to verify if it returns a valid metadata URL with a correct 270 | format. 271 | 272 | Args: 273 | settings (SettingsWrapper): Fixture for django settings 274 | """ 275 | settings.SAML2_AUTH["METADATA_AUTO_CONF_URL"] = METADATA_URL1 276 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None 277 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 278 | 279 | result = get_metadata() 280 | assert result == {"remote": [{"url": METADATA_URL1}]} 281 | 282 | 283 | def test_get_metadata_failure_with_invalid_metadata_url(settings: SettingsWrapper): 284 | """Test get_metadata function to verify if it fails with invalid metadata information. 285 | 286 | Args: 287 | settings (SettingsWrapper): Fixture for django settings 288 | """ 289 | # HTTP Responses are not mocked, so this will fail. 290 | settings.SAML2_AUTH["METADATA_AUTO_CONF_URL"] = METADATA_URL1 291 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None 292 | 293 | with pytest.raises(SAMLAuthError) as exc_info: 294 | get_metadata() 295 | 296 | assert str(exc_info.value) == "Invalid metadata URL." 297 | 298 | 299 | @responses.activate 300 | def test_get_metadata_success_with_multiple_metadata_urls(settings: SettingsWrapper): 301 | """Test get_metadata function to verify if it returns multiple metadata URLs if the user_id is 302 | unknown. 303 | 304 | Args: 305 | settings (SettingsWrapper): Fixture for django settings 306 | """ 307 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 308 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 309 | responses.add(responses.GET, METADATA_URL2, body=METADATA2) 310 | 311 | result = get_metadata() 312 | assert result == {"remote": [{"url": METADATA_URL1}, {"url": METADATA_URL2}]} 313 | 314 | 315 | @responses.activate 316 | def test_get_metadata_success_with_user_id(settings: SettingsWrapper): 317 | """Test get_metadata function to verify if it returns a valid metadata URLs given the user_id. 318 | 319 | Args: 320 | settings (SettingsWrapper): Fixture for django settings 321 | """ 322 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 323 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 324 | 325 | result = get_metadata("test@example.com") 326 | assert result == {"remote": [{"url": METADATA_URL1}]} 327 | 328 | 329 | def test_get_metadata_failure_with_nonexistent_user_id(settings: SettingsWrapper): 330 | """Test get_metadata function to verify if it raises an exception given a nonexistent user_id. 331 | 332 | Args: 333 | settings (SettingsWrapper): Fixture for django settings 334 | """ 335 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 336 | 337 | with pytest.raises(SAMLAuthError) as exc_info: 338 | get_metadata("nonexistent_user@example.com") 339 | assert str(exc_info.value) == "No metadata URL associated with the given user identifier." 340 | 341 | 342 | def test_get_metadata_success_with_local_file(settings: SettingsWrapper): 343 | """Test get_metadata function to verify if correctly returns path to local metadata file. 344 | 345 | Args: 346 | settings (SettingsWrapper): Fixture for django settings 347 | """ 348 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None 349 | settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "/absolute/path/to/metadata.xml" 350 | 351 | result = get_metadata() 352 | assert result == {"local": ["/absolute/path/to/metadata.xml"]} 353 | 354 | 355 | def test_get_saml_client_success(settings: SettingsWrapper): 356 | """Test get_saml_client function to verify if it is correctly instantiated with local metadata 357 | file. 358 | 359 | Args: 360 | settings (SettingsWrapper): Fixture for django settings 361 | """ 362 | settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml" 363 | result = get_saml_client("example.com", acs) 364 | assert isinstance(result, Saml2Client) 365 | 366 | 367 | @responses.activate 368 | def test_get_saml_client_success_with_user_id(settings: SettingsWrapper): 369 | """Test get_saml_client function to verify if it is correctly instantiated with remote metadata 370 | URL and valid user_id. 371 | 372 | Args: 373 | settings (SettingsWrapper): Fixture for django settings 374 | """ 375 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 376 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 377 | 378 | result = get_saml_client("example.com", acs, "test@example.com") 379 | assert isinstance(result, Saml2Client) 380 | 381 | 382 | def test_get_saml_client_failure_with_missing_metadata_url(settings: SettingsWrapper): 383 | """Test get_saml_client function to verify if it raises an exception given a missing non-mocked 384 | metadata URL. 385 | 386 | Args: 387 | settings (SettingsWrapper): Fixture for django settings 388 | """ 389 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 390 | 391 | with pytest.raises(SAMLAuthError) as exc_info: 392 | get_saml_client("example.com", acs, "test@example.com") 393 | 394 | assert str(exc_info.value) == "Metadata URL/file is missing." 395 | 396 | 397 | def test_get_saml_client_failure_with_invalid_file(settings: SettingsWrapper): 398 | """Test get_saml_client function to verify if it raises an exception given an invalid path to 399 | metadata file. 400 | 401 | Args: 402 | settings (SettingsWrapper): Fixture for django settings 403 | """ 404 | settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "/invalid/metadata.xml" 405 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None 406 | 407 | with pytest.raises(SAMLAuthError) as exc_info: 408 | get_saml_client("example.com", acs) 409 | 410 | assert str(exc_info.value) == "[Errno 2] No such file or directory: '/invalid/metadata.xml'" 411 | assert exc_info.value.extra is not None 412 | assert isinstance(exc_info.value.extra["exc"], FileNotFoundError) 413 | 414 | 415 | @pytest.mark.parametrize( 416 | "supplied_config_values,expected_encryption_keypairs", 417 | [ 418 | ( 419 | { 420 | "KEY_FILE": "django_saml2_auth/tests/dummy_key.pem", 421 | }, 422 | None, 423 | ), 424 | ( 425 | { 426 | "CERT_FILE": "django_saml2_auth/tests/dummy_cert.pem", 427 | }, 428 | None, 429 | ), 430 | ( 431 | { 432 | "KEY_FILE": "django_saml2_auth/tests/dummy_key.pem", 433 | "CERT_FILE": "django_saml2_auth/tests/dummy_cert.pem", 434 | }, 435 | [ 436 | { 437 | "key_file": "django_saml2_auth/tests/dummy_key.pem", 438 | "cert_file": "django_saml2_auth/tests/dummy_cert.pem", 439 | } 440 | ], 441 | ), 442 | ], 443 | ) 444 | def test_get_saml_client_success_with_key_and_cert_files( 445 | settings: SettingsWrapper, 446 | supplied_config_values: Dict[str, str], 447 | expected_encryption_keypairs: Union[List, None], 448 | ): 449 | """Test get_saml_client function to verify that it is correctly instantiated with encryption_keypairs 450 | if both key_file and cert_file are provided (even if encryption_keypairs isn't). 451 | 452 | Args: 453 | settings (SettingsWrapper): Fixture for django settings 454 | """ 455 | 456 | settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml" 457 | 458 | for key, value in supplied_config_values.items(): 459 | settings.SAML2_AUTH[key] = value 460 | 461 | result = get_saml_client("example.com", acs) 462 | assert isinstance(result, Saml2Client) 463 | assert result.config.encryption_keypairs == expected_encryption_keypairs 464 | 465 | for key, value in supplied_config_values.items(): 466 | # ensure that the added settings do not get carried over to other tests 467 | del settings.SAML2_AUTH[key] 468 | 469 | 470 | def test_get_saml_client_success_with_custom_assertion_url_hook(settings: SettingsWrapper): 471 | settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml" 472 | 473 | settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_ASSERTION_URL"] = GET_CUSTOM_ASSERTION_URL 474 | 475 | result = get_saml_client("example.com", acs, "test@example.com") 476 | assert result is not None 477 | assert "https://example.com/custom-tenant/acs" in result.config.endpoint( 478 | "assertion_consumer_service", 479 | BINDING_HTTP_POST, 480 | "sp", 481 | ) 482 | 483 | @responses.activate 484 | def test_decode_saml_response_success( 485 | settings: SettingsWrapper, 486 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 487 | ): 488 | """Test decode_saml_response function to verify if it correctly decodes the SAML response. 489 | 490 | Args: 491 | settings (SettingsWrapper): Fixture for django settings 492 | monkeypatch (MonkeyPatch): PyTest monkeypatch fixture 493 | """ 494 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 495 | settings.SAML2_AUTH["ASSERTION_URL"] = "https://api.example.com" 496 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS 497 | 498 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 499 | monkeypatch.setattr( 500 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 501 | ) 502 | result = decode_saml_response(post_request, acs) 503 | assert len(result.get_identity()) > 0 # type: ignore 504 | 505 | 506 | @responses.activate 507 | def test_extract_user_identity_success( 508 | settings: SettingsWrapper, 509 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 510 | ): 511 | """Test extract_user_identity function to verify if it correctly extracts user identity 512 | information from a (pysaml2) parsed SAML response.""" 513 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 514 | settings.SAML2_AUTH["ASSERTION_URL"] = "https://api.example.com" 515 | settings.SAML2_AUTH["TRIGGER"] = { 516 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 517 | } 518 | 519 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 520 | monkeypatch.setattr( 521 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 522 | ) 523 | authn_response = decode_saml_response(post_request, acs) 524 | 525 | result = extract_user_identity(authn_response) # type: ignore 526 | assert len(result) == 6 527 | assert result["username"] == result["email"] == "test@example.com" 528 | assert result["first_name"] == "John" 529 | assert result["last_name"] == "Doe" 530 | assert result["token"] == "TOKEN" 531 | assert result["user_identity"] == get_user_identity() 532 | 533 | 534 | @responses.activate 535 | def test_extract_user_identity_with_slashed_attribute_keys_success( 536 | settings: SettingsWrapper, 537 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 538 | ): 539 | """Test extract_user_identity function to verify if it correctly extracts user identity 540 | information from a (pysaml2) parsed SAML response with slashed attribute keys.""" 541 | settings.SAML2_AUTH = { 542 | "ATTRIBUTES_MAP": { 543 | "email": "http://schemas.org/user/claim2.0/email", 544 | "username": "http://schemas.org/user/username", 545 | "first_name": "http://schemas.org/user/claim2.0/first_name", 546 | "last_name": "http://schemas.org/user/claim2.0/last_name", 547 | "token": "http://schemas.org/auth/server/token", 548 | }, 549 | "ASSERTION_URL": "https://api.example.com", 550 | "TRIGGER": { 551 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 552 | }, 553 | } 554 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 555 | 556 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 557 | monkeypatch.setattr( 558 | Saml2Client, 559 | "parse_authn_request_response", 560 | lambda *args, **kwargs: mock_parse_authn_request_response(*args, **kwargs, slash_keys=True), # type: ignore 561 | ) 562 | authn_response = decode_saml_response(post_request, acs) 563 | 564 | result = extract_user_identity(authn_response) # type: ignore 565 | 566 | assert len(result) == 6 567 | assert result["username"] == result["email"] == "test@example.com" 568 | assert result["first_name"] == "John" 569 | assert result["last_name"] == "Doe" 570 | assert result["token"] == "TOKEN" 571 | assert result["user_identity"] == get_user_identify_with_slashed_keys() 572 | 573 | 574 | @responses.activate 575 | def test_extract_user_identity_token_not_required( 576 | settings: SettingsWrapper, 577 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 578 | ): 579 | """Test extract_user_identity function to verify if it correctly extracts user identity 580 | information from a (pysaml2) parsed SAML response when token is not required.""" 581 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 582 | settings.SAML2_AUTH = { 583 | "TOKEN_REQUIRED": False, 584 | "ASSERTION_URL": "https://api.example.com", 585 | "TRIGGER": { 586 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 587 | }, 588 | } 589 | 590 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 591 | monkeypatch.setattr( 592 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 593 | ) 594 | authn_response = decode_saml_response(post_request, acs) 595 | 596 | result = extract_user_identity(authn_response) # type: ignore 597 | assert len(result) == 5 598 | assert "token" not in result 599 | 600 | 601 | @responses.activate 602 | def test_extract_user_identity_with_custom_trigger( 603 | settings: SettingsWrapper, 604 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 605 | ): 606 | """Test extract_user_identity function to verify if it correctly extracts user identity 607 | information from a (pysaml2) parsed SAML response when token is not required. The function 608 | uses a custom trigger to enrich the user identity information with the issuer attribute. 609 | """ 610 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 611 | settings.SAML2_AUTH = { 612 | "TOKEN_REQUIRED": False, 613 | "ASSERTION_URL": "https://api.example.com", 614 | "TRIGGER": { 615 | "EXTRACT_USER_IDENTITY": "django_saml2_auth.tests.test_saml.mock_extract_user_identity", 616 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 617 | }, 618 | } 619 | 620 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 621 | monkeypatch.setattr( 622 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 623 | ) 624 | authn_response = decode_saml_response(post_request, acs) 625 | 626 | result = extract_user_identity(authn_response) # type: ignore 627 | assert len(result) == 5 628 | assert "token" not in result 629 | assert result["issuer"] == METADATA_URL1 630 | 631 | 632 | @pytest.mark.django_db 633 | @responses.activate 634 | def test_acs_view_when_next_url_is_none( 635 | settings: SettingsWrapper, 636 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 637 | ): 638 | """Test Acs view when login_next_url is None in the session""" 639 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 640 | settings.SAML2_AUTH = { 641 | "ASSERTION_URL": "https://api.example.com", 642 | "DEFAULT_NEXT_URL": "default_next_url", 643 | "USE_JWT": False, 644 | "TRIGGER": { 645 | "BEFORE_LOGIN": None, 646 | "AFTER_LOGIN": None, 647 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 648 | }, 649 | } 650 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 651 | 652 | monkeypatch.setattr( 653 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 654 | ) 655 | 656 | created, mock_user = user.get_or_create_user( 657 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 658 | ) 659 | 660 | monkeypatch.setattr( 661 | user, 662 | "get_or_create_user", 663 | ( 664 | created, 665 | mock_user, 666 | ), 667 | ) 668 | 669 | middleware = SessionMiddleware(MagicMock()) 670 | middleware.process_request(post_request) 671 | post_request.session["login_next_url"] = None 672 | post_request.session.save() 673 | 674 | result = acs(post_request) 675 | assert result["Location"] == "default_next_url" 676 | 677 | 678 | @pytest.mark.django_db 679 | @responses.activate 680 | def test_acs_view_when_redirection_state_is_passed_in_relay_state( 681 | settings: SettingsWrapper, 682 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 683 | ): 684 | """Test Acs view when login_next_url is None and redirection state in POST request""" 685 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 686 | settings.SAML2_AUTH = { 687 | "ASSERTION_URL": "https://api.example.com", 688 | "DEFAULT_NEXT_URL": "default_next_url", 689 | "USE_JWT": False, 690 | "TRIGGER": { 691 | "BEFORE_LOGIN": None, 692 | "AFTER_LOGIN": None, 693 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 694 | }, 695 | } 696 | post_request = RequestFactory().post( 697 | METADATA_URL1, {"SAMLResponse": "SAML RESPONSE", "RelayState": "/admin/logs"} 698 | ) 699 | 700 | monkeypatch.setattr( 701 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 702 | ) 703 | 704 | created, mock_user = user.get_or_create_user( 705 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 706 | ) 707 | 708 | monkeypatch.setattr( 709 | user, 710 | "get_or_create_user", 711 | ( 712 | created, 713 | mock_user, 714 | ), 715 | ) 716 | 717 | middleware = SessionMiddleware(MagicMock()) 718 | middleware.process_request(post_request) 719 | post_request.session["login_next_url"] = None 720 | post_request.session.save() 721 | 722 | result = acs(post_request) 723 | assert result["Location"] == "/admin/logs" 724 | 725 | 726 | def get_custom_metadata_example( 727 | user_id: Optional[str] = None, 728 | domain: Optional[str] = None, 729 | saml_response: Optional[str] = None, 730 | ): 731 | """ 732 | Get metadata file locally depending on current SP domain 733 | """ 734 | metadata_file_path = "/absolute/path/to/metadata.xml" 735 | if domain: 736 | protocol_idx = domain.find("https://") 737 | if protocol_idx > -1: 738 | domain = domain[protocol_idx + 8 :] 739 | if domain in DOMAIN_PATH_MAP: 740 | print("metadata domain", domain) 741 | metadata_file_path = DOMAIN_PATH_MAP[domain] 742 | print("metadata path", metadata_file_path) 743 | else: 744 | raise SAMLAuthError(f"Domain {domain} not mapped!") 745 | else: 746 | # Fallback to local path 747 | metadata_file_path = "/absolute/path/to/metadata.xml" 748 | return {"local": [metadata_file_path]} 749 | 750 | 751 | # WARNING: leave this test at the end or add 752 | # settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_METADATA"] = None 753 | # to following tests that uses settings, otherwise the TRIGGER.GET_CUSTOM_METADATA is always set 754 | # and used in the get_metadata function 755 | 756 | 757 | def test_get_metadata_success_with_custom_trigger(settings: SettingsWrapper): 758 | """Test get_metadata function to verify if correctly returns path to local metadata file. 759 | 760 | Args: 761 | settings (SettingsWrapper): Fixture for django settings 762 | """ 763 | settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = None 764 | settings.SAML2_AUTH["TRIGGER"]["GET_CUSTOM_METADATA"] = ( 765 | "django_saml2_auth.tests.test_saml.get_custom_metadata_example" 766 | ) 767 | 768 | result = get_metadata(domain="https://example.com") 769 | assert result == {"local": ["django_saml2_auth/tests/metadata2.xml"]} 770 | 771 | with pytest.raises(SAMLAuthError) as exc_info: 772 | get_metadata(domain="not-mapped-example.com") 773 | 774 | assert str(exc_info.value) == "Domain not-mapped-example.com not mapped!" 775 | 776 | 777 | @pytest.mark.django_db 778 | @responses.activate 779 | def test_acs_view_with_use_jwt_both_redirects_user_and_sets_cookies( 780 | settings: SettingsWrapper, 781 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 782 | ): 783 | """Test Acs view when USE_JWT is set, the user is redirected and cookies are set""" 784 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 785 | settings.SAML2_AUTH = { 786 | "DEFAULT_NEXT_URL": "default_next_url", 787 | "USE_JWT": True, 788 | "JWT_SECRET": "JWT_SECRET", 789 | "JWT_ALGORITHM": "HS256", 790 | "FRONTEND_URL": "https://app.example.com/account/login/saml", 791 | "TRIGGER": { 792 | "BEFORE_LOGIN": None, 793 | "AFTER_LOGIN": None, 794 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 795 | }, 796 | } 797 | monkeypatch.setattr( 798 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 799 | ) 800 | client = Client() 801 | response = client.post("/acs/", {"SAMLResponse": "SAML RESPONSE", "RelayState": "/"}) 802 | 803 | # Response includes a redirect to the single page app, with the JWT in the query string. 804 | assert response.status_code == 302 805 | assert "https://app.example.com/account/login/saml?token=eyJ" in getattr(response, "url") 806 | # Response includes a session id cookie (i.e. the user is logged in to the django admin console) 807 | assert response.cookies.get("sessionid") 808 | 809 | 810 | @pytest.mark.django_db 811 | @responses.activate 812 | def test_acs_view_use_jwt_set_inactive_user( 813 | settings: SettingsWrapper, 814 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 815 | ): 816 | """Test Acs view when USE_JWT is set that inactive users can not log in""" 817 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 818 | settings.SAML2_AUTH = { 819 | "DEFAULT_NEXT_URL": "default_next_url", 820 | "USE_JWT": True, 821 | "JWT_SECRET": "JWT_SECRET", 822 | "JWT_ALGORITHM": "HS256", 823 | "FRONTEND_URL": "https://app.example.com/account/login/saml", 824 | "TRIGGER": { 825 | "BEFORE_LOGIN": None, 826 | "AFTER_LOGIN": None, 827 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 828 | }, 829 | } 830 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 831 | monkeypatch.setattr( 832 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 833 | ) 834 | created, mock_user = user.get_or_create_user( 835 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 836 | ) 837 | mock_user.is_active = False 838 | mock_user.save() 839 | monkeypatch.setattr(user, "get_or_create_user", (created, mock_user)) 840 | 841 | middleware = SessionMiddleware(MagicMock()) 842 | middleware.process_request(post_request) 843 | post_request.session.save() 844 | 845 | result = acs(post_request) 846 | assert result.status_code == 500 847 | assert f"Error code: {INACTIVE_USER}" in result.content.decode() 848 | 849 | @pytest.mark.django_db 850 | @responses.activate 851 | def test_acs_view_when_use_jwt_next_url_has_query_parameters( 852 | settings: SettingsWrapper, 853 | monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821 854 | ): 855 | """Test Acs view when login_next_url has query parameters in the session""" 856 | responses.add(responses.GET, METADATA_URL1, body=METADATA1) 857 | settings.SAML2_AUTH = { 858 | "ASSERTION_URL": "https://api.example.com", 859 | "DEFAULT_NEXT_URL": "default_next_url", 860 | "USE_JWT": True, 861 | "JWT_SECRET": "JWT_SECRET", 862 | "JWT_ALGORITHM": "HS256", 863 | "TRIGGER": { 864 | "BEFORE_LOGIN": None, 865 | "AFTER_LOGIN": None, 866 | "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS, 867 | }, 868 | } 869 | post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}) 870 | 871 | monkeypatch.setattr( 872 | Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response 873 | ) 874 | 875 | created, mock_user = user.get_or_create_user( 876 | {"username": "test@example.com", "first_name": "John", "last_name": "Doe"} 877 | ) 878 | 879 | monkeypatch.setattr( 880 | user, 881 | "get_or_create_user", 882 | ( 883 | created, 884 | mock_user, 885 | ), 886 | ) 887 | 888 | middleware = SessionMiddleware(MagicMock()) 889 | middleware.process_request(post_request) 890 | post_request.session["login_next_url"] = "/endpoint/?query=param&another=param" 891 | post_request.session.save() 892 | 893 | result = acs(post_request) 894 | assert result["Location"].count("?") == 1 895 | assert result["Location"].count("&") == 2 896 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django SAML2 Authentication 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/grafana-django-saml2-auth?label=version&logo=pypi)](https://pypi.org/project/grafana-django-saml2-auth/) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/grafana/django-saml2-auth/deploy.yml?branch=main&logo=github)](https://github.com/grafana/django-saml2-auth/actions) [![Coveralls](https://img.shields.io/coveralls/github/grafana/django-saml2-auth?logo=coveralls)](https://coveralls.io/github/grafana/django-saml2-auth) [![Downloads](https://pepy.tech/badge/grafana-django-saml2-auth)](https://pepy.tech/project/grafana-django-saml2-auth) 4 | 5 | This plugin provides a simple way to integrate SAML2 Authentication into your Django-powered app. SAML SSO is a standard, so practically any SAML2 based SSO identity provider is supported. 6 | 7 | This plugin supports both identity provider and service provider-initiated SSO: 8 | 9 | - For IdP-initiated SSO, the user should sign in to their identity provider platform, e.g., Okta, and click on the application that authorizes and redirects the user to the service provider, that is your platform. 10 | - For SP-initiated SSO, the user should first exist on your platform, either by signing in via the first method (IdP-initiated SSO) or any other custom solution. It can be configured to be redirected to the correct application on the identity provider platform. 11 | 12 | For IdP-initiated SSO, the user will be created if it doesn't exist. Still, for SP-initiated SSO, the user should exist in your platform for the code to detect and redirect them to the correct application on the identity provider platform. 13 | 14 | ## Project Information 15 | 16 | - Original Author: Fang Li ([@fangli](https://github.com/fangli)) 17 | - Maintainer: Mostafa Moradian ([@mostafa](https://github.com/mostafa)) 18 | - Version support matrix: 19 | 20 | | **Python** | **Django** | **django-saml2-auth** | **End of extended support
(Django)** | 21 | | ------------------------------ | ---------- | --------------------- | ---------------------------------------- | 22 | | 3.10.x, 3.11.x, 3.12.x | 4.2.x | >=3.4.0 | April 2026 | 23 | | 3.10.x, 3.11.x, 3.12.x, 3.13.x | 5.1.x | >3.12.0 | December 2025 | 24 | | 3.10.x, 3.11.x, 3.12.x, 3.13.x | 5.2.x | >3.12.0 | April 2028 | 25 | 26 | - Release logs are available [here](https://github.com/grafana/django-saml2-auth/releases). 27 | 28 | - For contribution, read [contributing guide](CONTRIBUTING.md). 29 | 30 | ## CycloneDX SBOM 31 | 32 | This project provides a CycloneDX Software Bill of Materials (SBOM) in JSON format. The SBOM is generated by the [GitHub Actions workflow](.github/workflows/deploy.yml) and is available as an artifact for each release. The SBOM is generated using the [cyclonedx-python](https://github.com/CycloneDX/cyclonedx-python) library. 33 | 34 | ## Donate 35 | 36 | Please give us a shiny ![star](https://img.shields.io/github/stars/grafana/django-saml2-auth.svg?style=social&label=Star&maxAge=86400) and help spread the word. 37 | 38 | ## Installation 39 | 40 | You can install this plugin via `pip`. Make sure you update `pip` to be able to install from git: 41 | 42 | ```bash 43 | pip install grafana-django-saml2-auth 44 | ``` 45 | 46 | or from source: 47 | 48 | ```bash 49 | git clone https://github.com/grafana/django-saml2-auth 50 | cd django-saml2-auth 51 | python setup.py install 52 | ``` 53 | 54 | `xmlsec` is also required by `pysaml2`, so it must be installed: 55 | 56 | ``` bash 57 | // RPM-based distributions 58 | # yum install xmlsec1 59 | // DEB-based distributions 60 | # apt-get install xmlsec1 61 | // macOS 62 | # brew install xmlsec1 63 | ``` 64 | 65 | [Windows binaries](https://www.zlatkovic.com/projects/libxml/index.html) are also available. 66 | 67 | ## How to use? 68 | 69 | 1. Once you have the library installed or in your `requirements.txt`, import the views module in your root `urls.py`: 70 | 71 | ```python 72 | import django_saml2_auth.views 73 | ``` 74 | 75 | 2. Override the default login page in the root `urls.py` file, by adding these lines **BEFORE** any `urlpatterns`: 76 | 77 | ```python 78 | # These are the SAML2 related URLs. (required) 79 | re_path(r'^sso/', include('django_saml2_auth.urls')), 80 | 81 | # The following line will replace the default user login with SAML2 (optional) 82 | # If you want to specific the after-login-redirect-URL, use parameter "?next=/the/path/you/want" 83 | # with this view. 84 | re_path(r'^accounts/login/$', django_saml2_auth.views.signin), 85 | 86 | # The following line will replace the admin login with SAML2 (optional) 87 | # If you want to specific the after-login-redirect-URL, use parameter "?next=/the/path/you/want" 88 | # with this view. 89 | re_path(r'^admin/login/$', django_saml2_auth.views.signin), 90 | ``` 91 | 92 | 3. Add `'django_saml2_auth'` to `INSTALLED_APPS` in your django `settings.py`: 93 | 94 | ```python 95 | INSTALLED_APPS = [ 96 | '...', 97 | 'django_saml2_auth', 98 | ] 99 | ``` 100 | 101 | 4. In `settings.py`, add the SAML2 related configuration: 102 | 103 | Please note, the only required setting is **METADATA\_AUTO\_CONF\_URL** or the existence of a **GET\_METADATA\_AUTO\_CONF\_URLS** trigger function. The following block shows all required and optional configuration settings and their default values. 104 | 105 |
106 | Click to see the entire settings block 107 | 108 | ```python 109 | SAML2_AUTH = { 110 | # Metadata is required, choose either remote url or local file path 111 | 'METADATA_AUTO_CONF_URL': '[The auto(dynamic) metadata configuration URL of SAML2]', 112 | 'METADATA_LOCAL_FILE_PATH': '[The metadata configuration file path]', 113 | 'KEY_FILE': '[The key file path]', 114 | 'CERT_FILE': '[The certificate file path]', 115 | 116 | # If both `KEY_FILE` and `CERT_FILE` are provided, `ENCRYPTION_KEYPAIRS` will be added automatically. There is no need to provide it unless you wish to override the default value. 117 | 'ENCRYPTION_KEYPAIRS': [ 118 | { 119 | "key_file": '[The key file path]', 120 | "cert_file": '[The certificate file path]', 121 | } 122 | ], 123 | 124 | 'DEBUG': False, # Send debug information to a log file 125 | # Optional logging configuration. 126 | # By default, it won't log anything. 127 | # The following configuration is an example of how to configure the logger, 128 | # which can be used together with the DEBUG option above. Please note that 129 | # the logger configuration follows the Python's logging configuration schema: 130 | # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema 131 | 'LOGGING': { 132 | 'version': 1, 133 | 'formatters': { 134 | 'simple': { 135 | 'format': '[%(asctime)s] [%(levelname)s] [%(name)s.%(funcName)s] %(message)s', 136 | }, 137 | }, 138 | 'handlers': { 139 | 'stdout': { 140 | 'class': 'logging.StreamHandler', 141 | 'stream': 'ext://sys.stdout', 142 | 'level': 'DEBUG', 143 | 'formatter': 'simple', 144 | }, 145 | }, 146 | 'loggers': { 147 | 'saml2': { 148 | 'level': 'DEBUG' 149 | }, 150 | }, 151 | 'root': { 152 | 'level': 'DEBUG', 153 | 'handlers': [ 154 | 'stdout', 155 | ], 156 | }, 157 | }, 158 | 159 | # Optional settings below 160 | 'DEFAULT_NEXT_URL': '/admin', # Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter ?next= specificed in the login URL. 161 | 'CREATE_USER': True, # Create a new Django user when a new user logs in. Defaults to True. 162 | 'NEW_USER_PROFILE': { 163 | 'USER_GROUPS': [], # The default group name when a new user logs in 164 | 'ACTIVE_STATUS': True, # The default active status for new users 165 | 'STAFF_STATUS': False, # The staff status for new users 166 | 'SUPERUSER_STATUS': False, # The superuser status for new users 167 | }, 168 | 'ATTRIBUTES_MAP': { # Change Email/UserName/FirstName/LastName to corresponding SAML2 userprofile attributes. 169 | 'email': 'user.email', 170 | 'username': 'user.username', 171 | 'first_name': 'user.first_name', 172 | 'last_name': 'user.last_name', 173 | 'token': 'Token', # Mandatory, can be unrequired if TOKEN_REQUIRED is False 174 | 'groups': 'Groups', # Optional 175 | }, 176 | 'GROUPS_MAP': { # Optionally allow mapping SAML2 Groups to Django Groups 177 | 'SAML Group Name': 'Django Group Name', 178 | }, 179 | 'TRIGGER': { 180 | 'EXTRACT_USER_IDENTITY': 'path.to.your.extract.user.identity.hook.method', 181 | # Optional: needs to return a User Model instance or None 182 | 'GET_USER': 'path.to.your.get.user.hook.method', 183 | 'CREATE_USER': 'path.to.your.new.user.hook.method', 184 | 'BEFORE_LOGIN': 'path.to.your.login.hook.method', 185 | 'AFTER_LOGIN': 'path.to.your.after.login.hook.method', 186 | # Optional. This is executed right before METADATA_AUTO_CONF_URL. 187 | # For systems with many metadata files registered allows to narrow the search scope. 188 | 'GET_USER_ID_FROM_SAML_RESPONSE': 'path.to.your.get.user.from.saml.hook.method', 189 | # This can override the METADATA_AUTO_CONF_URL to enumerate all existing metadata autoconf URLs 190 | 'GET_METADATA_AUTO_CONF_URLS': 'path.to.your.get.metadata.conf.hook.method', 191 | # This will override ASSERTION_URL to allow more dynamic assertion URLs 192 | 'GET_CUSTOM_ASSERTION_URL': 'path.to.your.get.custom.assertion.url.hook.method', 193 | # This will override FRONTEND_URL for more dynamic URLs 194 | 'GET_CUSTOM_FRONTEND_URL': 'path.to.your.get.custom.frontend.url.hook.method', 195 | }, 196 | 'ASSERTION_URL': 'https://mysite.com', # Custom URL to validate incoming SAML requests against 197 | 'ENTITY_ID': 'https://mysite.com/sso/acs/', # Populates the Issuer element in authn request 198 | 'NAME_ID_FORMAT': FormatString, # Sets the Format property of authn NameIDPolicy element, e.g. 'user.email' 199 | '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 200 | 'JWT_ALGORITHM': 'HS256', # JWT algorithm to sign the message with 201 | 'JWT_SECRET': 'your.jwt.secret', # JWT secret to sign the message with 202 | 'JWT_PRIVATE_KEY': '--- YOUR PRIVATE KEY ---', # Private key to sign the message with. The algorithm should be set to RSA256 or a more secure alternative. 203 | 'JWT_PRIVATE_KEY_PASSPHRASE': 'your.passphrase', # If your private key is encrypted, you might need to provide a passphrase for decryption 204 | 'JWT_PUBLIC_KEY': '--- YOUR PUBLIC KEY ---', # Public key to decode the signed JWT token 205 | 'JWT_EXP': 60, # JWT expiry time in seconds 206 | 'FRONTEND_URL': 'https://myfrontendclient.com', # Redirect URL for the client if you are using JWT auth with DRF. See explanation below 207 | 'LOGIN_CASE_SENSITIVE': True, # whether of not to get the user in case_sentive mode 208 | 'AUTHN_REQUESTS_SIGNED': True, # Require each authentication request to be signed 209 | 'LOGOUT_REQUESTS_SIGNED': True, # Require each logout request to be signed 210 | 'WANT_ASSERTIONS_SIGNED': True, # Require each assertion to be signed 211 | 'WANT_RESPONSE_SIGNED': True, # Require response to be signed 212 | 'FORCE_AUTHN': False, # Forces the user to re-authenticate with each authentication request 213 | 'ACCEPTED_TIME_DIFF': None, # Accepted time difference between your server and the Identity Provider 214 | 'ALLOWED_REDIRECT_HOSTS': ["https://myfrontendclient.com"], # Allowed hosts to redirect to using the ?next parameter 215 | 'TOKEN_REQUIRED': True, # Whether or not to require the token parameter in the SAML assertion 216 | 'DISABLE_EXCEPTION_HANDLER': True, # Whether the custom exception handler should be used 217 | } 218 | 219 | ``` 220 | 221 |
222 | 223 | 5. In your SAML2 SSO identity provider, set the Single-sign-on URL and Audience URI (SP Entity ID) to 224 | 225 | ## How to debug? 226 | 227 | To debug what's happening between the SAMLP Identity Provider and your Django application, you can use SAML-tracer for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/) or [Chrome](https://chrome.google.com/webstore/detail/saml-tracer/mpdajninpobndbfcldcmbpnnbhibjmch?hl=en). Using this tool, you can see the SAML requests and responses that are being sent back and forth. 228 | 229 | Also, you can enable the debug mode in the `settings.py` file by setting the `DEBUG` flag to `True` and enabling the `LOGGING` configuration. See above for configuration examples. 230 | 231 | *Note:* Don't forget to disable the debug mode in production and also remove the logging configuration if you don't want to see internal logs of pysaml2 library. 232 | 233 | ## Module Settings 234 | 235 | Some of the following settings are related to how this module operates. The rest are passed as options to the pysaml2 library. For more information on the pysaml2 library, see the [pysaml2 documentation](https://pysaml2.readthedocs.io/en/latest/howto/config.html), which contains examples of available settings. Also, note that all settings are not implemented in this module. 236 | 237 |
238 | Click to see the module settings 239 | 240 | | **Field name** | **Description** | **Data type(s)** | **Default value(s)** | **Example** | 241 | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | 242 | | **METADATA\_AUTO\_CONF\_URL** | Auto SAML2 metadata configuration URL | `str` | `None` | `https://ORG.okta.com/app/APP-ID/sso/saml/metadata` | 243 | | **METADATA\_LOCAL\_FILE\_PATH** | SAML2 metadata configuration file path | `str` | `None` | `/path/to/the/metadata.xml` | 244 | | **KEY_FILE** | SAML2 private key file path. Required for AUTHN\_REQUESTS\_SIGNED | `str` | `None` | `/path/to/the/key.pem` | 245 | | **CERT_FILE** | SAML2 public certificate file path | `str` | `None` | `/path/to/the/cert.pem` | 246 | | **ENCRYPTION_KEYPAIRS** | Required for handling encrypted assertions. Will be automatically set if both `KEY_FILE` and `CERT_FILE` are set. | `list` | Not set. | `[ { 'key_file': '[The key file path]', 'cert_file': '[The certificate file path]' } ]` | 247 | | **DEBUG** | Send debug information to a log file | `bool` | `False` | | 248 | | **LOGGING** | Logging configuration dictionary | `dict` | Not set. | | 249 | | **DEFAULT\_NEXT\_URL** | Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter `?next=` specificed in the login URL. | `str` | `admin:index` | `https://app.example.com/account/login` | 250 | | **CREATE\_USER** | Determines if a new Django user should be created for new users | `bool` | `True` | | 251 | | **CREATE\_GROUPS** | Determines if a new Django group should be created if the SAML2 Group does not exist | `bool` | `False` | | 252 | | **NEW\_USER\_PROFILE** | Default settings for newly created users | `dict` | `{'USER_GROUPS': [], 'ACTIVE_STATUS': True, 'STAFF_STATUS': False, 'SUPERUSER_STATUS': False}` | | 253 | | **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'}` | 254 | | **TOKEN\_REQUIRED** | Set this to `False` if you don't require the token parameter in the SAML assertion (in the attributes map) | `bool` | `True` | | 255 | | **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` | `{}` | | 256 | | **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` | 257 | | **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` | 258 | | **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` | 259 | | **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` | 260 | | **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` | 261 | | **TRIGGER.GET\_METADATA\_AUTO\_CONF\_URLS** | A hook function that returns a list of metadata Autoconf URLs as dictionary, where the key is `"url"` and the value is the corresponding metadata Autoconf URL. (e.g., `[{"url": METADATA_URL1}, {"url": METADATA_URL2}]`). 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` | 262 | | **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` | 263 | | **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` | 264 | | **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` | 265 | | **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` | 266 | | **TRIGGER.GET\_CUSTOM\_ASSERTION\_URL** | A hook function to get the assertion URL dynamically. Useful when you have dynamic routing, multi-tenant setup and etc. Overrides `ASSERTION_URL`. | `str` | `None` | `my_app.utils.get_custom_assertion_url` | 267 | | **TRIGGER.GET\_CUSTOM\_FRONTEND\_URL** | A hook function to get a dynamic `FRONTEND_URL` dynamically (see below for more details). Overrides `FRONTEND_URL`. Acceots one parameter: `relay_state`. | `str` | `None` | `my_app.utils.get_custom_frontend_url` | 268 | | **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 URL, for more customization use `GET_CUSTOM_ASSERTION_URL`. | `str` | `None` | `https://example.com` | 269 | | **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` | 270 | | **NAME\_ID\_FORMAT** | The optional value of the `'Format'` property of the `'NameIDPolicy'` element in authentication requests. | `str` | `None` | `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent` | 271 | | **USE\_JWT** | Set this to the boolean `True` if you are using Django with JWT authentication | `bool` | `False` | | 272 | | **JWT\_ALGORITHM** | JWT algorithm (str) to sign the message with: [supported algorithms](https://pyjwt.readthedocs.io/en/stable/algorithms.html). | `str` | `HS512` or `RS512` | | 273 | | **JWT\_SECRET** | JWT secret to sign the message if an HMAC is used with the SHA hash algorithm (`HS*`). | `str` | `None` | | 274 | | **JWT\_PRIVATE\_KEY** | Private key (str) to sign the message with. The algorithm should be set to `RSA256` or a more secure alternative. | `str` or `bytes` | `--- YOUR PRIVATE KEY ---` | | 275 | | **JWT\_PRIVATE\_KEY\_PASSPHRASE** | If your private key is encrypted, you must provide a passphrase for decryption. | `str` or `bytes` | `None` | | 276 | | **JWT\_PUBLIC\_KEY** | Public key to decode the signed JWT token. | `str` or `bytes` | `'--- YOUR PUBLIC KEY ---'` | | 277 | | **JWT\_EXP** | JWT expiry time in seconds | `int` | 60 | | 278 | | **FRONTEND\_URL** | If `USE_JWT` is `True`, you should set the URL to where your frontend is located (will default to `DEFAULT_NEXT_URL` if you fail to do so). Once the client is authenticated through the SAML SSO, your client is redirected to the `FRONTEND_URL` with the JWT token as `token` query parameter. Example: `https://app.example.com/?&token= Optional[List[Dict[str, str]]] | 293 | | **GET\_USER_ID\_FROM\_SAML\_RESPONSE** | Allows retrieving a user ID before GET_METADATA_AUTO_CONF_URLS gets triggered. Warning: SAML response still not verified. Use with caution! | get_user_id_from_saml_response(saml_response: str, user_id: Optional[str]) -> Optional[str] | 294 | 295 |
296 | 297 | ## JWT Signing Algorithm and Settings 298 | 299 | Both symmetric and asymmetric signing functions are [supported](https://pyjwt.readthedocs.io/en/stable/algorithms.html). If you want to use symmetric signing using a secret key, use either of the following algorithms plus a secret key: 300 | 301 | - HS256 302 | - HS384 303 | - HS512 304 | 305 | ```python 306 | { 307 | ... 308 | 'USE_JWT': True, 309 | 'JWT_ALGORITHM': 'HS256', 310 | 'JWT_SECRET': 'YOU.ULTRA.SECURE.SECRET', 311 | ... 312 | } 313 | ``` 314 | 315 | Otherwise if you want to use your PKI key-pair to sign JWT tokens, use either of the following algorithms and then set the following fields: 316 | 317 | - RS256 318 | - RS384 319 | - RS512 320 | - ES256 321 | - ES256K 322 | - ES384 323 | - ES521 324 | - ES512 325 | - PS256 326 | - PS384 327 | - PS512 328 | - EdDSA 329 | 330 | ```python 331 | { 332 | ... 333 | 'USE_JWT': True, 334 | 'JWT_ALGORITHM': 'RS256', 335 | 'JWT_PRIVATE_KEY': '--- YOUR PRIVATE KEY ---', 336 | 'JWT_PRIVATE_KEY_PASSPHRASE': 'your.passphrase', # Optional, if your private key is encrypted 337 | 'JWT_PUBLIC_KEY': '--- YOUR PUBLIC KEY ---', 338 | ... 339 | } 340 | ``` 341 | 342 | *Note:* If both PKI fields and `JWT_SECRET` are defined, the `JWT_ALGORITHM` decides which method to use for signing tokens. 343 | 344 | ### Custom token triggers 345 | 346 | This is an example of the functions that could be passed to the `TRIGGER.CUSTOM_CREATE_JWT` (it uses the [DRF Simple JWT library](https://github.com/jazzband/djangorestframework-simplejwt/blob/master/docs/index.rst)) and to `TRIGGER.CUSTOM_TOKEN_QUERY`: 347 | 348 | ``` python 349 | from rest_framework_simplejwt.tokens import RefreshToken 350 | 351 | 352 | def get_custom_jwt(user): 353 | """Create token for user and return it""" 354 | return RefreshToken.for_user(user) 355 | 356 | 357 | def get_custom_token_query(refresh): 358 | """Create url query with refresh and access token""" 359 | return "?%s%s%s%s%s" % ("refresh=", str(refresh), "&", "access=", str(refresh.access_token)) 360 | 361 | ``` 362 | 363 | ## Exception Handling 364 | 365 | This library implements an exception handler that returns an error response with a default error template. See the 366 | section below if you want to implement a custom error template. 367 | 368 | If you want to disable error handling, set `DISABLE_EXCEPTION_HANDLER` to `True`. In this case the library will raise 369 | `SAMLAuthError` when an error happens and you might need to implement an exception handler. This might come in handy if 370 | you are using the library for an API. 371 | 372 | ## Customize Error Messages and Templates 373 | 374 | The default permission `denied`, `error` and user `welcome` page can be overridden. 375 | 376 | To override these pages put a template named 'django\_saml2\_auth/error.html', 'django\_saml2\_auth/welcome.html' or 'django\_saml2\_auth/denied.html' in your project's template folder. 377 | > [!Note] 378 | > If you set `DISABLE_EXCEPTION_HANDLER` to `True`, the custom error pages will not be displayed. 379 | 380 | If a 'django\_saml2\_auth/welcome.html' template exists, that page will be shown to the user upon login instead of the user being redirected to the previous visited page. This welcome page can contain some first-visit notes and welcome words. The [Django user object](https://docs.djangoproject.com/en/1.9/ref/contrib/auth/#django.contrib.auth.models.User) is available within the template as the `user` template variable. 381 | 382 | To enable a logout page, add the following lines to `urls.py`, before any `urlpatterns`: 383 | 384 | ```python 385 | # The following line will replace the default user logout with the signout page (optional) 386 | url(r'^accounts/logout/$', django_saml2_auth.views.signout), 387 | 388 | # The following line will replace the default admin user logout with the signout page (optional) 389 | url(r'^admin/logout/$', django_saml2_auth.views.signout), 390 | ``` 391 | 392 | To override the built in signout page put a template named 'django\_saml2\_auth/signout.html' in your project's template folder. 393 | 394 | If your SAML2 identity provider uses user attribute names other than the defaults listed in the `settings.py` `ATTRIBUTES_MAP`, update them in `settings.py`. 395 | 396 | ## For Okta Users 397 | 398 | I created this plugin originally for Okta. The `METADATA_AUTO_CONF_URL` needed in `settings.py` can be found in the Okta Web UI by navigating to the SAML2 app's `Sign On` tab. In the `Settings` box, you should see: 399 | 400 | Identity Provider metadata is available if this application supports dynamic configuration. 401 | 402 | The `Identity Provider metadata` link is the `METADATA_AUTO_CONF_URL`. 403 | 404 | More information can be found in the [Okta Developer Documentation](https://developer.okta.com/docs/guides/saml-application-setup/overview/). 405 | 406 | ## Release Process 407 | 408 | I adopted a reasonably simple release process, which is almost automated, except for two actions that needed to be taken to start a release: 409 | 410 | 1. Tag the `main` branch locally with the the `vSEMVER`, e.g. `v3.9.0`, and push the tag. 411 | 2. After the tag is pushed, the release process will be triggered automatically. 412 | 3. The release process will: 413 | 1. run the linters and tests. 414 | 2. build the binary and source package. 415 | 3. publish the package to PyPI. 416 | 4. create a new release with auto-generated release notes on the tag. 417 | 5. upload the SBOM artifacts and build artifacts to the release. 418 | --------------------------------------------------------------------------------