├── 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 | [](https://pypi.org/project/grafana-django-saml2-auth/) [](https://github.com/grafana/django-saml2-auth/actions) [](https://coveralls.io/github/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  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 |
--------------------------------------------------------------------------------