Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ djangosaml2
![Django versions](https://img.shields.io/pypi/djversions/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:
Expand Down
23 changes: 18 additions & 5 deletions djangosaml2/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions djangosaml2/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
"""
Expand Down
8 changes: 4 additions & 4 deletions djangosaml2/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
114 changes: 83 additions & 31 deletions djangosaml2/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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]

Expand All @@ -734,23 +782,25 @@ def test_logout_service_local(self):
"Not a valid LogoutRequest",
)

# now simulate a logout response sent by the idp
expected_request = """<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="XXXXXXXXXXXXXXXXXXXXXX" Version="2.0" Destination="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php" Reason=""><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://sp.example.com/saml2/metadata/</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03</saml:NameID><samlp:SessionIndex>a0123456789abcdef0123456789abcdef</samlp:SessionIndex></samlp:LogoutRequest>"""

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 = """<?xml version='1.0' encoding='UTF-8'?>
<samlp:LogoutResponse xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://sp.example.com/saml2/ls/" ID="a140848e7ce2bce834d7264ecdde0151" InResponseTo="{}" IssueInstant="{}" Version="2.0"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status></samlp:LogoutResponse>""".format(
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()), [])

Expand All @@ -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 = """<?xml version='1.0' encoding='UTF-8'?>
<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="{}" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">{}</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>""".format(
<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="{}" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID NameQualifier="" SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">{}</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>""".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"]

Expand All @@ -799,30 +848,33 @@ 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()

response = self.client.get(reverse("saml2_logout"))
self.assertEqual(response.status_code, 302)

# now simulate a logout response sent by the idp
expected_request = """<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="XXXXXXXXXXXXXXXXXXXXXX" Version="2.0" Destination="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php" Reason=""><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://sp.example.com/saml2/metadata/</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03</saml:NameID><samlp:SessionIndex>a0123456789abcdef0123456789abcdef</samlp:SessionIndex></samlp:LogoutRequest>"""

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 = """<?xml version='1.0' encoding='UTF-8'?>
<samlp:LogoutResponse xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://sp.example.com/saml2/ls/" ID="a140848e7ce2bce834d7264ecdde0151" InResponseTo="{}" IssueInstant="{}" Version="2.0"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status></samlp:LogoutResponse>""".format(
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()), [])

Expand Down
Loading
Loading