diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 11f1112..ff7a044 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -15,24 +15,22 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
django-version: ["4.2", "5.0", "5.1", "5.2"]
- include:
- - python-version: "3.9"
- django-version: "4.2"
- - python-version: "3.13"
- django-version: "5.1"
exclude:
- # Python 3.9 is incompatible with Django v5+
- - django-version: 5.0
- python-version: 3.9
- - django-version: 5.1
- python-version: 3.9
- - django-version: 5.2
- python-version: 3.9
# Django 4.2 is incompatible with Python 3.13+
- django-version: 4.2
python-version: 3.13
+ - django-version: 4.2
+ python-version: 3.14
+ # Django 5.0 is incompatible with Python 3.13+
+ - django-version: 5.0
+ python-version: 3.13
+ - django-version: 5.0
+ python-version: 3.14
+ # Django 5.1 is incompatible with Python 3.14
+ - django-version: 5.1
+ python-version: 3.14
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
@@ -42,7 +40,6 @@ jobs:
allow-prereleases: true
- name: Install dependencies and testing utilities
run: |
- sudo apt-get update && sudo apt-get install xmlsec1
python -m pip install --upgrade pip
python -m pip install --upgrade tox rstcheck setuptools codecov
#- name: Readme check
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c3e6be3..9079916 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,7 +16,7 @@ repos:
rev: v3.19.0
hooks:
- id: pyupgrade
- args: [--py39-plus]
+ args: [--py310-plus]
- repo: https://github.com/myint/autoflake
rev: 'v2.3.1'
diff --git a/MANIFEST.in b/MANIFEST.in
index d7e9aff..dc71f64 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,10 @@
-include README.rst
+include README.md
include CHANGES
include COPYING
global-include *.html *.csr *.key *.pem *.xml
include djangosaml2/tests/attribute-maps/*.py
+prune env
+prune venv
+prune build
+prune dist
global-exclude *.pyc
diff --git a/README.md b/README.md
index 9af17f6..3db657c 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,9 @@ djangosaml2

-A Django application that builds a Fully Compliant SAML2 Service Provider on top of PySAML2 library.
- Djangosaml2 protects your project with a SAML2 SSO Authentication.
+A Django application that builds a SAML2 Service Provider on top of the
+[pygamlastan](https://github.com/kushaldas/pygamlastan) library.
+Djangosaml2 protects your project with SAML2 SSO authentication.
Features:
diff --git a/djangosaml2/cache.py b/djangosaml2/cache.py
index 546df55..a435c08 100644
--- a/djangosaml2/cache.py
+++ b/djangosaml2/cache.py
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from saml2.cache import Cache
+from pygamlastan.compat.saml2.cache import Cache
class DjangoSessionCacheAdapter(dict):
@@ -52,12 +52,25 @@ def __init__(self, django_session):
self._db = DjangoSessionCacheAdapter(django_session, "_outstanding_queries")
def outstanding_queries(self):
- return self._db._get_objects()
-
- def set(self, saml2_session_id, came_from):
- self._db[saml2_session_id] = came_from
+ """Return the legacy request-to-return-URL mapping expected by clients."""
+ return {
+ request_id: (value.get("came_from") if isinstance(value, dict) else value)
+ for request_id, value in self._db._get_objects().items()
+ }
+
+ def set(self, saml2_session_id, came_from, entity_id=None):
+ """Persist return and IdP correlation data for an AuthnRequest."""
+ self._db[saml2_session_id] = {
+ "came_from": came_from,
+ "entity_id": entity_id,
+ }
self._db.sync()
+ def entity_id(self, saml2_session_id):
+ """Return the IdP selected for an outstanding AuthnRequest."""
+ value = self._db._get_objects().get(saml2_session_id)
+ return value.get("entity_id") if isinstance(value, dict) else None
+
def delete(self, saml2_session_id):
if saml2_session_id in self._db:
del self._db[saml2_session_id]
diff --git a/djangosaml2/conf.py b/djangosaml2/conf.py
index e4cf140..580f0af 100644
--- a/djangosaml2/conf.py
+++ b/djangosaml2/conf.py
@@ -21,7 +21,7 @@
from django.http import HttpRequest
from django.utils.module_loading import import_string
-from saml2.config import SPConfig
+from pygamlastan.compat.saml2.config import SPConfig
from .utils import get_custom_setting
@@ -40,7 +40,7 @@ def get_config_loader(path: str) -> Callable:
def config_settings_loader(request: Optional[HttpRequest] = None) -> SPConfig:
- """Utility function to load the pysaml2 configuration.
+ """Utility function to load the pygamlastan compatibility configuration.
The configuration can be modified based on the request being passed.
This is the default config loader, which just loads the config from the settings.
"""
diff --git a/djangosaml2/overrides.py b/djangosaml2/overrides.py
index 8f6bc6b..44124dc 100644
--- a/djangosaml2/overrides.py
+++ b/djangosaml2/overrides.py
@@ -2,18 +2,18 @@
from django.conf import settings
-import saml2.client
+from pygamlastan.compat.saml2.client import Saml2Client as BaseSaml2Client
logger = logging.getLogger("djangosaml2")
-class Saml2Client(saml2.client.Saml2Client):
+class Saml2Client(BaseSaml2Client):
"""
Custom Saml2Client that adds a choice of preference for binding used with
SAML Logout Requests. The preferred binding can be configured via
SAML_LOGOUT_REQUEST_PREFERRED_BINDING settings variable.
- (Original Saml2Client always prefers SOAP, so it is always used if declared
- in remote metadata); but doesn't actually work and causes crashes.
+ The base client supports browser bindings only; this override lets a Django
+ setting choose which one is attempted first.
"""
def do_logout(self, *args, **kwargs):
diff --git a/djangosaml2/tests/__init__.py b/djangosaml2/tests/__init__.py
index 56dc7ae..27b12e8 100644
--- a/djangosaml2/tests/__init__.py
+++ b/djangosaml2/tests/__init__.py
@@ -15,9 +15,9 @@
# limitations under the License.
import base64
import datetime
-import re
import sys
from importlib import import_module
+from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs, urlparse
@@ -31,12 +31,15 @@
from django.contrib.auth import SESSION_KEY, get_user_model
from django.contrib.auth.models import AnonymousUser
-from saml2.config import SPConfig
-from saml2.s_utils import (
+from pygamlastan.bindings import redirect_encode
+from pygamlastan.compat.saml2.config import SPConfig
+from pygamlastan.compat.saml2.s_utils import (
UnknownSystemEntity,
decode_base64_and_inflate,
deflate_and_base64_encode,
)
+from pygamlastan.crypto import SamlSigner
+from pygamlastan.xml import parse_logout_request
from djangosaml2 import views
from djangosaml2.cache import OutstandingQueriesCache
@@ -146,6 +149,27 @@ def add_outstanding_query(self, session_id, came_from):
def b64_for_post(self, xml_text, encoding="utf-8"):
return base64.b64encode(xml_text.encode(encoding)).decode("ascii")
+ def signed_redirect(self, xml_text, is_request, relay_state=None):
+ """Return a Redirect-binding URL signed by the test IdP."""
+ key_path = Path(__file__).with_name("idpcert.key")
+ signer = SamlSigner.from_pem(key_path.read_bytes())
+ return redirect_encode(
+ xml_text.encode("utf-8"),
+ is_request,
+ "http://sp.example.com/saml2/ls/",
+ relay_state=relay_state,
+ signer=signer,
+ sig_alg=signer.signature_method_uri(),
+ )
+
+ def idp_certificate_der(self):
+ """Return the DER certificate matching the test IdP signing key."""
+ certificate = Path(__file__).with_name("idpcert.pem").read_text()
+ encoded = "".join(
+ line for line in certificate.splitlines() if not line.startswith("-----")
+ )
+ return base64.b64decode(encoded)
+
def test_get_idp_sso_supported_bindings_noargs(self):
settings.SAML_CONFIG = conf.create_conf(
sp_host="sp.example.com",
@@ -431,6 +455,28 @@ def test_login_several_idps(self):
decode_base64_and_inflate(saml_request).decode("utf-8"),
)
+ session_id = get_session_id_from_saml2(
+ decode_base64_and_inflate(saml_request).decode("utf-8")
+ )
+ selected_idp = "https://idp2.example.com/simplesaml/saml2/idp/metadata.php"
+ saml_response = auth_response(
+ session_id,
+ "multi-idp-user",
+ idp_entity_id=selected_idp,
+ )
+ response = self.client.post(
+ reverse("saml2_acs"),
+ {
+ "SAMLResponse": self.b64_for_post(saml_response),
+ "RelayState": "/",
+ },
+ )
+ self.assertRedirects(response, "/", fetch_redirect_response=False)
+ self.assertEqual(
+ User.objects.get(id=self.client.session[SESSION_KEY]).username,
+ "multi-idp-user",
+ )
+
@override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard")
def test_assertion_consumer_service(self):
# Get initial number of users
@@ -711,6 +757,7 @@ def test_logout_service_local(self):
sp_host="sp.example.com",
idp_hosts=["idp.example.com"],
metadata_file="remote_metadata_one_idp.xml",
+ sp_kwargs={"want_logout_response_signed": True},
)
self.do_login()
@@ -725,6 +772,7 @@ def test_logout_service_local(self):
params = parse_qs(url.query)
self.assertIn("SAMLRequest", params)
+ relay_state = params["RelayState"][0]
saml_request = params["SAMLRequest"][0]
@@ -734,10 +782,10 @@ def test_logout_service_local(self):
"Not a valid LogoutRequest",
)
- # now simulate a logout response sent by the idp
- expected_request = """http://sp.example.com/saml2/metadata/1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03a0123456789abcdef0123456789abcdef"""
-
- request_id = re.findall(r' ID="(.*?)" ', expected_request)[0]
+ # now simulate a signed, correlated logout response sent by the IdP
+ request_id = parse_logout_request(
+ decode_base64_and_inflate(saml_request).decode("utf-8")
+ ).id
instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
saml_response = """
@@ -745,12 +793,14 @@ def test_logout_service_local(self):
request_id, instant
)
- response = self.client.get(
- reverse("saml2_ls"),
- {
- "SAMLResponse": deflate_and_base64_encode(saml_response),
- },
- )
+ with mock.patch.object(
+ SPConfig,
+ "idp_signing_certs",
+ return_value=[self.idp_certificate_der()],
+ ):
+ response = self.client.get(
+ self.signed_redirect(saml_response, False, relay_state=relay_state)
+ )
self.assertContains(response, "Logged out", status_code=200)
self.assertListEqual(list(self.client.session.keys()), [])
@@ -763,19 +813,18 @@ def test_logout_service_global(self):
subject_id = self.do_login()
# now simulate a global logout process initiated by another SP
- subject_id = views._get_subject_id(self.saml_session)
instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
saml_request = """
-https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757""".format(
+https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757""".format(
instant, subject_id
)
- response = self.client.get(
- reverse("saml2_ls"),
- {
- "SAMLRequest": deflate_and_base64_encode(saml_request),
- },
- )
+ with mock.patch.object(
+ SPConfig,
+ "idp_signing_certs",
+ return_value=[self.idp_certificate_der()],
+ ):
+ response = self.client.get(self.signed_redirect(saml_request, True))
self.assertEqual(response.status_code, 302)
location = response["Location"]
@@ -799,6 +848,7 @@ def test_post_logout_redirection(self):
sp_host="sp.example.com",
idp_hosts=["idp.example.com"],
metadata_file="remote_metadata_one_idp.xml",
+ sp_kwargs={"want_logout_response_signed": True},
)
self.do_login()
@@ -806,10 +856,10 @@ def test_post_logout_redirection(self):
response = self.client.get(reverse("saml2_logout"))
self.assertEqual(response.status_code, 302)
- # now simulate a logout response sent by the idp
- expected_request = """http://sp.example.com/saml2/metadata/1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03a0123456789abcdef0123456789abcdef"""
-
- request_id = re.findall(r' ID="(.*?)" ', expected_request)[0]
+ # now simulate a signed, correlated logout response sent by the IdP
+ relay_state = parse_qs(urlparse(response.url).query)["RelayState"][0]
+ logout_request = saml2_from_httpredirect_request(response.url)
+ request_id = parse_logout_request(logout_request.decode("utf-8")).id
instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
saml_response = """
@@ -817,12 +867,14 @@ def test_post_logout_redirection(self):
request_id, instant
)
- response = self.client.get(
- reverse("saml2_ls"),
- {
- "SAMLResponse": deflate_and_base64_encode(saml_response),
- },
- )
+ with mock.patch.object(
+ SPConfig,
+ "idp_signing_certs",
+ return_value=[self.idp_certificate_der()],
+ ):
+ response = self.client.get(
+ self.signed_redirect(saml_response, False, relay_state=relay_state)
+ )
self.assertRedirects(response, "/dashboard/")
self.assertListEqual(list(self.client.session.keys()), [])
diff --git a/djangosaml2/tests/auth_response.py b/djangosaml2/tests/auth_response.py
index b25a6ce..20c4227 100644
--- a/djangosaml2/tests/auth_response.py
+++ b/djangosaml2/tests/auth_response.py
@@ -14,6 +14,9 @@
# limitations under the License.
import datetime
+import itertools
+
+_id_counter = itertools.count(1)
def auth_response(
@@ -23,6 +26,7 @@ def auth_response(
acs_url="http://sp.example.com/saml2/acs/",
metadata_url="http://sp.example.com/saml2/metadata/",
attribute_statements=None,
+ idp_entity_id="https://idp.example.com/simplesaml/saml2/idp/metadata.php",
):
"""Generates a fresh signed authentication response
@@ -32,17 +36,19 @@ def auth_response(
that session.
uid: Unique identifier for a User (will be present as an attribute in
the answer). Ignored when attribute_statements is not ``None``.
- audience: SP entityid (used when PySAML validates the response
+ audience: SP entityid (used when pygamlastan validates the response
audience).
acs_url: URL where the response has been posted back.
metadata_url: URL where the SP metadata can be queried.
attribute_statements: An alternative XML AttributeStatement to use in
lieu of the default (uid). The uid argument is ignored when
attribute_statements is not ``None``.
+ idp_entity_id: Entity ID used as the Response and Assertion issuer.
"""
timestamp = datetime.datetime.now() - datetime.timedelta(seconds=10)
tomorrow = datetime.datetime.now() + datetime.timedelta(days=1)
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
+ message_number = next(_id_counter)
if attribute_statements is None:
attribute_statements = (
@@ -57,16 +63,16 @@ def auth_response(
saml_response_tpl = (
""
- ''
+ ''
''
- "https://idp.example.com/simplesaml/saml2/idp/metadata.php"
+ "%(idp_entity_id)s"
""
""
''
""
- ''
+ ''
''
- "https://idp.example.com/simplesaml/saml2/idp/metadata.php"
+ "%(idp_entity_id)s"
""
""
''
@@ -103,4 +109,6 @@ def auth_response(
"timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
"tomorrow": tomorrow.strftime("%Y-%m-%dT%H:%M:%SZ"),
"yesterday": yesterday.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "message_number": message_number,
+ "idp_entity_id": idp_entity_id,
}
diff --git a/djangosaml2/tests/conf.py b/djangosaml2/tests/conf.py
index d02fe17..9d53b57 100644
--- a/djangosaml2/tests/conf.py
+++ b/djangosaml2/tests/conf.py
@@ -15,7 +15,7 @@
import os.path
-import saml2
+from pygamlastan.compat import saml2
def create_conf(
@@ -28,25 +28,14 @@ def create_conf(
if idp_hosts is None:
idp_hosts = ["idp.example.com"]
- try:
- from saml2.sigver import get_xmlsec_binary
- except ImportError:
- get_xmlsec_binary = None
-
- if get_xmlsec_binary:
- xmlsec_path = get_xmlsec_binary(["/opt/local/bin"])
- else:
- xmlsec_path = "/usr/bin/xmlsec1"
-
BASEDIR = os.path.dirname(os.path.abspath(__file__))
config = {
- "xmlsec_binary": xmlsec_path,
"entityid": "http://%s/saml2/metadata/" % sp_host,
"attribute_map_dir": os.path.join(BASEDIR, "attribute-maps"),
"service": {
"sp": {
"name": "Test SP",
- "name_id_format": saml2.saml.NAMEID_FORMAT_PERSISTENT,
+ "name_id_policy_format": saml2.saml.NAMEID_FORMAT_PERSISTENT,
"endpoints": {
"assertion_consumer_service": [
("http://%s/saml2/acs/" % sp_host, saml2.BINDING_HTTP_POST),
diff --git a/djangosaml2/utils.py b/djangosaml2/utils.py
index 10d022d..db1bca1 100644
--- a/djangosaml2/utils.py
+++ b/djangosaml2/utils.py
@@ -17,8 +17,8 @@
import urllib
import zlib
from functools import lru_cache, wraps
+from importlib.metadata import PackageNotFoundError, version
from typing import Optional
-from importlib.metadata import version, PackageNotFoundError
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@@ -28,9 +28,9 @@
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.module_loading import import_string
-from saml2.config import SPConfig
-from saml2.mdstore import MetaDataMDX
-from saml2.s_utils import UnknownSystemEntity
+from pygamlastan.compat.saml2.config import SPConfig
+from pygamlastan.compat.saml2.mdstore import MetaDataMDX
+from pygamlastan.compat.saml2.s_utils import UnknownSystemEntity
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ def get_idp_sso_supported_bindings(
idp_entity_id: Optional[str] = None, config: Optional[SPConfig] = None
) -> list:
"""Returns the list of bindings supported by an IDP
- This is not clear in the pysaml2 code, so wrapping it in a util"""
+ The compatibility metadata API returns this shape, so wrap it in a util."""
if config is None:
# avoid circular import
from .conf import get_config
@@ -87,7 +87,7 @@ def get_idp_sso_supported_bindings(
def get_location(http_info):
- """Extract the redirect URL from a pysaml2 http_info object"""
+ """Extract the redirect URL from a SAML binding response mapping."""
try:
headers = dict(http_info["headers"])
return headers["Location"]
@@ -151,8 +151,9 @@ def get_session_id_from_saml2(saml2_xml):
def get_subject_id_from_saml2(saml2_xml):
+ """Return the text value of the first NameID in a SAML message."""
saml2_xml = saml2_xml if isinstance(saml2_xml, str) else saml2_xml.decode()
- re.findall('">([a-z0-9]+)', saml2_xml)[0]
+ return re.findall('">([a-z0-9]+)', saml2_xml)[0]
def add_param_in_url(url: str, param_key: str, param_value: str):
@@ -240,7 +241,6 @@ def _django_csp_update_decorator():
"""Returns a view CSP decorator if django-csp is available, otherwise None."""
try:
from csp.decorators import csp_update
- import csp
except ModuleNotFoundError:
# If csp is not installed, do not update fields as Content-Security-Policy
# is not used
@@ -258,8 +258,8 @@ def _django_csp_update_decorator():
# form-action https: to send data to IdPs
# Check django-csp version to determine the appropriate format
try:
- csp_version = version('django-csp')
- major_version = int(csp_version.split('.')[0])
+ csp_version = version("django-csp")
+ major_version = int(csp_version.split(".")[0])
# Version detection successful
if major_version >= 4:
@@ -267,7 +267,13 @@ def _django_csp_update_decorator():
return csp_update(config={"form-action": ["https:"]})
# django-csp < 4.0 uses kwargs format
return csp_update(FORM_ACTION=["https:"])
- except (PackageNotFoundError, ValueError, RuntimeError, AttributeError, IndexError):
+ except (
+ PackageNotFoundError,
+ ValueError,
+ RuntimeError,
+ AttributeError,
+ IndexError,
+ ):
# Version detection failed, we need to try both formats
# Try v4.0+ style first because:
# 1. It has better error handling with clear messages
diff --git a/djangosaml2/views.py b/djangosaml2/views.py
index 0d1180a..233dbf6 100644
--- a/djangosaml2/views.py
+++ b/djangosaml2/views.py
@@ -42,13 +42,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.shortcuts import get_current_site
-import saml2
-from saml2.client_base import LogoutError
-from saml2.config import SPConfig
-from saml2.ident import code, decode
-from saml2.mdstore import SourceNotFound
-from saml2.metadata import entity_descriptor
-from saml2.response import (
+from pygamlastan import SamlBindingError
+from pygamlastan import bindings as saml_bindings
+from pygamlastan import xml as saml_xml
+from pygamlastan.compat import saml2
+from pygamlastan.compat.saml2.client_base import LogoutError
+from pygamlastan.compat.saml2.config import SPConfig
+from pygamlastan.compat.saml2.ident import code, decode
+from pygamlastan.compat.saml2.mdstore import SourceNotFound
+from pygamlastan.compat.saml2.metadata import entity_descriptor
+from pygamlastan.compat.saml2.response import (
RequestVersionTooLow,
SignatureError,
StatusAuthnFailed,
@@ -57,11 +60,11 @@
StatusRequestDenied,
UnsolicitedResponse,
)
-from saml2.s_utils import UnsupportedBinding
-from saml2.saml import SCM_BEARER
-from saml2.samlp import AuthnRequest, IDPEntry, IDPList, Scoping
-from saml2.sigver import MissingKey
-from saml2.validate import ResponseLifetimeExceed, ToEarly
+from pygamlastan.compat.saml2.s_utils import UnsupportedBinding
+from pygamlastan.compat.saml2.saml import SCM_BEARER
+from pygamlastan.compat.saml2.samlp import AuthnRequest, IDPEntry, IDPList, Scoping
+from pygamlastan.compat.saml2.sigver import MissingKey
+from pygamlastan.compat.saml2.validate import ResponseLifetimeExceed, ToEarly
from .cache import IdentityCache, OutstandingQueriesCache, StateCache
from .conf import get_config
@@ -78,6 +81,29 @@
validate_referral_url,
)
+
+def _authn_response_request_id(encoded_response):
+ """Return the request ID correlated by an HTTP-POST AuthnResponse."""
+ decoded = saml_bindings.post_decode([("SAMLResponse", encoded_response)])
+ return saml_xml.parse_response(decoded.saml_text).in_response_to
+
+
+def _redirect_signature_kwargs(request, binding):
+ """Preserve detached Redirect signature inputs from Django's raw query."""
+ if binding != saml2.BINDING_HTTP_REDIRECT:
+ return {}
+ decoded = saml_bindings.redirect_decode(request.META.get("QUERY_STRING", ""))
+ if decoded.sig_alg is None and decoded.signature is None:
+ return {}
+ if not decoded.sig_alg or decoded.signature is None or not decoded.signature_input:
+ raise SamlBindingError("incomplete HTTP-Redirect signature parameters")
+ return {
+ "sig_alg": decoded.sig_alg,
+ "signature": base64.b64encode(decoded.signature).decode("ascii"),
+ "signed_query": decoded.signature_input,
+ }
+
+
logger = logging.getLogger("djangosaml2")
@@ -140,14 +166,14 @@ class LoginView(SPConfigMixin, View):
"""SAML Authorization Request initiator.
This view initiates the SAML2 Authorization handshake
- using the pysaml2 library to create the AuthnRequest.
+ using pygamlastan to create the AuthnRequest.
post_binding_form_template is a path to a template containing HTML form with
hidden input elements, used to send the SAML message data when HTTP POST
binding is being used. You can customize this template to include custom
branding and/or text explaining the automatic redirection process. Please
see the example template in templates/djangosaml2/example_post_binding_form.html
- If set to None or nonexistent template, default form from the saml2 library
+ If set to None or nonexistent template, the compatibility layer's default form
will be rendered.
"""
@@ -393,7 +419,7 @@ def get(self, request, *args, **kwargs):
)
if not http_response:
- # use the html provided by pysaml2 if no template was specified or it doesn't exist
+ # Use the generated HTML if no template was specified or it does not exist.
try:
session_id, result = client.prepare_for_authenticate(
entityid=selected_idp,
@@ -412,7 +438,7 @@ def get(self, request, *args, **kwargs):
# success, so save the session ID and return our response
oq_cache = OutstandingQueriesCache(request.saml_session)
- oq_cache.set(session_id, next_path)
+ oq_cache.set(session_id, next_path, selected_idp)
logger.debug(
f'Saving the session_id "{oq_cache.__dict__}" '
"in the OutstandingQueries cache",
@@ -425,7 +451,7 @@ def get(self, request, *args, **kwargs):
@method_decorator(csrf_exempt, name="dispatch")
class AssertionConsumerServiceView(SPConfigMixin, View):
- """The IdP will send its response to this view, which will process it using pysaml2 and
+ """The IdP will send its response to this view, which pygamlastan processes before
log the user in using whatever SAML authentication backend has been enabled in
settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose,
though some implementations may instead register their own subclasses of Saml2Backend.
@@ -481,10 +507,12 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None):
_exception = None
try:
+ request_id = _authn_response_request_id(request.POST["SAMLResponse"])
response = client.parse_authn_request_response(
request.POST["SAMLResponse"],
saml2.BINDING_HTTP_POST,
outstanding_queries,
+ expected_idp=oq_cache.entity_id(request_id),
)
except (StatusError, ToEarly) as e:
_exception = e
@@ -690,7 +718,7 @@ class LogoutInitView(LoginRequiredMixin, SPConfigMixin, View):
"""SAML Logout Request initiator
This view initiates the SAML2 Logout request
- using the pysaml2 library to create the LogoutRequest.
+ using pygamlastan to create the LogoutRequest.
"""
def get(self, request, *args, **kwargs):
@@ -769,7 +797,7 @@ class LogoutView(SPConfigMixin, View):
"""SAML Logout Response endpoint
The IdP will send the logout response to this view,
- which will process it with pysaml2 help and log the user
+ which will process it with pygamlastan and log the user
out.
Note that the IdP can request a logout even when
we didn't initiate the process as a single logout
@@ -796,10 +824,14 @@ def do_logout_service(self, request, data, binding, *args, **kwargs):
if "SAMLResponse" in data: # we started the logout
logger.debug("Receiving a logout response from the IdP")
try:
+ signature_kwargs = _redirect_signature_kwargs(request, binding)
response = client.parse_logout_request_response(
- data["SAMLResponse"], binding
+ data["SAMLResponse"],
+ binding,
+ relay_state=data.get("RelayState"),
+ **signature_kwargs,
)
- except StatusError as e:
+ except (SamlBindingError, StatusError) as e:
response = None
logger.warning(
f"Error logging out from remote provider: {e}", exc_info=True
@@ -819,12 +851,21 @@ def do_logout_service(self, request, data, binding, *args, **kwargs):
auth.logout(request)
return render(request, self.logout_error_template, status=403)
- http_info = client.handle_logout_request(
- data["SAMLRequest"],
- subject_id,
- binding,
- relay_state=data.get("RelayState", ""),
- )
+ try:
+ signature_kwargs = _redirect_signature_kwargs(request, binding)
+ http_info = client.handle_logout_request(
+ data["SAMLRequest"],
+ subject_id,
+ binding,
+ relay_state=data.get("RelayState", ""),
+ **signature_kwargs,
+ )
+ except (SamlBindingError, ValueError) as e:
+ logger.warning(
+ f"Invalid logout request from remote provider: {e}",
+ exc_info=True,
+ )
+ return render(request, self.logout_error_template, status=403)
state.sync()
auth.logout(request)
if (
@@ -893,7 +934,7 @@ def get(self, request, *args, **kwargs):
def get_namespace_prefixes():
- from saml2 import md, saml, samlp, xmldsig, xmlenc
+ from pygamlastan.compat.saml2 import md, saml, samlp, xmldsig, xmlenc
return {
"saml": saml.NAMESPACE,
diff --git a/docs/source/contents/faq.md b/docs/source/contents/faq.md
index 44f578e..8a5e07b 100644
--- a/docs/source/contents/faq.md
+++ b/docs/source/contents/faq.md
@@ -25,12 +25,12 @@ case of a problem, much harder to debug.
**Why not call this package django-saml as many other Django applications?**
Following that pattern then I should import the application with
-import saml but unfortunately that module name is already used in pysaml2.
+``import saml``, but that generic module name is already widely used.
-**saml2.response.UnsolicitedResponse: Unsolicited response**
+**pygamlastan.compat.saml2.response.UnsolicitedResponse: Unsolicited response**
If you are experiencing issues with unsolicited requests this is due to the fact that
cookies not being sent when using the HTTP-POST binding. You have to configure samesite
djangosaml2 middleware (see setup documentation) and also consider upgrading
to Django 3.1 or higher.
-If you can't do that, configure "allow_unsolicited" to True in pySAML2 configuration.
+If you can't do that, configure ``allow_unsolicited`` to ``True`` in the SAML configuration.
diff --git a/docs/source/contents/miscellanea.rst b/docs/source/contents/miscellanea.rst
index a7ce531..0ba9753 100644
--- a/docs/source/contents/miscellanea.rst
+++ b/docs/source/contents/miscellanea.rst
@@ -11,7 +11,7 @@ But it need to be replaced by this one::
'AttributeNameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri'
Otherwise the Assertions sent from the IdP to the SP will have a wrong
-Attribute Name Format and pysaml2 will be confused.
+Attribute Name Format and the SAML parser will reject it.
Furthermore if you have a AttributeLimit filter in your SimpleSAMLphp
configuration you will need to enable another attribute filter just
@@ -33,6 +33,8 @@ Okta settings to configure on your Idp's SAML app advanced settings::
Okta sample configuration for setting up an Okta SSO with Django::
+ from pygamlastan.compat import saml2
+
'service': {
# we are just a lonely SP
'sp': {
@@ -41,7 +43,7 @@ Okta sample configuration for setting up an Okta SSO with Django::
'want_assertions_signed': True, # assertion signing (default=True)
'want_response_signed': True,
"want_assertions_or_response_signed": True, # is response signing required
- 'name_id_format': NAMEID_FORMAT_UNSPECIFIED,
+ 'name_id_policy_format': saml2.saml.NAMEID_FORMAT_UNSPECIFIED,
# Must for signed logout requests
"logout_requests_signed": True,
diff --git a/docs/source/contents/setup.rst b/docs/source/contents/setup.rst
index fed507a..e3b7028 100644
--- a/docs/source/contents/setup.rst
+++ b/docs/source/contents/setup.rst
@@ -4,17 +4,10 @@ Setup
Prepare Environment and Install Requirements
============================================
-PySAML2 uses xmlsec1_ binary to sign SAML assertions so you need to install
-it either through your operating system package or by compiling the source
-code. It doesn't matter where the final executable is installed because
-you will need to set the full path to it in the configuration stage.
+You can install the djangosaml2 package using pip. This also installs
+pygamlastan and its dependencies automatically::
-.. _xmlsec1: http://www.aleksey.com/xmlsec/
-
-Now you can install the djangosaml2 package using pip. This
-will also install PySAML2 and its dependencies automatically::
-
- apt install python3-pip xmlsec1 python3-dev libssl-dev libsasl2-dev
+ apt install python3-pip python3-dev
pip3 install virtualenv
mkdir djangosaml2_project && cd "$_"
virtualenv -ppython3 env
@@ -31,8 +24,8 @@ Django project:
1. **settings.py** as you may already know, it is the main Django
configuration file.
2. **urls.py** is the file where you will include djangosaml2 urls.
-3. **pysaml2** specific files such as an attribute map directory and a
- certificates involved in SAML2 signature and encryption operations.
+3. SAML metadata and the certificates involved in signature and encryption
+ operations.
The first thing you need to do is add ``djangosaml2`` to the list of
installed apps::
@@ -76,7 +69,8 @@ have the "Secure" attribute, which is required in order to use "SameSite=None",
djangosaml2 will by default attempt to set the ``SameSite`` attribute of the SAML session cookie to ``None`` so that it can be
used in cross-site requests, but this is only possible with Django 3.1 or higher. If you are experiencing issues with
unsolicited requests or cookies not being sent (particularly when using the HTTP-POST binding), consider upgrading
- to Django 3.1 or higher. If you can't do that, configure "allow_unsolicited" to True in pySAML2 configuration.
+ to Django 3.1 or higher. If you can't do that, configure ``allow_unsolicited``
+ to ``True`` in the SAML configuration.
Authentication backend
======================
@@ -163,7 +157,7 @@ Use the following setting to choose your preferred binding for SP initiated sso
For example::
- import saml2
+ from pygamlastan.compat import saml2
SAML_DEFAULT_BINDING = saml2.BINDING_HTTP_POST
Preferred Logout binding
@@ -175,7 +169,7 @@ Use the following setting to choose your preferred binding for SP initiated logo
For example::
- import saml2
+ from pygamlastan.compat import saml2
SAML_LOGOUT_REQUEST_PREFERRED_BINDING = saml2.BINDING_HTTP_POST
Ignore Logout errors
@@ -250,14 +244,14 @@ We can define the authentication context in settings.SAML_CONFIG['service']['sp'
Custom and dynamic configuration loading
========================================
-By default, djangosaml2 reads the pysaml2 configuration options from the
+By default, djangosaml2 reads its pygamlastan compatibility configuration from the
SAML_CONFIG setting but sometimes you want to read this information from
another place, like a file or a database. Sometimes you even want this
configuration to be different depending on the request.
Starting from djangosaml2 0.5.0 you can define your own configuration
loader which is a callable that accepts a request parameter and returns
-a saml2.config.SPConfig object. In order to do so you set the following
+a ``pygamlastan.compat.saml2.config.SPConfig`` object. In order to do so you set the following
setting::
SAML_CONFIG_LOADER = 'python.path.to.your.callable'
@@ -484,23 +478,17 @@ Changes in the urls.py file.
# more url definitions
)
-PySAML2 specific files and configuration
-----------------------------------------
-Once you have finished configuring your Django project you have to
-start configuring PySAML2, please consult its `official documentation `_ before start.
-If you use just that library you have to put your configuration options in a file and initialize PySAML2 with
-the path to that file. In djangosaml2 you just put the same information in the Django
-settings.py file under the SAML_CONFIG option. We will see a typical configuration for protecting a Django project::
+pygamlastan files and configuration
+-----------------------------------
+The pygamlastan compatibility layer reads the established djangosaml2
+configuration shape. Put the configuration in Django's ``settings.py`` under
+the ``SAML_CONFIG`` option. A typical service-provider configuration is::
from os import path
- import saml2
- import saml2.saml
+ from pygamlastan.compat import saml2
BASEDIR = path.dirname(path.abspath(__file__))
SAML_CONFIG = {
- # full path to the xmlsec1 binary programm
- 'xmlsec_binary': '/usr/bin/xmlsec1',
-
# your entity id, usually your subdomain plus the url to the metadata view
'entityid': 'http://localhost:8000/saml2/metadata/',
@@ -516,7 +504,7 @@ settings.py file under the SAML_CONFIG option. We will see a typical configurati
# we are just a lonely SP
'sp' : {
'name': 'Federated Django sample SP',
- 'name_id_format': saml2.saml.NAMEID_FORMAT_TRANSIENT,
+ 'name_id_policy_format': saml2.saml.NAMEID_FORMAT_TRANSIENT,
# For Okta add signed logout requests. Enable this:
# "logout_requests_signed": True,
@@ -592,14 +580,9 @@ settings.py file under the SAML_CONFIG option. We will see a typical configurati
},
},
- # where the remote metadata is stored, local, remote or mdq server.
- # One metadatastore or many ...
+ # local files containing trusted IdP metadata
'metadata': {
'local': [path.join(BASEDIR, 'remote_metadata.xml')],
- 'remote': [{"url": "https://idp.testunical.it/idp/shibboleth"},],
- 'mdq': [{"url": "https://ds.testunical.it",
- "cert": "certficates/others/ds.testunical.it.cert",
- }]
},
# set to 1 to output debugging information
@@ -609,12 +592,6 @@ settings.py file under the SAML_CONFIG option. We will see a typical configurati
'key_file': path.join(BASEDIR, 'private.key'), # private part
'cert_file': path.join(BASEDIR, 'public.pem'), # public part
- # Encryption
- 'encryption_keypairs': [{
- 'key_file': path.join(BASEDIR, 'private.key'), # private part
- 'cert_file': path.join(BASEDIR, 'public.pem'), # public part
- }],
-
# own metadata settings
'contact_person': [
{'given_name': 'Lorenzo',
@@ -638,19 +615,13 @@ settings.py file under the SAML_CONFIG option. We will see a typical configurati
.. note::
- Please check the `PySAML2 documentation`_ for more information about
- these and other configuration options.
-
-.. _`PySAML2 documentation`: http://pysaml2.readthedocs.io/en/latest/
+ See the `pygamlastan project `_
+ for information about its native SAML implementation and compatibility layer.
There are several external files and directories you have to create according
to this configuration.
-The xmlsec1 binary was mentioned in the installation section. Here, in the
-configuration part you just need to put the full path to xmlsec1 so PySAML2
-can call it as it needs.
-
Signed Logout Request
=====================
@@ -663,7 +634,7 @@ Attribute Map
The ``attribute_map_dir`` points to a directory with attribute mappings that
are used to translate user attribute names from several standards. It's usually
-safe to just copy the default PySAML2 attribute maps that you can find in the
+safe to copy the bundled attribute maps that you can find in the
``tests/attributemaps`` directory of the source distribution.
Metadata
@@ -697,9 +668,9 @@ SAML2 certificate creation example::
openssl req -nodes -new -x509 -newkey rsa:2048 -days 3650 -keyout private.key -out public.cert
-PySAML2 certificates are files, in the form of strings that contains a filesystem path.
+Certificate settings are strings containing filesystem paths.
What about configuring the certificates in a different way, in case we are using a container based deploy?
- You could supply the cert & key as environment variables (base64 encoded) then create the files when the container starts, either in an entry point shell script or in your settings.py file.
-- Using `Python Tempfile `_ In the settings create two temp files, then write the content configured in environment variables in them, then use tmpfile.name as key/cert values in pysaml2 configuration.
+- Using `Python Tempfile `_ In the settings create two temp files, then write the content configured in environment variables in them, then use tmpfile.name as key/cert values in the SAML configuration.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 4868dcd..1dd1492 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -2,7 +2,7 @@ Welcome to Djangosaml2's Documentation
======================================
A Django application that builds a fully compliant SAML2 Service Provider on top of
-`PySAML2 `_ library.
+the `pygamlastan `_ library.
Djangosaml2 protects your project with a SAML2 SSO Authentication, supporting features like
**HTTP-REDIRECT** and **HTTP-POST SSO Binding**, **Single logout**,
**Discovery Service**, **Wayf page** with customizable html template,
@@ -46,4 +46,4 @@ under the `Apache 2.0 `_.
:maxdepth: 2
:caption: Security considerations
- contents/security.md
\ No newline at end of file
+ contents/security.md
diff --git a/pyproject.toml b/pyproject.toml
index 28b8614..e02e1ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,14 +1,14 @@
[tool.black]
force-exclude = '''/(migrations)/'''
-target-version = ["py39"]
+target-version = ["py310"]
[tool.isort]
src_paths = ["djangosaml2", "tests"]
profile = "black"
known_django = ["django"]
known_contrib = ["django.contrib"]
-known_saml2 = ["saml2"]
+known_pygamlastan = ["pygamlastan"]
known_first_party = ["djangosaml2"]
known_tests = ["tests"]
-sections = ["FUTURE", "STDLIB", "DJANGO", "CONTRIB", "THIRDPARTY", "SAML2", "FIRSTPARTY", "TESTS", "LOCALFOLDER"]
+sections = ["FUTURE", "STDLIB", "DJANGO", "CONTRIB", "THIRDPARTY", "PYGAMLASTAN", "FIRSTPARTY", "TESTS", "LOCALFOLDER"]
skip_glob = ["**/migrations/*.py"]
diff --git a/setup.cfg b/setup.cfg
index 4ed9ab4..c6ff14c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,3 @@
-[bdist_wheel]
-universal = 1
-
[flake8]
# E203 ignore
# https://github.com/PyCQA/pycodestyle/issues/373
diff --git a/setup.py b/setup.py
index f02a5c8..15efa60 100644
--- a/setup.py
+++ b/setup.py
@@ -28,7 +28,7 @@ def read(*rnames):
setup(
name="djangosaml2",
version="1.12.0",
- description="pysaml2 integration for Django",
+ description="pygamlastan SAML 2.0 integration for Django",
long_description=read("README.md"),
long_description_content_type="text/markdown",
classifiers=[
@@ -43,17 +43,17 @@ def read(*rnames):
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: WSGI",
"Topic :: Security",
"Topic :: Software Development :: Libraries :: Application Frameworks",
],
- keywords="django,pysaml2,sso,saml2,federated authentication,authentication",
+ keywords="django,pygamlastan,sso,saml2,federated authentication,authentication",
author="Yaco Sistemas and independent contributors",
author_email="lorenzo.gil.sanchez@gmail.com",
maintainer="Giuseppe De Marco",
@@ -63,6 +63,6 @@ def read(*rnames):
packages=find_packages(exclude=["tests", "tests.*"]),
include_package_data=True,
zip_safe=False,
- install_requires=["defusedxml>=0.4.1", "Django>=4.2", "pysaml2>=6.5.1"],
- python_requires=">=3.9",
+ install_requires=["defusedxml>=0.4.1", "Django>=4.2", "pygamlastan>=0.6.0"],
+ python_requires=">=3.10",
)
diff --git a/tox.ini b/tox.ini
index 10c2633..a0943e7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
[tox]
envlist =
- py{3.9,3.10,3.11,3.12,3.13}-django{4.2,5.0,5.1,5.2}
+ py{3.10,3.11,3.12,3.13,3.14}-django{4.2,5.0,5.1,5.2}
[testenv]
commands =