@@ -478,12 +478,12 @@
Sezione Link Utili
-
-
+
+
-
-
+
+
diff --git a/generate_x509_key_and_certificate.sh b/generate_x509_key_and_certificate.sh
deleted file mode 100755
index 12da27d..0000000
--- a/generate_x509_key_and_certificate.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-CERTIFICATES_DIR="`pwd`/example/certificates/"
-OPENSSL_DOCKER_IMAGE="frapsoft/openssl"
-
-OPENSSL_CMD="docker run --rm -v $CERTIFICATES_DIR:/export/ $OPENSSL_DOCKER_IMAGE"
-
-PRIVATE_KEY_PEM_FILE="private.key"
-CERTIFICATE_PEM_FILE="public.cert"
-
-SUBJ_C="IT"
-SUBJ_ST="State"
-SUBJ_L="City"
-SUBJ_O="Acme Inc."
-SUBJ_OU="IT Department"
-SUBJ_CN="spid-django.selfsigned.example"
-
-DAYS="730"
-
-set -e
-
-ls $CERTIFICATES_DIR > /dev/null
-
-$OPENSSL_CMD req \
- -nodes \
- -new \
- -x509 \
- -sha256 \
- -days $DAYS \
- -newkey rsa:2048 \
- -subj "/C=$SUBJ_C/ST=$SUBJ_ST/L=$SUBJ_L/O=$SUBJ_O/OU=$SUBJ_OU/CN=$SUBJ_CN" \
- -keyout "/export/$PRIVATE_KEY_PEM_FILE" \
- -out "/export/$CERTIFICATE_PEM_FILE"
diff --git a/pycodestyle.cfg b/pycodestyle.cfg
new file mode 100644
index 0000000..6ec89fd
--- /dev/null
+++ b/pycodestyle.cfg
@@ -0,0 +1,8 @@
+[pycodestyle]
+
+format = pylint
+
+# List of error codes:
+# https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes
+
+ignore = E501, W503
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 83a8f43..1618f05 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,3 +5,4 @@ black
flake8
isort
bandit
+-e .
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 2419ba1..155c183 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-django>3.0<4.0
+django>3.0,<4.0
# hint before: pip install -U setuptools
pysaml2 @ git+https://github.com/peppelinux/pysaml2.git@pplnx-v6.5.1#pysaml2
@@ -6,3 +6,6 @@ cffi
# django saml2 SP
djangosaml2>=1.0.0
+
+# For command update_idps.py
+requests
\ No newline at end of file
diff --git a/runtests.py b/runtests.py
new file mode 100644
index 0000000..190daf3
--- /dev/null
+++ b/runtests.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+"""
+Running tests for djangosaml2_spid application.
+"""
+import os
+import sys
+
+import django
+from django.conf import settings
+from django.test.utils import get_runner
+
+if __name__ == "__main__":
+ os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings'
+ django.setup()
+ TestRunner = get_runner(settings)
+ test_runner = TestRunner()
+ failures = test_runner.run_tests(['tests', 'src'])
+ sys.exit(bool(failures))
+
diff --git a/setup.py b/setup.py
index e8f716e..2d60886 100644
--- a/setup.py
+++ b/setup.py
@@ -1,23 +1,16 @@
-import re
import os
-import sys
-
-from glob import glob
from setuptools import setup, find_packages
-SRC_FOLDER = 'src'
-PKG_NAME = 'djangosaml2_spid'
with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme:
README = readme.read()
-
with open(os.path.join(os.path.dirname(__file__), 'requirements.txt')) as requirements:
REQUIREMENTS = requirements.read()
setup(
- name="djangosaml2_spid",
+ name="djangosaml2-spid",
version='0.6.6',
description="Djangosaml2 SPID Service Provider",
long_description=README,
@@ -26,13 +19,9 @@
author_email='demarcog83@gmail.com',
license="Apache 2.0",
url='https://github.com/peppelinux/djangosaml2_spid',
- packages=[PKG_NAME,],
- package_dir={PKG_NAME: f'{SRC_FOLDER}/{PKG_NAME}'},
-
- package_data={PKG_NAME: [i.replace(f'{SRC_FOLDER}/{PKG_NAME}/', '')
- for i in glob(f'{SRC_FOLDER}/{PKG_NAME}/**',
- recursive=True)]
- },
+ packages=find_packages('src'),
+ package_dir={'': 'src'},
+ include_package_data=True,
classifiers=[
"Development Status :: 4 - Beta",
"License :: OSI Approved :: Apache Software License",
diff --git a/example/spid_config/attribute-maps/satosa_spid_basic_hybrid.py b/src/djangosaml2_spid/attribute_maps/satosa_spid_basic_hybrid.py
similarity index 100%
rename from example/spid_config/attribute-maps/satosa_spid_basic_hybrid.py
rename to src/djangosaml2_spid/attribute_maps/satosa_spid_basic_hybrid.py
diff --git a/example/spid_config/attribute-maps/satosa_spid_uri_hybrid.py b/src/djangosaml2_spid/attribute_maps/satosa_spid_uri_hybrid.py
similarity index 100%
rename from example/spid_config/attribute-maps/satosa_spid_uri_hybrid.py
rename to src/djangosaml2_spid/attribute_maps/satosa_spid_uri_hybrid.py
diff --git a/src/djangosaml2_spid/conf.py b/src/djangosaml2_spid/conf.py
new file mode 100644
index 0000000..9afc394
--- /dev/null
+++ b/src/djangosaml2_spid/conf.py
@@ -0,0 +1,278 @@
+import os
+import copy
+import logging
+from typing import Optional
+
+import saml2
+from saml2.config import SPConfig
+from saml2.saml import NAMEID_FORMAT_TRANSIENT
+from saml2.sigver import get_xmlsec_binary
+from saml2.xmldsig import DIGEST_SHA256, SIG_RSA_SHA256
+
+from django.conf import settings
+from django.apps import apps
+from django.http import HttpRequest
+from django.core.exceptions import ImproperlyConfigured
+
+logger = logging.getLogger('djangosaml2')
+
+djangosaml2_spid_config = apps.get_app_config('djangosaml2_spid')
+
+
+#
+# Required settings
+
+if not hasattr(settings, 'SPID_CONTACTS'):
+ raise ImproperlyConfigured('Manca la configurazione SPID_CONTACTS!')
+
+if not hasattr(settings, 'SAML_CONFIG'):
+ raise ImproperlyConfigured("Manca la configurazione base per SAML2 "
+ "con le informazioni sull'organizzazione!")
+elif not isinstance(settings.SAML_CONFIG, dict):
+ raise ImproperlyConfigured('Formato improprio per la configurazione SAML2!')
+elif 'organization' not in settings.SAML_CONFIG:
+ raise ImproperlyConfigured("Mancano le informazioni sull'organizzazione "
+ "nella configurazione SAML2!")
+
+#
+# SPID settings with default values
+
+settings.SPID_URLS_PREFIX = getattr(settings, 'SPID_URLS_PREFIX', 'spid')
+
+settings.LOGIN_URL = getattr(settings, 'LOGIN_URL', '/spid/login')
+settings.LOGOUT_URL = getattr(settings, 'LOGOUT_URL', '/spid/logout')
+settings.LOGIN_REDIRECT_URL = getattr(settings, 'LOGIN_REDIRECT_URL', '/spid/echo_attributes')
+
+settings.SPID_DEFAULT_BINDING = getattr(
+ settings, 'SPID_DEFAULT_BINDING', saml2.BINDING_HTTP_POST
+)
+
+settings.SPID_DIG_ALG = getattr(settings, 'SPID_DIG_ALG', DIGEST_SHA256)
+settings.SPID_SIG_ALG = getattr(settings, 'SPID_SIG_ALG', SIG_RSA_SHA256)
+
+settings.SPID_NAMEID_FORMAT = getattr(
+ settings, 'SPID_NAMEID_FORMAT', NAMEID_FORMAT_TRANSIENT
+)
+settings.SPID_AUTH_CONTEXT = getattr(
+ settings, 'SPID_AUTH_CONTEXT', 'https://www.spid.gov.it/SpidL1'
+)
+
+settings.SPID_CERTS_DIR = getattr(
+ settings, 'SPID_CERTS_DIR',
+ os.path.join(settings.BASE_DIR, 'certificates/')
+)
+settings.SPID_PUBLIC_CERT = getattr(
+ settings, 'SPID_PUBLIC_CERT',
+ os.path.join(settings.SPID_CERTS_DIR, 'public.cert')
+)
+settings.SPID_PRIVATE_KEY = getattr(
+ settings, 'SPID_PRIVATE_KEY',
+ os.path.join(settings.SPID_CERTS_DIR, 'private.key')
+)
+
+# source: https://registry.spid.gov.it/identity-providers
+settings.SPID_IDENTITY_PROVIDERS_URL = getattr(
+ settings, 'SPID_IDENTITY_PROVIDERS_URL',
+ 'https://registry.spid.gov.it/assets/data/idp.json'
+)
+
+settings.SPID_IDENTITY_PROVIDERS_METADATA_DIR = getattr(
+ settings, 'SPID_IDENTITY_PROVIDERS_METADATA_DIR',
+ getattr(
+ settings, 'SPID_IDENTITY_PROVIDERS_METADATAS_DIR',
+ os.path.join(settings.BASE_DIR, 'metadata/')
+ )
+)
+
+# Validation tools settings
+settings.SPID_SAML_CHECK_REMOTE_METADATA_ACTIVE = getattr(
+ settings,
+ 'SPID_SAML_CHECK_REMOTE_METADATA_ACTIVE',
+ os.environ.get('SPID_SAML_CHECK_REMOTE_METADATA_ACTIVE', 'False') == 'True'
+)
+
+settings.SPID_SAML_CHECK_METADATA_URL = getattr(
+ settings,
+ 'SPID_SAML_CHECK_METADATA_URL',
+ os.environ.get('SPID_SAML_CHECK_METADATA_URL', 'http://localhost:8080/metadata.xml')
+)
+
+settings.SPID_TESTENV2_REMOTE_METADATA_ACTIVE = getattr(
+ settings,
+ 'SPID_TESTENV2_REMOTE_METADATA_ACTIVE',
+ os.environ.get('SPID_TESTENV2_REMOTE_METADATA_ACTIVE', 'False') == 'True'
+)
+
+settings.SPID_TESTENV2_METADATA_URL = getattr(
+ settings,
+ 'SPID_TESTENV2_METADATA_URL',
+ os.environ.get('SPID_TESTENV2_METADATA_URL', 'http://localhost:8088/metadata')
+)
+
+# Avviso 29v3
+settings.SPID_PREFIXES = getattr(settings, 'SPID_PREFIXES', dict(
+ spid='https://spid.gov.it/saml-extensions',
+ fpa='https://spid.gov.it/invoicing-extensions'
+))
+
+
+#
+# Defaults for other SAML settings
+
+settings.SAML_CONFIG_LOADER = getattr(
+ settings, 'SAML_CONFIG_LOADER', 'djangosaml2_spid.conf.config_settings_loader'
+)
+
+# OR NAME_ID or MAIN_ATTRIBUTE (not together!)
+settings.SAML_USE_NAME_ID_AS_USERNAME = getattr(
+ settings, 'SAML_USE_NAME_ID_AS_USERNAME', False
+)
+settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE = getattr(
+ settings, 'SAML_DJANGO_USER_MAIN_ATTRIBUTE', 'username'
+)
+settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = getattr(
+ settings, 'SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP', '__iexact'
+)
+settings.SAML_CREATE_UNKNOWN_USER = getattr(
+ settings, 'SAML_CREATE_UNKNOWN_USER', True
+)
+
+# logout
+settings.SAML_LOGOUT_REQUEST_PREFERRED_BINDING = getattr(
+ settings, 'SAML_LOGOUT_REQUEST_PREFERRED_BINDING', saml2.BINDING_HTTP_POST
+)
+
+settings.SAML_ATTRIBUTE_MAPPING = getattr(settings, 'SAML_ATTRIBUTE_MAPPING', {
+ 'spidCode': ('username', ),
+ 'fiscalNumber': ('tin', ),
+ 'email': ('email', ),
+ 'name': ('first_name', ),
+ 'familyName': ('last_name', ),
+ 'placeOfBirth': ('place_of_birth',),
+ 'dateOfBirth': ('birth_date',),
+})
+
+
+def config_settings_loader(request: Optional[HttpRequest] = None) -> SPConfig:
+ conf = SPConfig()
+ if request is None or not request.path.lstrip('/').startswith(settings.SPID_URLS_PREFIX):
+ # Not a SPID request: load SAML_CONFIG unchanged
+ conf.load(copy.deepcopy(settings.SAML_CONFIG))
+ return conf
+
+ # Build a SAML_CONFIG for SPID
+ spid_base_url = request.build_absolute_uri(os.path.join('/', settings.SPID_URLS_PREFIX))
+
+ saml_config = {
+ 'entityid': f'{spid_base_url}/metadata',
+ 'attribute_map_dir': os.path.join(djangosaml2_spid_config.path, 'attribute_maps/'),
+
+ 'service': {
+ 'sp': {
+ 'name': f'{spid_base_url}/metadata',
+ 'name_qualifier': request.build_absolute_uri('/'),
+ 'name_id_format': [settings.SPID_NAMEID_FORMAT],
+
+ 'endpoints': {
+ 'assertion_consumer_service': [
+ (f'{spid_base_url}/acs/', saml2.BINDING_HTTP_POST),
+ ],
+ 'single_logout_service': [
+ (f'{spid_base_url}/ls/post/', saml2.BINDING_HTTP_POST),
+ ],
+ },
+
+ # Mandates that the IdP MUST authenticate the presenter directly
+ # rather than rely on a previous security context.
+ 'force_authn': False, # SPID
+ 'name_id_format_allow_create': False,
+
+ # attributes that this project need to identify a user
+ 'required_attributes': [
+ 'spidCode',
+ 'name',
+ 'familyName',
+ 'fiscalNumber',
+ 'email'
+ ],
+
+ 'requested_attribute_name_format': saml2.saml.NAME_FORMAT_BASIC,
+ 'name_format': saml2.saml.NAME_FORMAT_BASIC,
+
+ # attributes that may be useful to have but not required
+ 'optional_attributes': [
+ 'gender',
+ 'companyName',
+ 'registeredOffice',
+ 'ivaCode',
+ 'idCard',
+ 'digitalAddress',
+ 'placeOfBirth',
+ 'countyOfBirth',
+ 'dateOfBirth',
+ 'address',
+ 'mobilePhone',
+ 'expirationDate'
+ ],
+
+ 'signing_algorithm': settings.SPID_SIG_ALG,
+ 'digest_algorithm': settings.SPID_DIG_ALG,
+
+ 'authn_requests_signed': True,
+ 'logout_requests_signed': True,
+
+ # Indicates that Authentication Responses to this SP must
+ # be signed. If set to True, the SP will not consume
+ # any SAML Responses that are not signed.
+ 'want_assertions_signed': True,
+
+ # When set to true, the SP will consume unsolicited SAML
+ # Responses, i.e. SAML Responses for which it has not sent
+ # a respective SAML Authentication Request.
+ 'allow_unsolicited': False,
+
+ # Permits to have attributes not configured in attribute-mappings
+ # otherwise...without OID will be rejected
+ 'allow_unknown_attributes': True,
+ },
+ },
+
+ 'metadata': {
+ 'local': [settings.SPID_IDENTITY_PROVIDERS_METADATA_DIR],
+ 'remote': []
+ },
+
+ # Signing
+ 'key_file': settings.SPID_PRIVATE_KEY,
+ 'cert_file': settings.SPID_PUBLIC_CERT,
+
+ # Encryption
+ 'encryption_keypairs': [{
+ 'key_file': settings.SPID_PRIVATE_KEY,
+ 'cert_file': settings.SPID_PUBLIC_CERT,
+ }],
+
+ 'organization': copy.deepcopy(settings.SAML_CONFIG['organization'])
+ }
+
+ if settings.SAML_CONFIG.get('debug'):
+ saml_config['debug'] = True
+
+ if 'xmlsec_binary' in settings.SAML_CONFIG:
+ saml_config['xmlsec_binary'] = copy.deepcopy(settings.SAML_CONFIG['xmlsec_binary'])
+ else:
+ saml_config['xmlsec_binary'] = get_xmlsec_binary(['/opt/local/bin', '/usr/bin/xmlsec1'])
+
+ if settings.SPID_SAML_CHECK_REMOTE_METADATA_ACTIVE:
+ saml_config['metadata']['remote'].append(
+ {'url': settings.SPID_SAML_CHECK_METADATA_URL}
+ )
+
+ if settings.SPID_TESTENV2_REMOTE_METADATA_ACTIVE:
+ saml_config['metadata']['remote'].append(
+ {'url': settings.SPID_TESTENV2_METADATA_URL}
+ )
+
+ logger.debug('SAML_CONFIG: %r', saml_config)
+ conf.load(saml_config)
+ return conf
diff --git a/src/djangosaml2_spid/management/commands/update_idps.py b/src/djangosaml2_spid/management/commands/update_idps.py
new file mode 100644
index 0000000..af8448b
--- /dev/null
+++ b/src/djangosaml2_spid/management/commands/update_idps.py
@@ -0,0 +1,68 @@
+from django.conf import settings
+from django.core.management.base import BaseCommand
+import json
+import os
+import requests
+
+
+SPID_IDENTITY_PROVIDERS_URL = settings.SPID_IDENTITY_PROVIDERS_URL
+SPID_IDENTITY_PROVIDERS_METADATA_DIR = settings.SPID_IDENTITY_PROVIDERS_METADATA_DIR
+
+
+class Command(BaseCommand):
+ help = 'Download and write all the official identity providers metadata XML files'
+
+ def handle(self, *args, **options):
+ self.write_identity_providers_metadatas()
+
+ def write_identity_providers_metadatas(self):
+ identity_providers = self.download_identity_providers()
+
+ self.print(f'Starting writing of IdPs metadata XML files '
+ f'into {SPID_IDENTITY_PROVIDERS_METADATA_DIR}:')
+
+ for identity_provider in identity_providers:
+ idp_entity_code = identity_provider['ipa_entity_code']
+ idp_entity_name = identity_provider['entity_name']
+ idp_metadata = identity_provider['metadata']
+ metadata_file_path = os.path.join(
+ SPID_IDENTITY_PROVIDERS_METADATA_DIR, f'{idp_entity_code}.xml')
+
+ self.print(f'Writing metadata XML file for IdP {idp_entity_name} '
+ f'into {metadata_file_path}', indentation_level=1)
+
+ with open(metadata_file_path, 'w', encoding='utf8') as metadata_file:
+ metadata_file.write(idp_metadata)
+
+ self.print_success(f'Successfully wrote all IdPs metadata XML files '
+ f'into {SPID_IDENTITY_PROVIDERS_METADATA_DIR}')
+
+ def download_identity_providers(self):
+ self.print(f'Starting download of identity providers (IdPs) '
+ f'official list from {SPID_IDENTITY_PROVIDERS_URL}')
+
+ with requests.get(SPID_IDENTITY_PROVIDERS_URL, verify=True) as response:
+ identity_providers = json.loads(response.content)['data']
+
+ self.print('Downloaded IdPs official list, starting IdPs metadatas download:')
+
+ for identity_provider in identity_providers:
+ idp_entity_name = identity_provider['entity_name']
+ idp_metadata_url = identity_provider['metadata_url']
+
+ self.print(f'Downloading metadata for IdP {idp_entity_name} '
+ f'from {idp_metadata_url}', indentation_level=1)
+
+ with requests.get(idp_metadata_url, verify=True) as response:
+ identity_provider['metadata'] = response.text
+
+ self.print_success('All IdPs metadatas downloaded successfully')
+
+ return identity_providers
+
+ def print(self, string, *, indentation_level=0):
+ indentation = ' ' * indentation_level
+ self.stdout.write(indentation + string)
+
+ def print_success(self, string, *, indentation_level=0):
+ self.print(self.style.SUCCESS(string), indentation_level=indentation_level)
diff --git a/src/djangosaml2_spid/spid_anomalies.py b/src/djangosaml2_spid/spid_anomalies.py
new file mode 100644
index 0000000..063ac71
--- /dev/null
+++ b/src/djangosaml2_spid/spid_anomalies.py
@@ -0,0 +1,59 @@
+import re
+import saml2
+
+
+class SpidAnomaly:
+ find_error_code_regexp = re.compile(r'ErrorCode nr(\d+)')
+
+ def __init__(self, *, code, message, troubleshoot=None):
+ self.code = code
+ self.status_message = f'ErrorCode nr{self.code}'
+ self.message = message
+ self.troubleshoot = troubleshoot
+
+ @classmethod
+ def from_saml2_exception(cls, exception):
+ if not isinstance(exception, saml2.response.StatusAuthnFailed):
+ return None
+
+ saml2_error_message = exception.args[0]
+ codes = set(cls.find_error_code_regexp.findall(saml2_error_message))
+ if len(codes) != 1:
+ return None
+
+ code = int(codes.pop())
+ return spid_anomalies_by_code[code]
+
+
+spid_anomalies = [
+ SpidAnomaly(
+ code=19,
+ message='Autenticazione fallita per ripetuta sottomissione di credenziali errate',
+ troubleshoot='Inserire credenziali corrette'
+ ),
+ SpidAnomaly(
+ code=20,
+ message='Utente privo di credenziali compatibili con il livello di autenticazione richiesto',
+ troubleshoot='Acquisire credenziali di livello idoneo all\'accesso al servizio'
+ ),
+ SpidAnomaly(
+ code=21,
+ message='Timeout durante l\'autenticazione utente',
+ troubleshoot='Si ricorda che l\'operazione di autenticazione deve essere completata entro un determinato periodo di tempo'
+ ),
+ SpidAnomaly(
+ code=22,
+ message='L\'utente nega il consenso all\'invio di dati al fornitore del servizio',
+ troubleshoot='È necessario dare il consenso per poter accedere al servizio'
+ ),
+ SpidAnomaly(
+ code=23,
+ message='Utente con identità sospesa/revocata o con credenziali bloccate'
+ ),
+ SpidAnomaly(
+ code=25,
+ message='Processo di autenticazione annullato dall\'utente'
+ )
+]
+
+spid_anomalies_by_code = {anomaly.code: anomaly for anomaly in spid_anomalies}
diff --git a/src/djangosaml2_spid/spid_metadata.py b/src/djangosaml2_spid/spid_metadata.py
new file mode 100644
index 0000000..2d93268
--- /dev/null
+++ b/src/djangosaml2_spid/spid_metadata.py
@@ -0,0 +1,140 @@
+import saml2
+from django.conf import settings
+from saml2.metadata import entity_descriptor, sign_entity_descriptor
+from saml2.sigver import security_context
+
+
+def spid_sp_metadata(conf):
+ metadata = entity_descriptor(conf)
+
+ # this will renumber acs starting from 0 and set index=0 as is_default
+ cnt = 0
+ for attribute_consuming_service in metadata.spsso_descriptor.attribute_consuming_service:
+ attribute_consuming_service.index = str(cnt)
+ cnt += 1
+
+ cnt = 0
+ for assertion_consumer_service in metadata.spsso_descriptor.assertion_consumer_service:
+ assertion_consumer_service.is_default = 'true' if not cnt else ''
+ assertion_consumer_service.index = str(cnt)
+ cnt += 1
+
+ # nameformat patch
+ for reqattr in metadata.spsso_descriptor.attribute_consuming_service[0].requested_attribute:
+ reqattr.name_format = None # "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ reqattr.friendly_name = None
+
+ metadata.extensions = None
+
+ # attribute consuming service service name patch
+ service_name = metadata.spsso_descriptor.attribute_consuming_service[0].service_name[0]
+ service_name.lang = 'it'
+ service_name.text = conf._sp_name
+
+ avviso_29_v3(metadata)
+
+ # metadata signature
+ secc = security_context(conf)
+ sign_dig_algs = dict(
+ sign_alg=conf._sp_signing_algorithm,
+ digest_alg=conf._sp_digest_algorithm
+ )
+ eid, xmldoc = sign_entity_descriptor(metadata, None, secc, **sign_dig_algs)
+ return xmldoc
+
+
+def avviso_29_v3(metadata):
+ """
+ https://www.agid.gov.it/sites/default/files/repository_files/spid-avviso-n29v3-specifiche_sp_pubblici_e_privati_0.pdf
+ """
+
+ saml2.md.SamlBase.register_prefix(settings.SPID_PREFIXES)
+
+ contact_map = settings.SPID_CONTACTS
+ metadata.contact_person = []
+ for contact in contact_map:
+ spid_contact = saml2.md.ContactPerson()
+ spid_contact.contact_type = contact['contact_type']
+ contact_kwargs = {
+ 'email_address': [contact['email_address']],
+ 'telephone_number': [contact['telephone_number']]
+ }
+ spid_extensions = saml2.ExtensionElement(
+ 'Extensions',
+ namespace='urn:oasis:names:tc:SAML:2.0:metadata'
+ )
+
+ if contact['contact_type'] == 'other':
+ spid_contact.loadd(contact_kwargs)
+ contact_kwargs['contact_type'] = contact['contact_type']
+ for k, v in contact.items():
+ if k in contact_kwargs:
+ continue
+ ext = saml2.ExtensionElement(
+ k,
+ namespace=settings.SPID_PREFIXES['spid'],
+ text=v
+ )
+ spid_extensions.children.append(ext)
+
+ spid_contact.extensions = spid_extensions
+
+ elif contact['contact_type'] == 'billing':
+ contact_kwargs['company'] = contact['company']
+ spid_contact.loadd(contact_kwargs)
+
+ elements = {}
+ for k, v in contact.items():
+ if k in contact_kwargs:
+ continue
+ ext = saml2.ExtensionElement(
+ k,
+ namespace=settings.SPID_PREFIXES['fpa'],
+ text=v
+ )
+ elements[k] = ext
+
+ # DatiAnagrafici
+ IdFiscaleIVA = saml2.ExtensionElement(
+ 'IdFiscaleIVA',
+ namespace=settings.SPID_PREFIXES['fpa'],
+ )
+ Anagrafica = saml2.ExtensionElement(
+ 'Anagrafica',
+ namespace=settings.SPID_PREFIXES['fpa'],
+ )
+ Anagrafica.children.append(elements['Denominazione'])
+
+ IdFiscaleIVA.children.append(elements['IdPaese'])
+ IdFiscaleIVA.children.append(elements['IdCodice'])
+ DatiAnagrafici = saml2.ExtensionElement(
+ 'DatiAnagrafici',
+ namespace=settings.SPID_PREFIXES['fpa'],
+ )
+ if elements.get('CodiceFiscale'):
+ DatiAnagrafici.children.append(elements['CodiceFiscale'])
+ DatiAnagrafici.children.append(IdFiscaleIVA)
+ DatiAnagrafici.children.append(Anagrafica)
+ CessionarioCommittente = saml2.ExtensionElement(
+ 'CessionarioCommittente',
+ namespace=settings.SPID_PREFIXES['fpa'],
+ )
+ CessionarioCommittente.children.append(DatiAnagrafici)
+
+ # Sede
+ Sede = saml2.ExtensionElement(
+ 'Sede',
+ namespace=settings.SPID_PREFIXES['fpa'],
+ )
+ Sede.children.append(elements['Indirizzo'])
+ Sede.children.append(elements['NumeroCivico'])
+ Sede.children.append(elements['CAP'])
+ Sede.children.append(elements['Comune'])
+ Sede.children.append(elements['Provincia'])
+ Sede.children.append(elements['Nazione'])
+ CessionarioCommittente.children.append(Sede)
+
+ spid_extensions.children.append(CessionarioCommittente)
+
+ spid_contact.extensions = spid_extensions
+ metadata.contact_person.append(spid_contact)
diff --git a/src/djangosaml2_spid/spid_request.py b/src/djangosaml2_spid/spid_request.py
new file mode 100644
index 0000000..97c496e
--- /dev/null
+++ b/src/djangosaml2_spid/spid_request.py
@@ -0,0 +1,81 @@
+import logging
+
+import saml2
+from django.conf import settings
+from django.urls import reverse
+from djangosaml2.overrides import Saml2Client
+from saml2.authn_context import requested_authn_context
+
+
+SPID_DEFAULT_BINDING = settings.SPID_DEFAULT_BINDING
+
+logger = logging.getLogger('djangosaml2')
+
+
+def spid_sp_authn_request(conf, selected_idp, next_url=''):
+ client = Saml2Client(conf)
+
+ logger.debug(f'Redirecting user to the IdP via {SPID_DEFAULT_BINDING} binding.')
+
+ # use the html provided by pysaml2 if no template was specified or it didn't exist
+ # SPID want the fqdn of the IDP, not the SSO endpoint
+ location_fixed = selected_idp
+ location = client.sso_location(selected_idp, SPID_DEFAULT_BINDING)
+
+ authn_req = saml2.samlp.AuthnRequest()
+ authn_req.destination = location_fixed
+ # spid-testenv2 preleva l'attribute consumer service dalla authnRequest (anche se questo sta già nei metadati...)
+ authn_req.attribute_consuming_service_index = "0"
+
+ # issuer
+ issuer = saml2.saml.Issuer()
+ issuer.name_qualifier = client.config.entityid
+ issuer.text = client.config.entityid
+ issuer.format = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
+ authn_req.issuer = issuer
+
+ # message id
+ authn_req.id = saml2.s_utils.sid()
+ authn_req.version = saml2.VERSION # "2.0"
+ authn_req.issue_instant = saml2.time_util.instant()
+
+ name_id_policy = saml2.samlp.NameIDPolicy()
+ name_id_policy.format = settings.SPID_NAMEID_FORMAT
+ authn_req.name_id_policy = name_id_policy
+
+ authn_context = requested_authn_context(class_ref=settings.SPID_AUTH_CONTEXT)
+ authn_req.requested_authn_context = authn_context
+
+ # if SPID authentication level is > 1 then forceauthn must be True
+ authn_req.force_authn = 'true'
+
+ authn_req.protocol_binding = SPID_DEFAULT_BINDING
+
+ assertion_consumer_service_url = client.config._sp_endpoints['assertion_consumer_service'][0][0]
+ authn_req.assertion_consumer_service_url = assertion_consumer_service_url
+
+ authn_req_signed = client.sign(
+ authn_req,
+ sign_prepare=False,
+ sign_alg=settings.SPID_SIG_ALG,
+ digest_alg=settings.SPID_DIG_ALG,
+ )
+
+ logger.debug(f'AuthRequest to {selected_idp}: {authn_req_signed}')
+
+ relay_state = next_url or reverse('djangosaml2:saml2_echo_attributes')
+ http_info = client.apply_binding(
+ SPID_DEFAULT_BINDING,
+ authn_req_signed,
+ location,
+ sign=True,
+ sigalg=settings.SPID_SIG_ALG,
+ relay_state=relay_state
+ )
+
+ return dict(
+ http_response=http_info,
+ authn_request=authn_req_signed,
+ relay_state=relay_state,
+ session_id=authn_req.id
+ )
\ No newline at end of file
diff --git a/example/spid_config/static/spid/bootstrap-italia.css b/src/djangosaml2_spid/static/spid/bootstrap-italia.css
similarity index 100%
rename from example/spid_config/static/spid/bootstrap-italia.css
rename to src/djangosaml2_spid/static/spid/bootstrap-italia.css
diff --git a/example/spid_config/static/spid/bootstrap-italia.js b/src/djangosaml2_spid/static/spid/bootstrap-italia.js
similarity index 100%
rename from example/spid_config/static/spid/bootstrap-italia.js
rename to src/djangosaml2_spid/static/spid/bootstrap-italia.js
diff --git a/example/spid_config/static/spid/brython.js b/src/djangosaml2_spid/static/spid/brython.js
similarity index 100%
rename from example/spid_config/static/spid/brython.js
rename to src/djangosaml2_spid/static/spid/brython.js
diff --git a/example/spid_config/static/spid/logo.jpg b/src/djangosaml2_spid/static/spid/logo.jpg
similarity index 100%
rename from example/spid_config/static/spid/logo.jpg
rename to src/djangosaml2_spid/static/spid/logo.jpg
diff --git a/example/spid_config/static/spid/logo_white.png b/src/djangosaml2_spid/static/spid/logo_white.png
similarity index 100%
rename from example/spid_config/static/spid/logo_white.png
rename to src/djangosaml2_spid/static/spid/logo_white.png
diff --git a/example/spid_config/static/spid/spid-ico-circle-bb.svg b/src/djangosaml2_spid/static/spid/spid-ico-circle-bb.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-ico-circle-bb.svg
rename to src/djangosaml2_spid/static/spid/spid-ico-circle-bb.svg
diff --git a/example/spid_config/static/spid/spid-idp-arubaid.svg b/src/djangosaml2_spid/static/spid/spid-idp-arubaid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-arubaid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-arubaid.svg
diff --git a/example/spid_config/static/spid/spid-idp-infocertid.svg b/src/djangosaml2_spid/static/spid/spid-idp-infocertid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-infocertid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-infocertid.svg
diff --git a/example/spid_config/static/spid/spid-idp-intesaid.svg b/src/djangosaml2_spid/static/spid/spid-idp-intesaid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-intesaid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-intesaid.svg
diff --git a/example/spid_config/static/spid/spid-idp-lepidaid.svg b/src/djangosaml2_spid/static/spid/spid-idp-lepidaid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-lepidaid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-lepidaid.svg
diff --git a/example/spid_config/static/spid/spid-idp-namirialid.svg b/src/djangosaml2_spid/static/spid/spid-idp-namirialid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-namirialid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-namirialid.svg
diff --git a/example/spid_config/static/spid/spid-idp-posteid.svg b/src/djangosaml2_spid/static/spid/spid-idp-posteid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-posteid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-posteid.svg
diff --git a/example/spid_config/static/spid/spid-idp-sielteid.svg b/src/djangosaml2_spid/static/spid/spid-idp-sielteid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-sielteid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-sielteid.svg
diff --git a/example/spid_config/static/spid/spid-idp-spiditalia.svg b/src/djangosaml2_spid/static/spid/spid-idp-spiditalia.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-spiditalia.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-spiditalia.svg
diff --git a/example/spid_config/static/spid/spid-idp-timid.svg b/src/djangosaml2_spid/static/spid/spid-idp-timid.svg
similarity index 100%
rename from example/spid_config/static/spid/spid-idp-timid.svg
rename to src/djangosaml2_spid/static/spid/spid-idp-timid.svg
diff --git a/example/spid_config/static/spid/spid-sp-access-button.css b/src/djangosaml2_spid/static/spid/spid-sp-access-button.css
similarity index 100%
rename from example/spid_config/static/spid/spid-sp-access-button.css
rename to src/djangosaml2_spid/static/spid/spid-sp-access-button.css
diff --git a/example/spid_config/static/spid/spid-sp-access-button.js b/src/djangosaml2_spid/static/spid/spid-sp-access-button.js
similarity index 100%
rename from example/spid_config/static/spid/spid-sp-access-button.js
rename to src/djangosaml2_spid/static/spid/spid-sp-access-button.js
diff --git a/example/spid_config/static/spid/spid_button.js b/src/djangosaml2_spid/static/spid/spid_button.js
similarity index 100%
rename from example/spid_config/static/spid/spid_button.js
rename to src/djangosaml2_spid/static/spid/spid_button.js
diff --git a/src/djangosaml2_spid/templates/spid_login_error.html b/src/djangosaml2_spid/templates/spid_login_error.html
new file mode 100644
index 0000000..029f338
--- /dev/null
+++ b/src/djangosaml2_spid/templates/spid_login_error.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
Errore di autenticazione
+
{{ spid_anomaly.message|default:'Accesso negato' }}
+
{{ spid_anomaly.troubleshoot|default:'' }}
+
+
+
diff --git a/src/djangosaml2_spid/tests.py b/src/djangosaml2_spid/tests.py
index 1aae4af..ac26249 100644
--- a/src/djangosaml2_spid/tests.py
+++ b/src/djangosaml2_spid/tests.py
@@ -1,15 +1,19 @@
-import logging
+import os
import re
+import xml.etree.ElementTree as ElementTree
-from django.conf import settings
from django.contrib.auth import get_user_model
-from django.test import Client, RequestFactory, TestCase
-
+from django.contrib.staticfiles import finders
+from django.contrib.staticfiles.storage import staticfiles_storage
+from django.core.exceptions import ImproperlyConfigured
+from django.http import HttpResponseBadRequest
+from django.test import Client, TestCase, RequestFactory
from django.urls import reverse
-from djangosaml2_spid.utils import repr_saml
-logger = logging.getLogger(__name__)
-# logger.setLevel(logging.DEBUG)
+from djangosaml2.conf import get_config_loader, get_config
+
+from .conf import config_settings_loader
+from .utils import repr_saml
def samlrequest_from_html_form(htmlstr):
@@ -20,75 +24,175 @@ def samlrequest_from_html_form(htmlstr):
return authn_request[0]
+
def repr_samlrequest(authnreqstr, **kwargs):
return repr_saml(authnreqstr, **kwargs)
-
-class SpidTest(TestCase):
+
+def dummy_loader():
+ return
+
+
+class TestSpidConfig(TestCase):
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_get_config_loader(self):
+ func = get_config_loader('djangosaml2_spid.tests.dummy_loader')
+ self.assertIs(func, dummy_loader)
+
+ func = get_config_loader('djangosaml2_spid.conf.config_settings_loader')
+ self.assertIs(func, config_settings_loader)
+
+ def test_get_config(self):
+ saml_config = get_config()
+ self.assertEqual(saml_config.entityid, 'http://localhost:8000/spid/metadata')
+
+ request = self.factory.get('')
+ saml_config = get_config(request=request)
+ self.assertEqual(saml_config.entityid, 'http://localhost:8000/spid/metadata')
+
+ request = self.factory.get('/spid/metadata')
+ saml_config = get_config(request=request)
+ self.assertEqual(saml_config.entityid, 'http://testserver/spid/metadata')
+ self.assertEqual(
+ saml_config.organization,
+ {'name': [('Example', 'it'), ('Example', 'en')],
+ 'display_name': [('Example', 'it'), ('Example', 'en')],
+ 'url': [('http://www.example.it', 'it'), ('http://www.example.it', 'en')]}
+ )
+
+
+class TestStaticFiles(TestCase):
+
+ def test_spid_logo(self):
+ abs_path = finders.find('spid/logo.jpg')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ # For using staticfiles_storage you have to configure STATIC_ROOT setting
+ with self.assertRaises(ImproperlyConfigured):
+ staticfiles_storage.exists(abs_path)
+
+ def test_idp_logos(self):
+ abs_path = finders.find('spid/spid-idp-intesaid.svg')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ abs_path = finders.find('spid/spid-idp-posteid.svg')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ def test_css_files(self):
+ abs_path = finders.find('spid/spid-sp-access-button.css')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ def test_scripts(self):
+ abs_path = finders.find('spid/brython.js')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ abs_path = finders.find('spid/spid_button.js')
+ self.assertTrue(os.path.isfile(abs_path))
+
+ abs_path = finders.find('spid/spid-sp-access-button.js')
+ self.assertTrue(os.path.isfile(abs_path))
+
+
+class TestSpid(TestCase):
def setUp(self):
self.create_user()
@classmethod
def create_user(cls, **kwargs):
- data = {'username': 'foo',
- 'first_name': 'foo',
- 'last_name': 'bar',
- 'email': 'that@mail.org'}
- for k,v in kwargs.items():
+ data = {'username': 'foo',
+ 'first_name': 'foo',
+ 'last_name': 'bar',
+ 'email': 'that@mail.org'}
+ for k, v in kwargs.items():
data[k] = v
user = get_user_model().objects.create(**data)
return user
-
def test_metadata_endpoint(self):
url = reverse('djangosaml2_spid:spid_metadata')
- req = Client()
- res = req.get(url)
-
+ client = Client()
+ res = client.get(url)
+
self.assertEqual(res.status_code, 200)
+
# TODO: here validation with spid saml tests
# ...
#
- logger.debug(res.content.decode())
+ metadata_xml = ElementTree.fromstring(res.content)
+ namespaces = dict(
+ md="urn:oasis:names:tc:SAML:2.0:metadata",
+ spid="https://spid.gov.it/saml-extensions",
+ fpa="https://spid.gov.it/invoicing-extensions",
+ )
+ self.assertEqual(
+ metadata_xml.tag, '{urn:oasis:names:tc:SAML:2.0:metadata}EntityDescriptor')
+ self.assertEqual(
+ metadata_xml.find('.//spid:VATNumber', namespaces).text, 'IT12345678901')
+ self.assertEqual(
+ metadata_xml.find('.//spid:FiscalCode', namespaces).text, 'XYZABCAAMGGJ000W')
def test_authnreq(self):
url = reverse('djangosaml2_spid:spid_login')
- req = Client()
- res = req.get(f'{url}?idp=http://localhost:54321')
+ client = Client()
+ res = client.get(f'{url}?idp=http://localhost:54321')
self.assertEqual(res.status_code, 200)
- htmlform = res.content.decode()
- encoded_authn_req = samlrequest_from_html_form(htmlform)
+ html_form = res.content.decode()
+ encoded_authn_req = samlrequest_from_html_form(html_form)
fancy_saml = repr_samlrequest(encoded_authn_req.encode(), b64=1)
- logger.debug(fancy_saml)
-
+ self.assertNotIn('ns0', fancy_saml)
+
+ lines = fancy_saml.split('\n')
+ self.assertEqual(lines[0], '')
+ self.assertTrue(lines[1].startswith('
')
def test_authnreq_already_logged_user(self):
url = reverse('djangosaml2_spid:index')
- req = Client()
+ client = Client()
user = get_user_model().objects.first()
- req.force_login(user)
- res = req.get(f'{url}')
+ client.force_login(user)
+ res = client.get(f'{url}')
+
self.assertEqual(res.status_code, 200)
- self.assertTrue('LOGGED' in res.content.decode())
- logger.debug(res.content.decode())
-
- url = reverse('djangosaml2_spid:spid_login')
- res = req.get(f'{url}')
-
+ self.assertIn(b'LOGGED IN:', res.content)
+ self.assertIn(b'first_name: foo', res.content)
+ self.assertIn(b'last_name: bar', res.content)
+ self.assertIn(b'is_active: True', res.content)
+ self.assertIn(b'is_superuser: False', res.content)
+ self.assertIn(b'is_staff: False', res.content)
+
def test_logout(self):
- url = reverse('djangosaml2_spid:spid_logout')
- req = Client()
+ logout_url = reverse('djangosaml2_spid:spid_logout')
+ client = Client()
user = get_user_model().objects.first()
- req.force_login(user)
- res = req.get(f'{url}')
+ client.force_login(user)
- def test_logout(self):
+ with self.assertLogs('djangosaml2', level='WARNING') as ctx:
+ res = client.get(logout_url)
+
+ self.assertEqual(res.status_code, 400)
+ self.assertIsInstance(res, HttpResponseBadRequest)
+
+ self.assertEqual(len(ctx.output), 2)
+ self.assertIn('WARNING:djangosaml2:The session does not contain '
+ 'the subject id for user AnonymousUser', ctx.output)
+ self.assertIn('ERROR:djangosaml2:Looks like the user None is not '
+ 'logged in any IdP/AA', ctx.output)
+
+ def test_echo_attributes(self):
url = reverse('djangosaml2_spid:spid_echo_attributes')
- req = Client()
+ client = Client()
user = get_user_model().objects.first()
- req.force_login(user)
- res = req.get(f'{url}')
+ client.force_login(user)
+ res = client.get(f'{url}')
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.content, b'No active SAML identity found. Are you '
+ b'sure you have logged in via SAML?')
diff --git a/src/djangosaml2_spid/urls.py b/src/djangosaml2_spid/urls.py
index 166fa37..14fbf91 100644
--- a/src/djangosaml2_spid/urls.py
+++ b/src/djangosaml2_spid/urls.py
@@ -1,36 +1,46 @@
-from django.urls import include, path
-from django.conf import settings
-from django.contrib import admin
-from django.contrib.auth import views as auth_views
-from django.urls import reverse
-from django.views.generic.base import RedirectView
+from django.urls import path
+from .conf import settings
+from . import views
-from djangosaml2 import views
-from djangosaml2_spid.views import (metadata_spid,
- spid_login,
- spid_logout,
- index,
- EchoAttributesView)
-
-SAML2_URL_PREFIX = 'spid'
+SPID_URLS_PREFIX = settings.SPID_URLS_PREFIX
urlpatterns = [
- # patched metadata for spid
- path(f'{SAML2_URL_PREFIX}/login/', spid_login, name='spid_login'),
- path(f'{SAML2_URL_PREFIX}/metadata/', metadata_spid, name='spid_metadata'),
- path(f'{SAML2_URL_PREFIX}/logout/', spid_logout, name='spid_logout'),
-
- path(f'{SAML2_URL_PREFIX}/acs/', views.AssertionConsumerServiceView.as_view(), name='saml2_acs'),
- path(f'{SAML2_URL_PREFIX}/ls/', views.LogoutView.as_view(), name='saml2_ls'),
- path(f'{SAML2_URL_PREFIX}/ls/post/', views.LogoutView.as_view(), name='saml2_ls_post'),
- path(f'{SAML2_URL_PREFIX}/echo_attributes', EchoAttributesView.as_view(), name='spid_echo_attributes'),
- path('logout/', auth_views.LogoutView.as_view(), {'next_page': settings.LOGOUT_REDIRECT_URL}, name='logout'),
-
- # path('', RedirectView.as_view(url='', permanent=False), name='index')
- path('', index, name='index')
-
- # path('spid/logout/', spid_logout,
- # {'next_page': settings.LOGOUT_REDIRECT_URL}, name='spid_logout'),
+ path(f'{SPID_URLS_PREFIX}', views.index, name='index'),
+ path(
+ f'{SPID_URLS_PREFIX}/echo_attributes/',
+ views.EchoAttributesView.as_view(),
+ name='spid_echo_attributes'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/login/',
+ views.spid_login,
+ name='spid_login'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/logout/',
+ views.spid_logout,
+ name='spid_logout'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/metadata/',
+ views.metadata_spid,
+ name='spid_metadata'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/acs/',
+ views.AssertionConsumerServiceView.as_view(),
+ name='saml2_acs'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/ls/',
+ views.LogoutView.as_view(),
+ name='saml2_ls'
+ ),
+ path(
+ f'{SPID_URLS_PREFIX}/ls/post/',
+ views.LogoutView.as_view(),
+ name='saml2_ls_post'
+ )
]
diff --git a/src/djangosaml2_spid/views.py b/src/djangosaml2_spid/views.py
index 0d964cf..ffd11cb 100644
--- a/src/djangosaml2_spid/views.py
+++ b/src/djangosaml2_spid/views.py
@@ -1,155 +1,71 @@
-import base64
-import logging
-import random
-import saml2
-import string
-
-from django.conf import settings
from django.contrib import auth
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
-from django.dispatch import receiver
-from django.http import (HttpResponse,
- HttpResponseRedirect,
- HttpResponseBadRequest,
- HttpResponseNotFound)
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import (
+ HttpResponse,
+ HttpResponseBadRequest,
+ HttpResponseNotFound,
+ HttpResponseRedirect
+)
from django.shortcuts import render
-from django.template import TemplateDoesNotExist
-from django.urls import reverse
-from djangosaml2.conf import get_config
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
from djangosaml2.cache import StateCache
from djangosaml2.conf import get_config
from djangosaml2.overrides import Saml2Client
-from djangosaml2.signals import post_authenticated, pre_user_save
from djangosaml2.utils import (
- available_idps, get_custom_setting,
- get_idp_sso_supported_bindings, get_location,
+ available_idps,
+ get_idp_sso_supported_bindings,
validate_referral_url
)
-from djangosaml2.views import (finish_logout,
- _get_subject_id,
- SPConfigMixin, View)
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
-from saml2.authn_context import requested_authn_context
from saml2.mdstore import UnknownSystemEntity
-from saml2.metadata import entity_descriptor, sign_entity_descriptor
-from saml2.sigver import security_context
from saml2.s_utils import UnsupportedBinding
+import djangosaml2.views as djangosaml2_views
+import logging
+import saml2
+from .conf import settings
+from .spid_anomalies import SpidAnomaly
+from .spid_metadata import spid_sp_metadata
+from .spid_request import spid_sp_authn_request
from .utils import repr_saml
+SPID_DEFAULT_BINDING = settings.SPID_DEFAULT_BINDING
+
logger = logging.getLogger('djangosaml2')
def index(request):
- """ Barebone 'diagnostics' view, print user attributes if logged in + login/logout links.
"""
+ Barebone 'diagnostics' view, print user attributes
+ if logged in + login/logout links.
+ """
+
if request.user.is_authenticated:
- out = "LOGGED IN: LOGOUT
".format(settings.LOGOUT_URL)
- out += "".join(['%s: %s' % (field.name, getattr(request.user, field.name))
- for field in request.user._meta.get_fields()
- if field.concrete])
+ out = f"LOGGED IN: LOGOUT
"
+ out += "".join([
+ f'{field.name}: {getattr(request.user, field.name)}'
+ for field in request.user._meta.get_fields()
+ if field.concrete]
+ )
return HttpResponse(out)
else:
- return HttpResponse("LOGGED OUT: LOGIN".format(settings.LOGIN_URL))
-
-
-# @receiver(pre_user_save, sender=User)
-# def custom_update_user(sender, instance, attributes, user_modified, **kargs):
- # """ Default behaviour does not play nice with booleans encoded in SAML as u'true'/u'false'.
- # This will convert those attributes to real booleans when saving.
- # """
- # for k, v in attributes.items():
- # u = set.intersection(set(v), set([u'true', u'false']))
- # if u:
- # setattr(instance, k, u.pop() == u'true')
- # return True # I modified the user object
-
-
-def spid_sp_authn_request(conf, selected_idp, binding,
- name_id_format, authn_context,
- sig_alg, dig_alg, next_url=''):
- client = Saml2Client(conf)
-
- logger.debug(f'Redirecting user to the IdP via {binding} binding.')
- # use the html provided by pysaml2 if no template was specified or it didn't exist
- # SPID want the fqdn of the IDP, not the SSO endpoint
- location_fixed = selected_idp
- location = client.sso_location(selected_idp, binding)
-
- authn_req = saml2.samlp.AuthnRequest()
- authn_req.destination = location_fixed
- # spid-testenv2 preleva l'attribute consumer service dalla authnRequest (anche se questo sta già nei metadati...)
- authn_req.attribute_consuming_service_index = "0"
-
- # issuer
- issuer = saml2.saml.Issuer()
- issuer.name_qualifier = client.config.entityid
- issuer.text = client.config.entityid
- issuer.format = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
- authn_req.issuer = issuer
-
- # message id
- authn_req.id = saml2.s_utils.sid()
- authn_req.version = saml2.VERSION # "2.0"
- authn_req.issue_instant = saml2.time_util.instant()
-
- name_id_policy = saml2.samlp.NameIDPolicy()
- # del(name_id_policy.allow_create)
- name_id_policy.format = name_id_format # settings.SPID_NAMEID_FORMAT
- authn_req.name_id_policy = name_id_policy
-
- # settings.SPID_AUTH_CONTEXT
- authn_context = requested_authn_context(class_ref=authn_context)
- authn_req.requested_authn_context = authn_context
-
- # force_auth = true only if SpidL >= 2
- # if 'SpidL1' in authn_context.authn_context_class_ref[0].text:
- # force_authn = 'false'
- # else:
- force_authn = 'true'
- authn_req.force_authn = force_authn
- # end force authn
-
- # settings.SPID_DEFAULT_BINDING
- authn_req.protocol_binding = binding
-
- assertion_consumer_service_url = client.config._sp_endpoints['assertion_consumer_service'][0][0]
- authn_req.assertion_consumer_service_url = assertion_consumer_service_url
-
- authn_req_signed = client.sign(authn_req, sign_prepare=False,
- sign_alg=sig_alg,
- digest_alg=dig_alg,
- )
- logger.debug(f'AuthRequest to {selected_idp}: {authn_req_signed}')
- relay_state = next_url or reverse('djangosaml2:saml2_echo_attributes')
- http_info = client.apply_binding(binding,
- authn_req_signed, location,
- sign=True,
- sigalg=sig_alg,
- relay_state = relay_state)
- return dict(http_response = http_info,
- authn_request = authn_req_signed,
- relay_state = relay_state,
- session_id = authn_req.id
- )
+ return HttpResponse(
+ f"LOGGED OUT: LOGIN"
+ )
-def spid_login(request,
- config_loader_path=None,
- wayf_template='wayf.html',
- authorization_error_template='djangosaml2/auth_error.html'):
+def spid_login(request, config_loader_path=None, wayf_template='wayf.html',
+ authorization_error_template='djangosaml2/auth_error.html'):
"""SAML Authorization Request initiator
This view initiates the SAML2 Authorization handshake
using the pysaml2 library to create the AuthnRequest.
It uses the SAML 2.0 Http POST protocol binding.
"""
-
logger.debug('SPID Login process started')
+
next_url = request.GET.get('next', settings.LOGIN_REDIRECT_URL)
if not next_url:
logger.warning('The next parameter exists but is empty')
@@ -158,18 +74,23 @@ def spid_login(request,
# Ensure the user-originating redirection url is safe.
if not validate_referral_url(request, next_url):
next_url = settings.LOGIN_REDIRECT_URL
-
+
if request.user.is_authenticated:
- redirect_authenticated_user = getattr(settings,
- 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN',
- True)
+ redirect_authenticated_user = getattr(
+ settings,
+ 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN',
+ True
+ )
if redirect_authenticated_user:
return HttpResponseRedirect(next_url)
- else: # pragma: no cover
+ else: # pragma: no cover
logger.debug('User is already logged in')
- return render(request, authorization_error_template, {
- 'came_from': next_url})
-
+ return render(
+ request,
+ authorization_error_template,
+ {'came_from': next_url}
+ )
+
# this works only if request came from wayf
selected_idp = request.GET.get('idp', None)
@@ -179,69 +100,73 @@ def spid_login(request,
idps = available_idps(conf)
if selected_idp is None and len(idps) > 1:
logger.debug('A discovery process is needed')
- return render(request, wayf_template,
- {
- 'available_idps': idps.items(),
- 'next_url': next_url
- }
- )
+ return render(request, wayf_template, {
+ 'available_idps': idps.items(),
+ 'next_url': next_url
+ })
else:
# otherwise is the first one
_msg = 'Unable to know which IdP to use'
try:
selected_idp = selected_idp or list(idps.keys())[0]
- except TypeError as e: # pragma: no cover
+ except TypeError as e: # pragma: no cover
logger.error(f'{_msg}: {e}')
- return HttpResponseError(_msg)
- except IndexError as e: # pragma: no cover
+ return HttpResponseNotFound(_msg)
+ except IndexError as e: # pragma: no cover
logger.error(f'{_msg}: {e}')
return HttpResponseNotFound(_msg)
-
- binding = settings.SPID_DEFAULT_BINDING
- logger.debug(f'Trying binding {binding} for IDP {selected_idp}')
+
# ensure our selected binding is supported by the IDP
- supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf)
- if binding not in supported_bindings:
+ logger.debug(
+ f'Trying binding {SPID_DEFAULT_BINDING} for IDP {selected_idp}'
+ )
+ supported_bindings = get_idp_sso_supported_bindings(
+ selected_idp,
+ config=conf
+ )
+ if not supported_bindings:
+ _msg = 'IdP Metadata not found or not valid'
+ return HttpResponseNotFound(_msg)
+
+ if SPID_DEFAULT_BINDING not in supported_bindings:
_msg = (
- f"Requested: {binding} but the selected "
- f"IDP [{selected_idp}] doesn't support "
- f"{BINDING_HTTP_POST} or {BINDING_HTTP_REDIRECT}. "
- f"Check if IdP Metadata is correctly loaded and updated."
+ f"Requested: {SPID_DEFAULT_BINDING} but the selected "
+ f"IDP [{selected_idp}] doesn't support "
+ f"{BINDING_HTTP_POST} or {BINDING_HTTP_REDIRECT}. "
+ f"Check if IdP Metadata is correctly loaded and updated."
)
logger.error(_msg)
raise UnsupportedBinding(_msg)
# SPID things here
try:
- login_response = spid_sp_authn_request(conf,
- selected_idp,
- binding,
- settings.SPID_NAMEID_FORMAT,
- settings.SPID_AUTH_CONTEXT,
- settings.SPID_SIG_ALG,
- settings.SPID_DIG_ALG,
- next_url
- )
- except UnknownSystemEntity as e: # pragma: no cover
+ login_response = spid_sp_authn_request(
+ conf,
+ selected_idp,
+ next_url
+ )
+ except UnknownSystemEntity as e: # pragma: no cover
_msg = f'Unknown IDP Entity ID: {selected_idp}'
logger.error(f'{_msg}: {e}')
return HttpResponseNotFound(_msg)
-
+
session_id = login_response['session_id']
http_response = login_response['http_response']
-
+
# success, so save the session ID and return our response
- logger.debug(f'Saving session-id {session_id} in the OutstandingQueries cache')
+ logger.debug(
+ f'Saving session-id {session_id} in the OutstandingQueries cache'
+ )
oq_cache = OutstandingQueriesCache(request.saml_session)
oq_cache.set(session_id, next_url)
-
- if binding == saml2.BINDING_HTTP_POST:
+
+ if SPID_DEFAULT_BINDING == saml2.BINDING_HTTP_POST:
return HttpResponse(http_response['data'])
- elif binding == saml2.BINDING_HTTP_REDIRECT:
+ elif SPID_DEFAULT_BINDING == saml2.BINDING_HTTP_REDIRECT:
headers = dict(login_response['http_response']['headers'])
return HttpResponseRedirect(headers['Location'])
-
-
+
+
@login_required
def spid_logout(request, config_loader_path=None, **kwargs):
"""SAML Logout Request initiator
@@ -251,27 +176,29 @@ def spid_logout(request, config_loader_path=None, **kwargs):
"""
state = StateCache(request.saml_session)
conf = get_config(config_loader_path, request)
- client = Saml2Client(conf, state_cache=state,
- identity_cache=IdentityCache(request.saml_session))
-
+ client = Saml2Client(
+ conf,
+ state_cache=state,
+ identity_cache=IdentityCache(request.saml_session)
+ )
+
# whatever happens, however, the user will be logged out of this sp
auth.logout(request)
state.sync()
-
- subject_id = _get_subject_id(request.saml_session)
+
+ subject_id = djangosaml2_views._get_subject_id(request.saml_session)
if subject_id is None:
logger.warning(
- 'The session does not contain the subject id for user %s',
- request.user)
- logger.error("Looks like the user %s is not logged in any IdP/AA", subject_id)
+ f'The session does not contain the subject id for user {request.user}'
+ )
+ logger.error(
+ f"Looks like the user {subject_id} is not logged in any IdP/AA"
+ )
return HttpResponseBadRequest("You are not logged in any IdP/AA")
-
+
slo_req = saml2.samlp.LogoutRequest()
- binding = settings.SPID_DEFAULT_BINDING
- location_fixed = subject_id.name_qualifier
- location = location_fixed
- slo_req.destination = location_fixed
+ slo_req.destination = subject_id.name_qualifier
# spid-testenv2 preleva l'attribute consumer service dalla authnRequest (anche se questo sta già nei metadati...)
slo_req.attribute_consuming_service_index = "0"
@@ -283,20 +210,22 @@ def spid_logout(request, config_loader_path=None, **kwargs):
# message id
slo_req.id = saml2.s_utils.sid()
- slo_req.version = saml2.VERSION # "2.0"
+ slo_req.version = saml2.VERSION # "2.0"
slo_req.issue_instant = saml2.time_util.instant()
# oggetto
slo_req.name_id = subject_id
-
+
try:
- session_info = client.users.get_info_from(slo_req.name_id,
- subject_id.name_qualifier,
- False)
+ session_info = client.users.get_info_from(
+ slo_req.name_id,
+ subject_id.name_qualifier,
+ False
+ )
except KeyError as e:
logger.error(f'SPID Logout error: {e}')
return HttpResponseRedirect('/')
-
+
session_indexes = [session_info['session_index']]
# aggiungere session index
@@ -309,215 +238,87 @@ def spid_logout(request, config_loader_path=None, **kwargs):
sis.append(saml2.samlp.SessionIndex(text=si))
slo_req.session_index = sis
- slo_req.protocol_binding = binding
+ slo_req.protocol_binding = SPID_DEFAULT_BINDING
assertion_consumer_service_url = client.config._sp_endpoints['assertion_consumer_service'][0][0]
slo_req.assertion_consumer_service_url = assertion_consumer_service_url
- slo_req_signed = client.sign(slo_req, sign_prepare=False,
- sign_alg=settings.SPID_SIG_ALG,
- digest_alg=settings.SPID_DIG_ALG)
- session_id = slo_req.id
+ slo_req_signed = client.sign(
+ slo_req,
+ sign_prepare=False,
+ sign_alg=settings.SPID_SIG_ALG,
+ digest_alg=settings.SPID_DIG_ALG
+ )
_req_str = slo_req_signed
- logger.debug('LogoutRequest to {}: {}'.format(subject_id.name_qualifier,
- repr_saml(_req_str)))
-
- # get slo from metadata
- slo_location = None
- # for k,v in client.metadata.metadata.items():
- # idp_nq = v.entity.get(subject_id.name_qualifier)
- # if idp_nq:
- # slo_location = idp_nq['idpsso_descriptor'][0]['single_logout_service'][0]['location']
-
- slo_location = client.metadata.single_logout_service(subject_id.name_qualifier,
- binding,
- "idpsso")[0]['location']
+ logger.debug(
+ f'LogoutRequest to {subject_id.name_qualifier}: {repr_saml(_req_str)}'
+ )
+
+ slo_location = client.metadata.single_logout_service(
+ subject_id.name_qualifier,
+ SPID_DEFAULT_BINDING,
+ "idpsso"
+ )[0]['location']
+
if not slo_location:
- logger.error('Unable to know SLO endpoint in {}'.format(subject_id.name_qualifier))
- return HttpResponse(text_type(e))
-
- http_info = client.apply_binding(binding,
- _req_str,
- slo_location,
- sign=True,
- sigalg=settings.SPID_SIG_ALG
+ error_message = f'Unable to know SLO endpoint in {subject_id.name_qualifier}'
+ logger.error(error_message)
+ return HttpResponse(error_message)
+
+ http_info = client.apply_binding(
+ SPID_DEFAULT_BINDING,
+ _req_str,
+ slo_location,
+ sign=True,
+ sigalg=settings.SPID_SIG_ALG
)
state.sync()
return HttpResponse(http_info['data'])
-def spid_sp_metadata(conf):
- metadata = entity_descriptor(conf)
-
- # this will renumber acs starting from 0 and set index=0 as is_default
- cnt = 0
- for attribute_consuming_service in metadata.spsso_descriptor.attribute_consuming_service:
- attribute_consuming_service.index = str(cnt)
- cnt += 1
-
- cnt = 0
- for assertion_consumer_service in metadata.spsso_descriptor.assertion_consumer_service:
- assertion_consumer_service.is_default = 'true' if not cnt else ''
- assertion_consumer_service.index = str(cnt)
- cnt += 1
-
- # nameformat patch... non proprio standard
- for reqattr in metadata.spsso_descriptor.attribute_consuming_service[0].requested_attribute:
- reqattr.name_format = None #"urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
- # reqattr.is_required = None
- reqattr.friendly_name = None
-
- # remove unecessary encryption and digest algs
- # supported_algs = ['http://www.w3.org/2009/xmldsig11#dsa-sha256',
- # 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256']
- # new_list = []
- # for alg in metadata.extensions.extension_elements:
- # if alg.attributes.get('Algorithm') in supported_algs:
- # new_list.append(alg)
- # metadata.extensions.extension_elements = new_list
-
- # ... Piuttosto non devo specificare gli algoritmi di firma/criptazione...
- metadata.extensions = None
-
- # attribute consuming service service name patch
- service_name = metadata.spsso_descriptor.attribute_consuming_service[0].service_name[0]
- service_name.lang = 'it'
- service_name.text = conf._sp_name
-
- ##############
- # avviso 29 v3
- #
- # https://www.agid.gov.it/sites/default/files/repository_files/spid-avviso-n29v3-specifiche_sp_pubblici_e_privati_0.pdf
- saml2.md.SamlBase.register_prefix(settings.SPID_PREFIXES)
-
- contact_map = settings.SPID_CONTACTS
- metadata.contact_person = []
- for contact in contact_map:
- spid_contact = saml2.md.ContactPerson()
- spid_contact.contact_type = contact['contact_type']
- contact_kwargs = {
- 'email_address' : [contact['email_address']],
- 'telephone_number' : [contact['telephone_number']]
- }
- if contact['contact_type'] == 'other':
- spid_contact.loadd(contact_kwargs)
- contact_kwargs['contact_type'] = contact['contact_type']
- spid_extensions = saml2.ExtensionElement(
- 'Extensions',
- namespace='urn:oasis:names:tc:SAML:2.0:metadata'
- )
- for k,v in contact.items():
- if k in contact_kwargs: continue
- ext = saml2.ExtensionElement(
- k,
- namespace=settings.SPID_PREFIXES['spid'],
- text=v
- )
- spid_extensions.children.append(ext)
-
- elif contact['contact_type'] == 'billing':
- contact_kwargs['company'] = contact['company']
- spid_contact.loadd(contact_kwargs)
- spid_extensions = saml2.ExtensionElement(
- 'Extensions',
- namespace='urn:oasis:names:tc:SAML:2.0:metadata'
- )
-
- elements = {}
- for k,v in contact.items():
- if k in contact_kwargs: continue
- ext = saml2.ExtensionElement(
- k,
- namespace=settings.SPID_PREFIXES['fpa'],
- text=v
- )
- elements[k] = ext
-
- # DatiAnagrafici
- IdFiscaleIVA = saml2.ExtensionElement(
- 'IdFiscaleIVA',
- namespace=settings.SPID_PREFIXES['fpa'],
- )
- Anagrafica = saml2.ExtensionElement(
- 'Anagrafica',
- namespace=settings.SPID_PREFIXES['fpa'],
- )
- Anagrafica.children.append(elements['Denominazione'])
-
- IdFiscaleIVA.children.append(elements['IdPaese'])
- IdFiscaleIVA.children.append(elements['IdCodice'])
- DatiAnagrafici = saml2.ExtensionElement(
- 'DatiAnagrafici',
- namespace=settings.SPID_PREFIXES['fpa'],
- )
- if elements.get('CodiceFiscale'):
- DatiAnagrafici.children.append(elements['CodiceFiscale'])
- DatiAnagrafici.children.append(IdFiscaleIVA)
- DatiAnagrafici.children.append(Anagrafica)
- CessionarioCommittente = saml2.ExtensionElement(
- 'CessionarioCommittente',
- namespace=settings.SPID_PREFIXES['fpa'],
- )
- CessionarioCommittente.children.append(DatiAnagrafici)
-
- # Sede
- Sede = saml2.ExtensionElement(
- 'Sede',
- namespace=settings.SPID_PREFIXES['fpa'],
- )
- Sede.children.append(elements['Indirizzo'])
- Sede.children.append(elements['NumeroCivico'])
- Sede.children.append(elements['CAP'])
- Sede.children.append(elements['Comune'])
- Sede.children.append(elements['Provincia'])
- Sede.children.append(elements['Nazione'])
- CessionarioCommittente.children.append(Sede)
-
- spid_extensions.children.append(CessionarioCommittente)
-
- spid_contact.extensions = spid_extensions
- metadata.contact_person.append(spid_contact)
- #
- # fine avviso 29v3
- ###################
-
- # metadata signature
- secc = security_context(conf)
- sign_dig_algs = dict(sign_alg = conf._sp_signing_algorithm,
- digest_alg = conf._sp_digest_algorithm)
- eid, xmldoc = sign_entity_descriptor(metadata, None, secc, **sign_dig_algs)
- return xmldoc
-
-
def metadata_spid(request, config_loader_path=None, valid_for=None):
"""Returns an XML with the SAML 2.0 metadata for this
SP as configured in the settings.py file.
"""
conf = get_config(config_loader_path, request)
xmldoc = spid_sp_metadata(conf)
- return HttpResponse(content=str(xmldoc).encode('utf-8'),
- content_type="text/xml; charset=utf8")
+ return HttpResponse(content=str(xmldoc).encode('utf-8'), content_type="text/xml; charset=utf8")
-class EchoAttributesView(LoginRequiredMixin, SPConfigMixin, View):
- """Example view that echo the SAML attributes of an user
- """
+class EchoAttributesView(LoginRequiredMixin, djangosaml2_views.SPConfigMixin, djangosaml2_views.View):
+ """Example view that echo the SAML attributes of an user"""
def get(self, request, *args, **kwargs):
state, client = self.get_state_client(request)
- subject_id = _get_subject_id(request.saml_session)
+ subject_id = djangosaml2_views._get_subject_id(request.saml_session)
try:
- identity = client.users.get_identity(subject_id,
- check_not_on_or_after=False)
+ identity = client.users.get_identity(subject_id, check_not_on_or_after=False)
except AttributeError:
return HttpResponse(
"No active SAML identity found. "
"Are you sure you have logged in via SAML?"
)
- return render(request,
- 'spid_echo_attributes.html',
- {'attributes': identity[0]}
+ return render(
+ request,
+ 'spid_echo_attributes.html',
+ {'attributes': identity[0]}
)
+
+
+class AssertionConsumerServiceView(djangosaml2_views.AssertionConsumerServiceView):
+ def handle_acs_failure(self, request, exception=None, status=403, **kwargs):
+ return render(
+ request,
+ 'spid_login_error.html', {
+ 'exception': exception,
+ 'spid_anomaly': SpidAnomaly.from_saml2_exception(exception)
+ },
+ status=status
+ )
+
+
+class LogoutView(djangosaml2_views.LogoutView):
+ pass
diff --git a/example/spid_config/__init__.py b/tests/__init__.py
similarity index 100%
rename from example/spid_config/__init__.py
rename to tests/__init__.py
diff --git a/tests/certificates/private.key b/tests/certificates/private.key
new file mode 100644
index 0000000..96376da
--- /dev/null
+++ b/tests/certificates/private.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIU8VNwN7pRxmP
+5LoELxanu9bL8+jIoQwRfJIA/NTFLSW7Znhp70IFIHxgbdRv5GeSA+EXVxaQz5ln
+sXXlxYnDX/oVXCaFxw+YI5Jm6qpw/if9+aSv3y1bsKp3zyGUkPiaLdfVFz/VuZZv
+z8izjATU1EKAT19XKTdgG/SjGgYczouSL5MguGd/oWnzn5TpyjGWk6NyV6yVHcqW
+2QoSzhfqOtqUedzRAC+u946ZQteNswcJuJWJ6+uyziLrrE6MphwP+ALyWkUDaV/7
+Yg2+ang8/z051XXFeckQ5L7Y/o60Ga53saWFVqk7f8wfE56kqnnNaPP5cio2YVdi
+jsM4MixxAgMBAAECggEAMgg6DuFMyxZm2/lUPBdGoT3Yt7eDPBh82yExle2Pdm+A
+LP26tTp8Uqt6ZNsJY6i39U/it+GYUTKILc20lF5xucoOu6b4OBEvY9/+gJW7W90e
+P+BJsWMcAPpumN2ylVhfvqIUdbQIzWg8mlBa3/zod/9LXKB2P16b5fUVdGbbf/Xk
+5Ugo8YABE9Gz9hVCuQfz81UM++rWr9mA4rPFhMu9Zv3N5UA0DGQ6caBAPV0stUsW
+B8yGHm0zvxvCCxNQ4mC+W8ZazVd0lrYR2b+jcl/f0YMigbz4Jk4ebYjOEvsyBqA9
+vXw7SM1YyQHGR0SeTLq0NMWtQ9G3wL47Dfd33JlRwQKBgQDlQLw/8p8GC1W3UgSH
+QM5dkdCVstMqlV1lsRgJ+eigHIVJ19qgBAgX2va43TUsdGZQYhanT6RsiwAYTvar
+gzuCL7fSklLqkR6Us42KDEqVjQUMeqYSCacfDMzNC3+ELMxX6pnyY3ovMO8WA6X+
+eGEWAh3fiQSiSj4HOJAIKRZtbwKBgQDfsxeDU37wiMWyAk5XQjdu/l1zFPqVGcYj
+ZI4SezkD5c7DfRb1iMXv8p21fYhhfQZ3zGDmTH7ER9gIgNk8GOf2RUaXqxEVKE2p
+wCyk8mKqnGl8fqFYq/Wf0u65ECqhZbvguLq4zyVF5BzcALtdVi/ZqttwYW3Ztr4H
+pxPpClXUHwKBgAcuePcz3YFt93hvrE6kXBKYT8Vwvaa22R8nZg5h8sSZQB+pEGM0
+3SAKLvJpk5HZ756TBAyntQnlbNJWHuoOiV2xqvuAs/I/K4sS+NsbOXbn3QGgEfW8
+sayKVRwTQSJd2OTkJ4BtV1WFHeg9owSOttPeqxrmiuuekcTeI7zttJMnAoGABaOK
+EDFmncGU2ivcta5hn1aiHGiG+IMxz2qVejnI8iQ01hCtJ3tPIgFHoG+NpId2RkbM
+moGLIH9/HpfA4hbuofKVGPWi9JmTe5fwiCfj/ND8h7rZblbHVkQG6HtT18Wsurlt
+W4M9OUnKwHD0SCIICsCXz3llP3uvxpmbkuBD0ZkCgYEAlTRGxu+u+dnBUirQmN9Z
+7yp2V+3XNSSgos+XsVWLKXZTmQcmfjlvwvYMO44MLhWnGIg6KisPBqphjjwF4pko
+0bGZTJRfuAU8XeqmJKJGl2D0Xqxe1mgvHC4etRiDfJsAmKRrFINCWoOf6mCdUcb9
+j0guDQ4G4u0ELH3VsWBsP8k=
+-----END PRIVATE KEY-----
diff --git a/tests/certificates/public.cert b/tests/certificates/public.cert
new file mode 100644
index 0000000..9c1c9b4
--- /dev/null
+++ b/tests/certificates/public.cert
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDazCCAlOgAwIBAgIUd3m78rxZI604Wb78Ofm0/N/ijNEwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTAyMTMxMTQ4MjVaFw0zMTAy
+MTExMTQ4MjVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDIU8VNwN7pRxmP5LoELxanu9bL8+jIoQwRfJIA/NTF
+LSW7Znhp70IFIHxgbdRv5GeSA+EXVxaQz5lnsXXlxYnDX/oVXCaFxw+YI5Jm6qpw
+/if9+aSv3y1bsKp3zyGUkPiaLdfVFz/VuZZvz8izjATU1EKAT19XKTdgG/SjGgYc
+zouSL5MguGd/oWnzn5TpyjGWk6NyV6yVHcqW2QoSzhfqOtqUedzRAC+u946ZQteN
+swcJuJWJ6+uyziLrrE6MphwP+ALyWkUDaV/7Yg2+ang8/z051XXFeckQ5L7Y/o60
+Ga53saWFVqk7f8wfE56kqnnNaPP5cio2YVdijsM4MixxAgMBAAGjUzBRMB0GA1Ud
+DgQWBBSZSKqD+zrKer2AUm4JGZNYjKKqEjAfBgNVHSMEGDAWgBSZSKqD+zrKer2A
+Um4JGZNYjKKqEjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBG
+qk8+W0CaPoYMp56q1svtQsQh3KGz2mwEjEJyGfyGYaQ8cBKY9yeSn55Hq+xJPDUg
+BJr7kPMWOfttAEHJZk9z/Y4KpGEn3Z1FPIZKkxdItUqEGwI13YjV+gmC27nqmuk0
+qdfO6rWYL90198q7u6KO5hqRLXR7ljdPXmiZ3qHHkAvnACQHzGU1UIl5I+PBQE8G
+cJqOE3YbO6n3f+4Grsjp2BJ1BkAH7eWvKDGhrmS0AfYDNDFfd0aFVT74w3I8aPaZ
+wyIz9+qYKkEhkJeaRgGCSGzKjcKxiurBcAvqxyIfpCdSOtN7Grw5Cp6Qd/jB46Zg
+TAturvWjLOqWHMULAdoq
+-----END CERTIFICATE-----
diff --git a/tests/metadata/satosa-spid.xml b/tests/metadata/satosa-spid.xml
new file mode 100644
index 0000000..3acecf9
--- /dev/null
+++ b/tests/metadata/satosa-spid.xml
@@ -0,0 +1,55 @@
+Authentication ProxyAuthentication ProxyAuthentication Proxy IdP ITAuthentication Proxy IdP ENhttps://www.spid.gov.it/assets/img/spid-ico-circle-bb.svghttps://www.example.org/privacy/MIIE/TCCA2WgAwIBAgIUEeBEsc8jIBXCMut2yzpO2ZjqgMQwDQYJKoZIhvcNAQEM
+BQAwgasxGzAZBgNVBAMMElNQSUQgZXhhbXBsZSBwcm94eTELMAkGA1UEBhMCSVQx
+DTALBgNVBAcMBFJvbWExFTATBgNVBGEMDFBBOklULWNfaDUwMTEbMBkGA1UECgwS
+U1BJRCBleGFtcGxlIHByb3h5MRMwEQYDVQQFEwoxMjM0NTY3ODkwMScwJQYDVQRT
+DB5odHRwczovL3NwaWQucHJveHkuZXhhbXBsZS5vcmcwHhcNMjEwMTI3MTc1MzIw
+WhcNNDEwMTIyMTc1MzIwWjCBqzEbMBkGA1UEAwwSU1BJRCBleGFtcGxlIHByb3h5
+MQswCQYDVQQGEwJJVDENMAsGA1UEBwwEUm9tYTEVMBMGA1UEYQwMUEE6SVQtY19o
+NTAxMRswGQYDVQQKDBJTUElEIGV4YW1wbGUgcHJveHkxEzARBgNVBAUTCjEyMzQ1
+Njc4OTAxJzAlBgNVBFMMHmh0dHBzOi8vc3BpZC5wcm94eS5leGFtcGxlLm9yZzCC
+AaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBALZH4l/Pd4r5iJIct18/tMTx
+1y8vMfVLEuMofiPvASoekqb86VzRQOUV5Cm5cxYa0Cpai8wN0hFn67XayvwhbqW6
+1g9CwnJRrXbriHze955RlktIX5qKsGQQl5yc7GeKrJOT6i71x9UDxCaHZaHBaiWO
+B2bq3Ikfi/8d0h78/OYyc9K7+n6qHHkvh8MM+FmtKwWUhwNGIh1mKb3VCJUWfzu9
+G81x/oxAD++ev0yN8HKOZBhSEq+vPOJrnBXnNg1DBIBL9fXo/zXfC424Lm9NPdE4
+vmJI6mFC7J+xCeLC3rEDcZspPNp8KerdcmRzLH1gVn7qF0ieERYbr0SaiN4cFaTl
+3WTGVDIlV5Hh0P/7dmf+IeeTFNsoI1i6sqiIsgV3YwEY0NhwGqNcnTy8ryQZMO9+
+OFnJGNvrCn7Jpe6DF1rPVYCvFnK+dZnBIqNct19znE0BKolsNmiNiOqH9ji3Ro+7
+h6xvUnKnuvHoLcmZ24HPF5dCFDk5KQNLCCQxNbwVpQIDAQABoxcwFTATBgNVHSAE
+DDAKMAgGBitMEAQCATANBgkqhkiG9w0BAQwFAAOCAYEAFj3rgGgZjUY9692iEMpU
+83OtzXsBB2jQoSX68xLetsvSADi0vJBUBaiHrfncE90XWGl8IN+0wdhMidgMw/Ji
+sKp+GyVN9NEsdvW6QBwPAg6P6Af59MEv2nKWCvlld+inRvTFKl/xLlINPtZH46VF
+rInQOnOhvAmuTnitZm8HD0Rm88wMmNG9ja9e1L9hv67RADtoDnmXO9J+tfzW98Gg
+iNtp9pmS/zioEdZJ7hSHWsUTfoKs+1tOcyQWnzlTfLYYbaENnJcEpUFetVc/SKYK
+egZQrNt5S/5LVWd1mNvgR4JE0yl832B+2LiNGYsqeazR/ncQbFekrTvGHwpOA6br
+D03lKQwBOv+xP9vyx1CCvQAXMmjqew4cH5VVVoSYl1km7TZ00NM56eqyIypKX/6X
+QVcTY49wESVbJ4SQdkJUYXiaY/MVuJ64AqJZsQdV/3ckf9jR3JwzaTVfraHWfRhO
+Nv9pHQdOVXURgMa070OZDrKfFvOQH3iBIAB2QhHFNtXz
+MIIE/TCCA2WgAwIBAgIUEeBEsc8jIBXCMut2yzpO2ZjqgMQwDQYJKoZIhvcNAQEM
+BQAwgasxGzAZBgNVBAMMElNQSUQgZXhhbXBsZSBwcm94eTELMAkGA1UEBhMCSVQx
+DTALBgNVBAcMBFJvbWExFTATBgNVBGEMDFBBOklULWNfaDUwMTEbMBkGA1UECgwS
+U1BJRCBleGFtcGxlIHByb3h5MRMwEQYDVQQFEwoxMjM0NTY3ODkwMScwJQYDVQRT
+DB5odHRwczovL3NwaWQucHJveHkuZXhhbXBsZS5vcmcwHhcNMjEwMTI3MTc1MzIw
+WhcNNDEwMTIyMTc1MzIwWjCBqzEbMBkGA1UEAwwSU1BJRCBleGFtcGxlIHByb3h5
+MQswCQYDVQQGEwJJVDENMAsGA1UEBwwEUm9tYTEVMBMGA1UEYQwMUEE6SVQtY19o
+NTAxMRswGQYDVQQKDBJTUElEIGV4YW1wbGUgcHJveHkxEzARBgNVBAUTCjEyMzQ1
+Njc4OTAxJzAlBgNVBFMMHmh0dHBzOi8vc3BpZC5wcm94eS5leGFtcGxlLm9yZzCC
+AaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBALZH4l/Pd4r5iJIct18/tMTx
+1y8vMfVLEuMofiPvASoekqb86VzRQOUV5Cm5cxYa0Cpai8wN0hFn67XayvwhbqW6
+1g9CwnJRrXbriHze955RlktIX5qKsGQQl5yc7GeKrJOT6i71x9UDxCaHZaHBaiWO
+B2bq3Ikfi/8d0h78/OYyc9K7+n6qHHkvh8MM+FmtKwWUhwNGIh1mKb3VCJUWfzu9
+G81x/oxAD++ev0yN8HKOZBhSEq+vPOJrnBXnNg1DBIBL9fXo/zXfC424Lm9NPdE4
+vmJI6mFC7J+xCeLC3rEDcZspPNp8KerdcmRzLH1gVn7qF0ieERYbr0SaiN4cFaTl
+3WTGVDIlV5Hh0P/7dmf+IeeTFNsoI1i6sqiIsgV3YwEY0NhwGqNcnTy8ryQZMO9+
+OFnJGNvrCn7Jpe6DF1rPVYCvFnK+dZnBIqNct19znE0BKolsNmiNiOqH9ji3Ro+7
+h6xvUnKnuvHoLcmZ24HPF5dCFDk5KQNLCCQxNbwVpQIDAQABoxcwFTATBgNVHSAE
+DDAKMAgGBitMEAQCATANBgkqhkiG9w0BAQwFAAOCAYEAFj3rgGgZjUY9692iEMpU
+83OtzXsBB2jQoSX68xLetsvSADi0vJBUBaiHrfncE90XWGl8IN+0wdhMidgMw/Ji
+sKp+GyVN9NEsdvW6QBwPAg6P6Af59MEv2nKWCvlld+inRvTFKl/xLlINPtZH46VF
+rInQOnOhvAmuTnitZm8HD0Rm88wMmNG9ja9e1L9hv67RADtoDnmXO9J+tfzW98Gg
+iNtp9pmS/zioEdZJ7hSHWsUTfoKs+1tOcyQWnzlTfLYYbaENnJcEpUFetVc/SKYK
+egZQrNt5S/5LVWd1mNvgR4JE0yl832B+2LiNGYsqeazR/ncQbFekrTvGHwpOA6br
+D03lKQwBOv+xP9vyx1CCvQAXMmjqew4cH5VVVoSYl1km7TZ00NM56eqyIypKX/6X
+QVcTY49wESVbJ4SQdkJUYXiaY/MVuJ64AqJZsQdV/3ckf9jR3JwzaTVfraHWfRhO
+Nv9pHQdOVXURgMa070OZDrKfFvOQH3iBIAB2QhHFNtXz
+urn:oasis:names:tc:SAML:2.0:nameid-format:transientproxy.authSaml2 Authentication Proxyhttps://spid.proxy.example.orgTechnicalmailto:supporto.tecnico@example.orgSupportmailto:richieste.ict@example.org
\ No newline at end of file
diff --git a/tests/metadata/spid-sp-test.xml b/tests/metadata/spid-sp-test.xml
new file mode 100644
index 0000000..f31998a
--- /dev/null
+++ b/tests/metadata/spid-sp-test.xml
@@ -0,0 +1,20 @@
+MIIDazCCAlOgAwIBAgIUHp2NGE+RVT0mHSUGtf6i/JCoJ7AwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTAyMjExNzQzMjhaFw0zMTAy
+MTkxNzQzMjhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDOiWsrKkD3NOUqssF0tO8dbfcxdH1G2Pjtz6BTd1+3
++S/eK+MEcme9PjJXktFxQDUXiVABKjmZvCTL3ycT7wUK+/mz5cahiOijR0sSAK11
+0h7e0SNH3zM9e+nfABBOY4rN6/C80X6lyrblaPVrJhJv4JxWOohXEzaYNBYt18qI
+2iClvgEl5MUfSUu34Xhk5ucGnM8kABXVWo5Sx86o4BmUm7VWj2BV2hfZUOR8fiw2
+TpYkbDnFhfeC8Qi5Sc0XhWJnRm1Yf7AF3037hzRZurGNsJYqlBHD64c8cLIFPuQv
+aDSEPiqAYJe2nmbkCt31CR4N6XiWM44mzZKAEpecwN5xAgMBAAGjUzBRMB0GA1Ud
+DgQWBBTLGjnN4QqN2bIXdFA2BeVHJjyu/TAfBgNVHSMEGDAWgBTLGjnN4QqN2bIX
+dFA2BeVHJjyu/TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZ
+pf1Mg6zUS5QiCs/wHvfKWLC5IPIE7sb5Wyh+nymhx56dpMmFAg4W2Ubmw8CgkUja
+Ew7cpzneSW5H0/m6mkJXvrKMg5UjCiwiaNWtn0sVv+3wvN4D4OPlcoA0lhRCc3XU
+42h4OiNsy2iKpdsNeQwkR2/kGElPEbvH7HDfjCNhZZPMeAnaYX81RS//CuSVCXG8
+Gg/bLoWfbPB4chbv4v9Yt4fg84UsWLN9v72K5kXX9dm4z4NI7rHp5blmCDywIXzG
+TKJKtakb4h++LAM28mFVdyZJK+xF06qiVZR3277Nb0mw4t9VZS8joV0XanXPmKDY
+yy3rGbJmm3YqcGup2vYo
+Exempel ABExempel ABExample Co.http://www.example.com/rolandJohnSmithjohn.smith@example.com
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..4300aa9
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,168 @@
+"""
+djangosaml2_spid settings for running tests.
+"""
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = '-%1f#b#w9t%g%job)vd&f7pxdl!_zu%!mxx197bixh8&%*(%nb'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = ['*']
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+
+ 'djangosaml2',
+ 'djangosaml2_spid',
+ 'tests',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+
+ # SAML session with SameSite = None
+ 'djangosaml2.middleware.SamlSessionMiddleware'
+]
+
+if 'djangosaml2' in INSTALLED_APPS or \
+ 'djangosaml2_spid' in INSTALLED_APPS:
+
+ AUTHENTICATION_BACKENDS = (
+ 'django.contrib.auth.backends.ModelBackend',
+ 'djangosaml2.backends.Saml2Backend',
+ )
+
+# AUTH_USER_MODEL = 'tests.custom_accounts.User'
+ROOT_URLCONF = 'tests.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+# WSGI_APPLICATION = 'tests.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+}
+
+DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
+
+# Password validation
+# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/3.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/3.1/howto/static-files/
+
+STATIC_URL = '/static/'
+
+
+# PySAML2 base settings
+SAML_CONFIG = {
+ 'entityid': 'http://localhost:8000/spid/metadata', # Only for testing, usually detected.
+
+ 'organization': {
+ 'name': [('Example', 'it'), ('Example', 'en')],
+ 'display_name': [('Example', 'it'), ('Example', 'en')],
+ 'url': [('http://www.example.it', 'it'), ('http://www.example.it', 'en')],
+ },
+}
+
+
+# SPID required settings
+SPID_CERTS_DIR = 'tests/certificates/'
+SPID_IDENTITY_PROVIDERS_METADATA_DIR = 'tests/metadata/'
+
+SPID_CONTACTS = [
+ {
+ 'contact_type': 'other',
+ 'telephone_number': '+39 8475634785',
+ 'email_address': 'tech-info@example.org',
+ 'VATNumber': 'IT12345678901',
+ 'FiscalCode': 'XYZABCAAMGGJ000W',
+ 'Private': ''
+ },
+ {
+ 'contact_type': 'billing',
+ 'telephone_number': '+39 84756344785',
+ 'email_address': 'info@example.org',
+ 'company': 'example s.p.a.',
+ # 'CodiceFiscale': 'NGLMRA80A01D086T',
+ 'IdCodice': '983745349857',
+ 'IdPaese': 'IT',
+ 'Denominazione': 'Destinatario Fatturazione',
+ 'Indirizzo': 'via tante cose',
+ 'NumeroCivico': '12',
+ 'CAP': '87100',
+ 'Comune': 'Cosenza',
+ 'Provincia': 'CS',
+ 'Nazione': 'IT',
+ },
+]
diff --git a/tests/urls.py b/tests/urls.py
new file mode 100644
index 0000000..96276b4
--- /dev/null
+++ b/tests/urls.py
@@ -0,0 +1,8 @@
+from django.contrib import admin
+from django.urls import path, include
+import djangosaml2_spid.urls
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path('', include((djangosaml2_spid.urls, 'djangosaml2_spid',))),
+]