Repository: openwisp/openwisp-radius (language: python)

Issue:
[feature] Add support for SAML registration method
We need this to add support for [SPID](https://www.spid.gov.it/) and other national identity verification methods based on the SAML protocol.

- [x] It should work in a similar way to social login (we'll probably need a similar view, maybe we can made some of that logic reusablew to avoid duplication), we'll need to pull a third party SAML library which I believe should be optional and installed by default in the test/dev environment
- [x] when enabled, we should register this registration method (like social login in https://github.com/openwisp/openwisp-radius/pull/258)
- [x] we must make sure that with this solution we can implement [SPID](https://www.spid.gov.it/)
- [ ] we may need to add an additional field to `RegisteredUser` to allow storing extra attributes and/or metadata
- [x] we need a way to configure this integration to flag `RegisteredUser.is_verified=True`, because some SAML implementations (like SPID) are valid for identity verification

For testing we may use mocks of identity provider services, a quick look on google yields some results, we should investigate more into this or look into how SAML libraries for django test this.

I would be nice to be able to configure the registration method label, so that instead of showing "Single Sign-On (SAML)" we could configure it to be something different (eg: "SPID").
[deps] dj-rest-auth==2.1.6 breaks build
URL encoding in https://github.com/iMerica/dj-rest-auth/commit/1f28787c7d137f20404b7218bfcf9d31d38cb266 was modified in dj-rest-auth. This breaks the following test

https://github.com/openwisp/openwisp-radius/blob/61ad2bdc1f6230746a37008914d2f34a4ea1227c/openwisp_radius/tests/test_api/test_api.py#L605-L615
[deps] dj-rest-auth==2.1.6 breaks build
URL encoding in https://github.com/iMerica/dj-rest-auth/commit/1f28787c7d137f20404b7218bfcf9d31d38cb266 was modified in dj-rest-auth. This breaks the following test

https://github.com/openwisp/openwisp-radius/blob/61ad2bdc1f6230746a37008914d2f34a4ea1227c/openwisp_radius/tests/test_api/test_api.py#L605-L615


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4b2019c0..49a9f4a4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,11 @@ jobs:
         with:
           ref: ${{ github.event.pull_request.head.sha }}
 
+      - name: Install system libraries
+        run: |
+          sudo apt update
+          sudo apt-get -qq -y install xmlsec1
+
       - name: Set up Python ${{ matrix.python-version }}
         uses: actions/setup-python@v2
         with:
@@ -44,7 +49,7 @@ jobs:
         run: sudo npm install -g jslint
 
       - name: Install openwisp-radius
-        run: pip install -e .
+        run: pip install -e .[saml]
 
       - name: QA checks
         run: |
diff --git a/MANIFEST.in b/MANIFEST.in
index 9b8af4ab..3e984a3e 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,7 +1,6 @@
 include LICENSE
 include README.rst
 include CHANGES.rst
-include requirements.txt
 recursive-include openwisp_radius *
 recursive-exclude * *.pyc
 recursive-exclude * *.swp
diff --git a/docs/source/developer/how_to_extend.rst b/docs/source/developer/how_to_extend.rst
index 8539d1a3..9a4c7894 100644
--- a/docs/source/developer/how_to_extend.rst
+++ b/docs/source/developer/how_to_extend.rst
@@ -71,6 +71,8 @@ ensuring also that ``openwisp_radius`` has been removed:
         # social login
         'allauth.socialaccount.providers.facebook',  # optional, can be removed if social login is not needed
         'allauth.socialaccount.providers.google',  # optional, can be removed if social login is not needed
+        # SAML login
+        'djangosaml2',  # optional, can be removed if SAML login is not needed
         # openwisp
         # 'myradius', <-- replace with your app-name here
         'openwisp_users',
@@ -82,6 +84,11 @@ ensuring also that ``openwisp_radius`` has been removed:
     MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
     PRIVATE_STORAGE_ROOT = os.path.join(MEDIA_ROOT, 'private')
 
+    AUTHENTICATION_BACKENDS = (
+        'openwisp_users.backends.UsersAuthenticationBackend',
+        'djangosaml2.backends.Saml2Backend', # optional, can be removed if SAML login is not needed
+    )
+
 .. important::
     Remember to include your radius app's name before proceeding.
 
@@ -96,6 +103,9 @@ Install (and add to the requirement of your project) openwisp-radius::
 
     pip install openwisp-radius
 
+.. note::
+    Use ``pip install openwisp-radius[saml]`` if you intend to use
+    `Single Sign-On (SAML) <../user/saml.html>`_ feature.
 
 3. Add ``EXTENDED_APPS``
 ------------------------
@@ -106,7 +116,6 @@ Add the following to your ``settings.py``:
 
     EXTENDED_APPS = ('openwisp_radius',)
 
-
 4. Add ``openwisp_utils.staticfiles.DependencyFinder``
 ------------------------------------------------------
 
@@ -388,8 +397,9 @@ The root ``url.py`` file should have the following paths (please read the commen
 
     from openwisp_radius.urls import get_urls
     # Only imported when views are extended.
-    # from .sample_radius.api.views import views as api_views
-    # from .sample_radius.social.views import views as social_views
+    # from myradius.api.views import views as api_views
+    # from myradius.social.views import views as social_views
+    # from myradius.saml.views import views as saml_views
 
     urlpatterns = [
         # ... other urls in your project ...
@@ -398,7 +408,7 @@ The root ``url.py`` file should have the following paths (please read the commen
         path('accounts/', include('openwisp_users.accounts.urls')),
         path('api/v1/', include('openwisp_utils.api.urls')),
         # Use only when extending views (dicussed below)
-        # path('', include((get_urls(api_views, social_views), 'radius'), namespace='radius')),
+        # path('', include((get_urls(api_views, social_views, saml_views), 'radius'), namespace='radius')),
         path('', include('openwisp_radius.urls', namespace='radius')), # Remove when extending views
     ]
 .. note::
@@ -458,7 +468,20 @@ Create a view file as done in `social views.py <https://github.com/openwisp/open
 
 Remember to use these views in root URL configurations in point 11.
 If you want only extend the API views and not social views, you can use
-``get_urls(None, social_views)`` to get social_views from *openwisp_radius*.
+``get_urls(api_views, None)`` to get social_views from *openwisp_radius*.
+
+3. Extending the SAML Views
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The SAML view classes can be extended into other django applications as well. Note
+that it is not required for extending *openwisp-radius* to your app and this change
+is required only if you plan to make changes to the SAML views.
+
+Create a view file as done in `saml views.py <https://github.com/openwisp/openwisp-radius/blob/master/tests/openwisp2/sample_radius/saml/views.py>`_.
+
+Remember to use these views in root URL configurations in point 11.
+If you want only extend the API views and social view but not SAML views, you can use
+``get_urls(api_views, social_views, None)`` to get saml_views from *openwisp_radius*.
 
 .. note::
     For more information about django views, please refer to the
diff --git a/docs/source/developer/setup.rst b/docs/source/developer/setup.rst
index d3aaf228..0acf9a89 100644
--- a/docs/source/developer/setup.rst
+++ b/docs/source/developer/setup.rst
@@ -109,7 +109,7 @@ modules listed ``INSTALLED_APPS``:
     ]
 
 These modules are optional, add them only if you need the
-`social login <../user/social_login.html>`_. feature:
+`social login <../user/social_login.html>`_ feature:
 
 .. code-block:: python
 
@@ -127,12 +127,16 @@ Add media locations in ``settings.py``:
     MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
     PRIVATE_STORAGE_ROOT = os.path.join(MEDIA_ROOT, 'private')
 
-Also, add ``AUTH_USER_MODEL`` and ``SITE_ID`` to your ``settings.py``:
+Also, add ``AUTH_USER_MODEL``, ``AUTHENTICATION_BACKENDS`` and ``SITE_ID`` to
+your ``settings.py``:
 
 .. code-block:: python
 
     AUTH_USER_MODEL = 'openwisp_users.User'
     SITE_ID = 1
+    AUTHENTICATION_BACKENDS = (
+        'openwisp_users.backends.UsersAuthenticationBackend',
+    )
 
 Add allowed freeradius hosts  in ``settings.py``:
 
@@ -294,7 +298,7 @@ Install your forked repo:
 
     git clone git://github.com/<your_username>/openwisp-radius
     cd openwisp-radius/
-    python setup.py develop
+    pip install -e .[saml]
 
 Install test requirements:
 
@@ -350,6 +354,6 @@ If you encounter any issue during installation, run:
 
 .. code-block:: shell
 
-    pip install -r requirements.txt -r requirements-test.txt
+    pip install -e .[saml] -r requirements-test.txt
 
 instead of ``pip install -r requirements-test.txt``
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 6015992e..dadabde6 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -58,6 +58,7 @@ openwisp-radius
    /user/enforcing_limits
    /user/registration
    /user/social_login
+   /user/saml
    /user/api
    /developer/how_to_extend
    /developer/captive_portal_mock.rst
diff --git a/docs/source/user/saml.rst b/docs/source/user/saml.rst
new file mode 100644
index 00000000..0b20db35
--- /dev/null
+++ b/docs/source/user/saml.rst
@@ -0,0 +1,145 @@
+=====================
+Single Sign-On (SAML)
+=====================
+
+`SAML <http://saml.xml.org/about-saml>`_ is supported by generating
+an additional temporary token right after users authenticates via SSO,
+the user is then redirected to the captive page with 3 querystring
+parameters:
+
+- ``username``
+- ``token`` (REST auth token)
+- ``login_method=saml``
+
+The captive page must recognize these two parameters, validate the token
+and automatically perform the submit action of the captive portal login form:
+``username`` should obviously used for the username field,
+while ``token`` should be used for the password field.
+
+The third parameter, ``login_method=saml``, is needed because it allows
+the captive page to remember that the user logged in via SAML,
+because it will need to perform the `SAML logout <#logout>`_ later on.
+
+The internal REST API of openwisp-radius will recognize the token and authorize
+the user.
+
+This kind of implementation allows to support SAML with any captive
+portal which already supports the RADIUS protocol because it's totally transparent
+for it, that is, the captive portal doesn't even know the user is signing-in with
+a SSO.
+
+.. note::
+   If you're building a public wifi service, we suggest
+   to take a look at `openwisp-wifi-login-pages <https://github.com/openwisp/openwisp-wifi-login-pages>`_,
+   which is built to work with openwisp-radius.
+
+Setup
+-----
+
+Install required system dependencies:
+
+    sudo apt install xmlsec1
+
+Install Python dependencies::
+
+    pip install openwisp-radius[saml]
+
+Ensure your ``settings.py`` looks like the following:
+
+.. code-block:: python
+
+    INSTALLED_APPS = [
+        # ... other apps ..
+        # apps needed for SAML login
+        'rest_framework.authtoken',
+        'django.contrib.sites',
+        'allauth',
+        'allauth.account',
+        'djangosaml2'
+    ]
+
+    SITE_ID = 1
+
+    # Update AUTHENTICATION_BACKENDS
+    AUTHENTICATION_BACKENDS = (
+        'openwisp_users.backends.UsersAuthenticationBackend',
+        'djangosaml2.backends.Saml2Backend', # <- add for SAML login
+    )
+
+    # Update MIDDLEWARE
+    MIDDLEWARE = [
+        # ... other middlewares ...
+        'djangosaml2.middleware.SamlSessionMiddleware',
+    ]
+
+Ensure your main ``urls.py`` contains the ``openwisp_users.accounts.urls``:
+
+.. code-block:: python
+
+    urlpatterns = [
+        # .. other urls ...
+        path('accounts/', include('openwisp_users.accounts.urls')),
+    ]
+
+Configure the SAML Settings
+---------------------------
+
+Refer to the djangosaml2 documentation to find out `how to configure
+required settings for SAML
+<https://djangosaml2.readthedocs.io/contents/setup.html#configuration>`_.
+
+Captive page button example
+---------------------------
+
+After successfully configuring SAML settings for your Identity Provider,
+you will need an HTML button similar to the one in the following example.
+
+This example needs the slug of the organization to assign the new user to
+the right organization:
+
+.. code-block:: html
+
+    <a href="https://openwisp2.mywifiproject.com/radius/saml2/login/?RelayState=https://captivepage.mywifiproject.com%3Forg%3Ddefault"
+       class="button">
+       Log in with SSO
+    </a>
+
+Substitute ``openwisp2.mywifiproject.com``, ``https://captivepage.mywifiproject.com``
+and ``default`` with the hostname of your openwisp-radius instance, your captive
+page and the organization slug respectively.
+
+Alternatively, you can take a look at
+`openwisp-wifi-login-pages <https://github.com/openwisp/openwisp-wifi-login-pages>`_,
+which provides buttons for Single Sign-On (SAML) by default.
+
+Logout
+------
+
+When logging out a user which logged in via SAML, the captive page
+should also call the SAML logout URL: ``/radius/saml2/logout/``.
+
+The `openwisp-wifi-login-pages <https://github.com/openwisp/openwisp-wifi-login-pages>`_
+app supports this with minimal configuration, refer to the
+`"Configuring SAML Login & Logout" <https://github.com/openwisp/openwisp-wifi-login-pages##configuring-saml-login--logout>`_
+section.
+
+Settings
+--------
+
+``OPENWISP_RADIUS_SAML_REGISTRATION_METHOD_LABEL``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Default**: ``'Single Sign-On (SAML)'``
+
+Sets the verbose name of SAML registration method.
+
+``OPENWISP_RADIUS_SAML_IS_VERIFIED``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Default**: ``False``
+
+Setting this to ``True`` will automatically flag user accounts
+created during SAML sign-in as verified users (``RegisteredUser.is_verified=True``).
+
+This is useful when SAML identity providers can be trusted
+to be legally valid identity verifiers.
diff --git a/docs/source/user/settings.rst b/docs/source/user/settings.rst
index 4b201c0d..73b45664 100644
--- a/docs/source/user/settings.rst
+++ b/docs/source/user/settings.rst
@@ -531,3 +531,8 @@ The default sender email is a common setting from the
 `Django core settings  <https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email>`_
 under ``DEFAULT_FROM_EMAIL``.
 Currently, ``DEFAULT_FROM_EMAIL`` is set to to ``webmaster@localhost``.
+
+.. note::
+
+    To learn about configuring SAML Login refer to the
+    `"Settings" section of SAML Login documentation <saml.html#settings>`_
diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py
index 59f917ee..52495698 100644
--- a/openwisp_radius/api/views.py
+++ b/openwisp_radius/api/views.py
@@ -486,7 +486,7 @@ def validate_user(self, *args, **kwargs):
             try:
                 uid = url_str_to_user_pk(self.request.POST['uid'])
                 user = User.objects.get(pk=uid)
-            except (User.DoesNotExist, ValueError, ValidationError):
+            except (User.DoesNotExist, ValidationError):
                 raise Http404()
             self.validate_membership(user)
             return user
diff --git a/openwisp_radius/apps.py b/openwisp_radius/apps.py
index b252bc8a..4d59a1fb 100644
--- a/openwisp_radius/apps.py
+++ b/openwisp_radius/apps.py
@@ -47,6 +47,10 @@ def ready(self, *args, **kwargs):
 
         if app_settings.SOCIAL_LOGIN_ENABLED:
             register_registration_method('social_login', _('Social login'))
+        if app_settings.SAML_LOGIN_ENABLED:
+            register_registration_method(
+                'saml', app_settings.SAML_REGISTRATION_METHOD_LABEL
+            )
 
     def connect_signals(self):
         Organization = swapper.load_model('openwisp_users', 'Organization')
diff --git a/openwisp_radius/saml/__init__.py b/openwisp_radius/saml/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/openwisp_radius/saml/urls.py b/openwisp_radius/saml/urls.py
new file mode 100644
index 00000000..855f41bd
--- /dev/null
+++ b/openwisp_radius/saml/urls.py
@@ -0,0 +1,16 @@
+from django.urls import path
+
+from . import views
+
+
+def get_saml_urls(saml_views=None):
+    if not saml_views:
+        saml_views = views
+    return [
+        path('login/', views.LoginView.as_view(), name='saml2_login'),
+        path('acs/', views.AssertionConsumerServiceView.as_view(), name='saml2_acs'),
+        path('logout/', views.LogoutInitView.as_view(), name='saml2_logout'),
+        path('ls/', views.LogoutView.as_view(), name='saml2_ls'),
+        path('ls/post/', views.LogoutView.as_view(), name='saml2_ls_post'),
+        path('metadata/', views.MetadataView.as_view(), name='saml2_metadata'),
+    ]
diff --git a/openwisp_radius/saml/utils.py b/openwisp_radius/saml/utils.py
new file mode 100644
index 00000000..15931774
--- /dev/null
+++ b/openwisp_radius/saml/utils.py
@@ -0,0 +1,8 @@
+from urllib.parse import urlparse
+
+
+def get_url_or_path(url):
+    parsed_url = urlparse(url)
+    if parsed_url.netloc:
+        return f'{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}'
+    return parsed_url.path
diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py
new file mode 100644
index 00000000..a57ee8fe
--- /dev/null
+++ b/openwisp_radius/saml/views.py
@@ -0,0 +1,110 @@
+import logging
+from urllib.parse import parse_qs, urlparse
+
+import swapper
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.shortcuts import get_object_or_404, render
+from djangosaml2.views import (
+    AssertionConsumerServiceView as BaseAssertionConsumerServiceView,
+)
+from djangosaml2.views import LoginView as BaseLoginView
+from djangosaml2.views import LogoutInitView, LogoutView, MetadataView  # noqa
+from rest_framework.authtoken.models import Token
+
+from .. import settings as app_settings
+from ..api.views import RadiusTokenMixin
+from ..utils import load_model
+from .utils import get_url_or_path
+
+logger = logging.getLogger(__name__)
+
+Organization = swapper.load_model('openwisp_users', 'Organization')
+RadiusToken = load_model('RadiusToken')
+RegisteredUser = load_model('RegisteredUser')
+OrganizationUser = swapper.load_model('openwisp_users', 'OrganizationUser')
+
+
+class OrganizationSamlMixin(object):
+    def get_org_slug_from_relay_state(self):
+        try:
+            parsed_url = urlparse(
+                self.request.POST.get(
+                    'RelayState', self.request.GET.get('RelayState', None),
+                )
+            )
+
+            org_slug = parse_qs(parsed_url.query)['org'][0]
+        except (KeyError, IndexError):
+            raise ValueError('Organization slug not provided')
+        else:
+            return org_slug
+
+    def get_organization_from_relay_state(self):
+        org_slug = self.get_org_slug_from_relay_state()
+        return get_object_or_404(Organization, slug=org_slug)
+
+
+class AssertionConsumerServiceView(
+    OrganizationSamlMixin, RadiusTokenMixin, BaseAssertionConsumerServiceView
+):
+    def post_login_hook(self, request, user, session_info):
+        """ If desired, a hook to add logic after a user has successfully logged in.
+        """
+        org = self.get_organization_from_relay_state()
+        is_member = user.is_member(org)
+        # add user to organization
+        if not is_member:
+            orgUser = OrganizationUser(organization=org, user=user)
+            orgUser.full_clean()
+            orgUser.save()
+        try:
+            user.registered_user
+        except ObjectDoesNotExist:
+            registered_user = RegisteredUser(
+                user=user, method='saml', is_verified=app_settings.SAML_IS_VERIFIED
+            )
+            registered_user.full_clean()
+            registered_user.save()
+
+    def customize_relay_state(self, relay_state):
+        """ Subclasses may override this method to implement custom logic for relay state.
+        """
+        return get_url_or_path(relay_state)
+
+    def custom_redirect(self, user, relay_state, session_info):
+        """ Subclasses may override this method to implement custom logic for redirect.
+
+            For example, some sites may require user registration if the user has not
+            yet been provisioned.
+        """
+        Token.objects.filter(user=user).delete()
+        token, _ = Token.objects.get_or_create(user=user)
+        return (
+            f'{relay_state}?username={user.username}&token={token.key}&'
+            f'login_method=saml'
+        )
+
+
+class LoginView(OrganizationSamlMixin, BaseLoginView):
+    def load_sso_kwargs(self, sso_kwargs):
+        service_config = (
+            getattr(settings, 'SAML_CONFIG', {}).get('service', {}).get('sp', None)
+        )
+        sso_kwargs['isPassive'] = service_config.get('isPassive', False)
+        sso_kwargs['attribute_consuming_service_index'] = service_config.get(
+            'attribute_consuming_service_index', '1'
+        )
+
+    def get(self, request, *args, **kwargs):
+        # Check correct organization slug is present in the request
+        try:
+            org_slug = self.get_org_slug_from_relay_state()
+            assert Organization.objects.filter(
+                slug=org_slug
+            ).exists(), 'Organization with the provided slug does not exist'
+        except (ValueError, AssertionError) as error:
+            logger.error(str(error))
+            return render(request, 'djangosaml2/login_error.html')
+
+        return super().get(request, *args, **kwargs)
diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py
index 70bd2b2e..35c8977c 100644
--- a/openwisp_radius/settings.py
+++ b/openwisp_radius/settings.py
@@ -41,6 +41,11 @@ def get_settings_value(option, default):
 BATCH_MAIL_SENDER = get_settings_value('BATCH_MAIL_SENDER', settings.DEFAULT_FROM_EMAIL)
 API_AUTHORIZE_REJECT = get_settings_value('API_AUTHORIZE_REJECT', False)
 SOCIAL_LOGIN_ENABLED = 'allauth.socialaccount' in settings.INSTALLED_APPS
+SAML_LOGIN_ENABLED = 'djangosaml2' in settings.INSTALLED_APPS
+SAML_REGISTRATION_METHOD_LABEL = get_settings_value(
+    'SAML_REGISTRATION_METHOD_LABEL', _('Single Sign-On (SAML)')
+)
+SAML_IS_VERIFIED = get_settings_value('SAML_IS_VERIFIED', False)
 DISPOSABLE_RADIUS_USER_TOKEN = get_settings_value('DISPOSABLE_RADIUS_USER_TOKEN', True)
 API_ACCOUNTING_AUTO_GROUP = get_settings_value('API_ACCOUNTING_AUTO_GROUP', True)
 FREERADIUS_ALLOWED_HOSTS = get_settings_value('FREERADIUS_ALLOWED_HOSTS', [])
diff --git a/openwisp_radius/templates/djangosaml2/auth_error.html b/openwisp_radius/templates/djangosaml2/auth_error.html
new file mode 100644
index 00000000..083db37d
--- /dev/null
+++ b/openwisp_radius/templates/djangosaml2/auth_error.html
@@ -0,0 +1,12 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+
+{% block content %}
+    <h1>{% trans "Authorization error" %}</h1>
+    <p>{% blocktrans %}You are already logged in and you are trying to go to the login page again.{% endblocktrans %}</p>
+
+    <p>{% blocktrans %}You may have been redirected here when trying to access some content
+       that required extra privileges that you do not have.{% endblocktrans %}</p>
+
+    <p>{% trans "Please" %} <a href="{% url 'radius:saml2_logout' %}">{% trans "logout" %}</a> {% trans "and login as a different user" %}</p>
+{% endblock %}
diff --git a/openwisp_radius/templates/djangosaml2/login_error.html b/openwisp_radius/templates/djangosaml2/login_error.html
new file mode 100644
index 00000000..964b4c46
--- /dev/null
+++ b/openwisp_radius/templates/djangosaml2/login_error.html
@@ -0,0 +1,7 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+
+{% block content %}
+    <h1>{% trans "Authentication Error" %}</h1>
+    <h2>{% trans "Access Denied" %}</h2>
+{% endblock %}
diff --git a/openwisp_radius/templates/djangosaml2/logout_error.html b/openwisp_radius/templates/djangosaml2/logout_error.html
new file mode 100644
index 00000000..97c1650d
--- /dev/null
+++ b/openwisp_radius/templates/djangosaml2/logout_error.html
@@ -0,0 +1,13 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+
+{% block content %}
+    <h1>{% trans "Logout Error" %}</h1>
+    <p>{% blocktrans %}Your Identity Provider ask this system to do a global logout but your federated session is lost.{% endblocktrans %}</p>
+
+    <p>{% blocktrans %}Even if your local session in this system has been closed, you have probably open sessions in other systems.{% endblocktrans %}</p>
+
+    <p>{% blocktrans %}In order to prevent illicit use of your personal information, please close your browser window and/or remove your cookies from your browser.{% endblocktrans %}</p>
+
+    <p>{% blocktrans %}Sorry for this inconvenience.{% endblocktrans %} </p>
+{% endblock %}
diff --git a/openwisp_radius/templates/djangosaml2/wayf.html b/openwisp_radius/templates/djangosaml2/wayf.html
new file mode 100644
index 00000000..09c54eab
--- /dev/null
+++ b/openwisp_radius/templates/djangosaml2/wayf.html
@@ -0,0 +1,14 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+
+{% block content %}
+    <h1>{% trans "Where are you from?" %}</h1>
+    <p>{% trans "Please select your" %} <strong>{% trans "Identity Provider" %}</strong> {% trans "from the following list" %}</p>
+    <ul>
+        {% for url, name in available_idps %}
+        <li><a
+                href="{% url 'radius:saml2_login' %}?idp={{ url }}{% if came_from %}&next={{ came_from }}{% endif %}">{{ name|default_if_none:url }}</a>
+        </li>
+        {% endfor %}
+    </ul>
+{% endblock %}
diff --git a/openwisp_radius/urls.py b/openwisp_radius/urls.py
index 7442d2b0..0ce9b049 100644
--- a/openwisp_radius/urls.py
+++ b/openwisp_radius/urls.py
@@ -7,7 +7,7 @@
 from .private_storage.views import rad_batch_csv_download_view
 
 
-def get_urls(api_views=None, social_views=None):
+def get_urls(api_views=None, social_views=None, saml_views=None):
     """
     Returns a list of urlpatterns
     Arguements:
@@ -32,6 +32,10 @@ def get_urls(api_views=None, social_views=None):
                 name='redirect_cp',
             )
         )
+    if app_settings.SAML_LOGIN_ENABLED:
+        from .saml.urls import get_saml_urls
+
+        urls.append(path('radius/saml2/', include(get_saml_urls(saml_views))),)
     return urls
 
 
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index ea14e46d..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-django>=3.0,<3.2
-swapper~=1.1.0
-# TODO: change this when next point version of openwisp-utils is released
-openwisp-utils[rest] @ https://github.com/openwisp/openwisp-utils/tarball/master
-passlib~=1.7.1
-djangorestframework-link-header-pagination~=0.1.1
-weasyprint>=43,<53
-# Needed for the new authentication backend in openwisp-users
-# TODO: remove when the new version of openwisp-users is released
-openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/master
-dj-rest-auth~=2.1.2
-django-sendsms~=0.4.0
-jsonfield~=3.1.0
-django-private-storage~=2.2
-celery~=4.4.0
-django-ipware~=3.0.0
diff --git a/setup.py b/setup.py
index 8138dc76..0bc9755c 100644
--- a/setup.py
+++ b/setup.py
@@ -6,26 +6,6 @@
 
 from openwisp_radius import get_version
 
-
-def get_install_requires():
-    """
-    parse requirements.txt, ignore links, exclude comments
-    """
-    requirements = []
-    for line in open('requirements.txt').readlines():
-        # skip to next iteration if comment or empty line
-        if (
-            line.startswith('#')
-            or line == ''
-            or line.startswith('http')
-            or line.startswith('git')
-        ):
-            continue
-        # add line to requirements
-        requirements.append(line)
-    return requirements
-
-
 if sys.argv[-1] == 'publish':
     # delete any *.pyc, *.pyo and __pycache__
     os.system('find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf')
@@ -54,7 +34,28 @@ def get_install_requires():
     packages=find_packages(exclude=['tests*', 'docs*']),
     include_package_data=True,
     zip_safe=False,
-    install_requires=get_install_requires(),
+    install_requires=[
+        'django>=3.0,<3.2',
+        'swapper~=1.1.0',
+        # TODO: change this when next point version of openwisp-utils is released
+        (
+            'openwisp-utils[rest] @'
+            'https://github.com/openwisp/openwisp-utils/tarball/master'
+        ),
+        'passlib~=1.7.1',
+        'djangorestframework-link-header-pagination~=0.1.1',
+        'weasyprint>=43,<53',
+        # Needed for the new authentication backend in openwisp-users
+        # TODO: remove when the new version of openwisp-users is released
+        'openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/master',
+        'dj-rest-auth~=2.1.6',
+        'django-sendsms~=0.4.0',
+        'jsonfield~=3.1.0',
+        'django-private-storage~=2.2',
+        'celery~=4.4.0',
+        'django-ipware~=3.0.0',
+    ],
+    extras_require={'saml': ['djangosaml2~=1.3.0']},
     classifiers=[
         'Development Status :: 3 - Alpha',
         'Environment :: Web Environment',


Test patch:
diff --git a/openwisp_radius/tests/test_saml/__init__.py b/openwisp_radius/tests/test_saml/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/openwisp_radius/tests/test_saml/remote_idp_metadata.xml b/openwisp_radius/tests/test_saml/remote_idp_metadata.xml
new file mode 100644
index 00000000..ea2eb878
--- /dev/null
+++ b/openwisp_radius/tests/test_saml/remote_idp_metadata.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0"?>
+<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+  <md:EntityDescriptor entityID="https://idp.example.com/simplesaml/saml2/idp/metadata.php">
+    <md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+      <md:KeyDescriptor use="signing">
+        <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+          <ds:X509Data>
+            <ds:X509Certificate>MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo</ds:X509Certificate>
+          </ds:X509Data>
+        </ds:KeyInfo>
+      </md:KeyDescriptor>
+      <md:KeyDescriptor use="encryption">
+        <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+          <ds:X509Data>
+            <ds:X509Certificate>MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo</ds:X509Certificate>
+          </ds:X509Data>
+        </ds:KeyInfo>
+      </md:KeyDescriptor>
+      <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php"/>
+      <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
+      <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/simplesaml/saml2/idp/SSOService.php"/>
+    </md:IDPSSODescriptor>
+    <md:Organization>
+      <md:OrganizationName xml:lang="en">Lorenzo's test IdP</md:OrganizationName>
+      <md:OrganizationDisplayName xml:lang="en">idp.example.com IdP</md:OrganizationDisplayName>
+      <md:OrganizationURL xml:lang="en">http://idp.example.com/</md:OrganizationURL>
+    </md:Organization>
+    <md:ContactPerson contactType="technical">
+      <md:SurName>Administrator</md:SurName>
+      <md:EmailAddress>lgs@yaco.es</md:EmailAddress>
+    </md:ContactPerson>
+  </md:EntityDescriptor>
+</md:EntitiesDescriptor>
diff --git a/openwisp_radius/tests/test_saml/test_views.py b/openwisp_radius/tests/test_saml/test_views.py
new file mode 100644
index 00000000..090f5ba1
--- /dev/null
+++ b/openwisp_radius/tests/test_saml/test_views.py
@@ -0,0 +1,138 @@
+import os
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlparse
+
+import swapper
+from django.contrib.auth import SESSION_KEY, get_user_model
+from django.test import TestCase, override_settings
+from django.urls import reverse
+from djangosaml2.tests import auth_response, conf
+from djangosaml2.utils import get_session_id_from_saml2, saml2_from_httpredirect_request
+from rest_framework.authtoken.models import Token
+
+from openwisp_radius.saml.utils import get_url_or_path
+from openwisp_users.tests.utils import TestOrganizationMixin
+
+from .utils import TestSamlMixins
+
+OrganizationUser = swapper.load_model('openwisp_users', 'OrganizationUser')
+RadiusToken = swapper.load_model('openwisp_radius', 'RadiusToken')
+User = get_user_model()
+
+
+BASE_PATH = os.path.dirname(os.path.abspath(__file__))
+METADATA_PATH = os.path.join(BASE_PATH, 'remote_idp_metadata.xml')
+ATTRIBUTE_MAPS_DIR = os.path.join(BASE_PATH, 'attribute-maps')
+CERT_PATH = os.path.join(BASE_PATH, 'mycert.pem')
+KEY_PATH = os.path.join(BASE_PATH, 'mycert.key')
+
+
+@override_settings(
+    SAML_CONFIG=conf.create_conf(
+        sp_host='sp.example.com',
+        idp_hosts=['idp.example.com'],
+        metadata_file=METADATA_PATH,
+    ),
+    SAML_ATTRIBUTE_MAPPING={'uid': ('username',)},
+    SAML_USE_NAME_ID_AS_USERNAME=False,
+)
+class TestAssertionConsumerServiceView(TestOrganizationMixin, TestSamlMixins, TestCase):
+    login_url = reverse('radius:saml2_login')
+
+    def _get_relay_state(self, redirect_url, org_slug):
+        return f'{redirect_url}?org={org_slug}'
+
+    def _get_saml_response_for_acs_view(self, relay_state):
+        response = self.client.get(self.login_url, {'RelayState': relay_state})
+        saml2_req = saml2_from_httpredirect_request(response.url)
+        session_id = get_session_id_from_saml2(saml2_req)
+        self.add_outstanding_query(session_id, relay_state)
+        return auth_response(session_id, 'org_user'), relay_state
+
+    def _post_successful_auth_assertions(self, query_params, org_slug):
+        self.assertEqual(User.objects.count(), 1)
+        user_id = self.client.session[SESSION_KEY]
+        user = User.objects.get(id=user_id)
+        self.assertEqual(user.username, 'org_user')
+        self.assertEqual(OrganizationUser.objects.count(), 1)
+        org_user = OrganizationUser.objects.get(user_id=user_id)
+        self.assertEqual(org_user.organization.slug, org_slug)
+        expected_query_params = {
+            'username': ['org_user'],
+            'token': [Token.objects.get(user_id=user_id).key],
+            'login_method': ['saml'],
+        }
+        self.assertDictEqual(query_params, expected_query_params)
+
+    def test_organization_slug_present(self):
+        expected_redirect_url = 'https://captive-portal.example.com'
+        org_slug = 'default'
+        relay_state = self._get_relay_state(
+            redirect_url=expected_redirect_url, org_slug=org_slug
+        )
+        saml_response, relay_state = self._get_saml_response_for_acs_view(relay_state)
+        response = self.client.post(
+            reverse('radius:saml2_acs'),
+            {
+                'SAMLResponse': self.b64_for_post(saml_response),
+                'RelayState': relay_state,
+            },
+        )
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(get_url_or_path(response.url), expected_redirect_url)
+        query_params = parse_qs(urlparse(response.url).query)
+        self._post_successful_auth_assertions(query_params, org_slug)
+
+    def test_relay_state_relative_path(self):
+        expected_redirect_path = '/captive/portal/page'
+        org_slug = 'default'
+        relay_state = self._get_relay_state(
+            redirect_url=expected_redirect_path, org_slug=org_slug
+        )
+        saml_response, relay_state = self._get_saml_response_for_acs_view(relay_state)
+        response = self.client.post(
+            reverse('radius:saml2_acs'),
+            {
+                'SAMLResponse': self.b64_for_post(saml_response),
+                'RelayState': relay_state,
+            },
+        )
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(get_url_or_path(response.url), expected_redirect_path)
+        query_params = parse_qs(urlparse(response.url).query)
+        self._post_successful_auth_assertions(query_params, org_slug)
+
+
+@override_settings(
+    SAML_CONFIG=conf.create_conf(
+        sp_host='sp.example.com',
+        idp_hosts=['idp.example.com'],
+        metadata_file=METADATA_PATH,
+    ),
+    SAML_ATTRIBUTE_MAPPING={'uid': ('username',)},
+    SAML_USE_NAME_ID_AS_USERNAME=False,
+)
+class TestLoginView(TestCase):
+    login_url = reverse('radius:saml2_login')
+
+    def test_organization_absolute_path(self):
+        redirect_url = 'https://captive-portal.example.com/login/'
+
+        with self.subTest('Organization slug not present in URL'):
+            with patch('logging.Logger.error') as mocked_logger:
+                response = self.client.get(self.login_url, {'RelayState': redirect_url})
+                mocked_logger.assert_called_once_with('Organization slug not provided')
+                self.assertEqual(response.status_code, 200)
+                self.assertContains(response, 'Authentication Error')
+
+        with self.subTest('Incorrect organization slug present in URL'):
+            with patch('logging.Logger.error') as mocked_logger:
+                response = self.client.get(
+                    self.login_url,
+                    {'RelayState': f'{redirect_url}?org=non-existent-org'},
+                )
+                mocked_logger.assert_called_once_with(
+                    'Organization with the provided slug does not exist'
+                )
+                self.assertEqual(response.status_code, 200)
+                self.assertContains(response, 'Authentication Error')
diff --git a/openwisp_radius/tests/test_saml/utils.py b/openwisp_radius/tests/test_saml/utils.py
new file mode 100644
index 00000000..2bb3bbc5
--- /dev/null
+++ b/openwisp_radius/tests/test_saml/utils.py
@@ -0,0 +1,30 @@
+import base64
+
+from django.conf import settings
+from django.utils.module_loading import import_string
+from djangosaml2.cache import OutstandingQueriesCache
+
+
+class TestSamlMixins:
+    """
+    This class contains contains methods copied from djangosaml2.tests.SAML2Tests.
+    """
+
+    def b64_for_post(self, xml_text, encoding='utf-8'):
+        return base64.b64encode(xml_text.encode(encoding)).decode('ascii')
+
+    def add_outstanding_query(self, session_id, came_from):
+        settings.SESSION_ENGINE = 'django.contrib.sessions.backends.db'
+        engine = import_string(settings.SESSION_ENGINE)
+        self.saml_session = engine.SessionStore()
+        self.saml_session.save()
+        self.oq_cache = OutstandingQueriesCache(self.saml_session)
+
+        self.oq_cache.set(
+            session_id if isinstance(session_id, str) else session_id.decode(),
+            came_from,
+        )
+        self.saml_session.save()
+        self.client.cookies[
+            settings.SESSION_COOKIE_NAME
+        ] = self.saml_session.session_key
diff --git a/tests/openwisp2/sample_radius/saml/__init__.py b/tests/openwisp2/sample_radius/saml/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/openwisp2/sample_radius/saml/views.py b/tests/openwisp2/sample_radius/saml/views.py
new file mode 100644
index 00000000..231b8dca
--- /dev/null
+++ b/tests/openwisp2/sample_radius/saml/views.py
@@ -0,0 +1,27 @@
+from openwisp_radius.saml.views import (
+    AssertionConsumerServiceView as BaseAssertionConsumerServiceView,
+)
+from openwisp_radius.saml.views import LoginView as BaseLoginView
+from openwisp_radius.saml.views import LogoutInitView as BaseLogoutInitView
+from openwisp_radius.saml.views import LogoutView as BaseLogoutView
+from openwisp_radius.saml.views import MetadataView as BaseMetadataView
+
+
+class AssertionConsumerServiceView(BaseAssertionConsumerServiceView):
+    pass
+
+
+class LoginView(BaseLoginView):
+    pass
+
+
+class LogoutInitView(BaseLogoutInitView):
+    pass
+
+
+class LogoutView(BaseLogoutView):
+    pass
+
+
+class MetadataView(BaseMetadataView):
+    pass
diff --git a/tests/openwisp2/sample_radius/tests.py b/tests/openwisp2/sample_radius/tests.py
index bd01ad44..3bf06015 100644
--- a/tests/openwisp2/sample_radius/tests.py
+++ b/tests/openwisp2/sample_radius/tests.py
@@ -48,6 +48,12 @@
     TestRadiusPostAuth as BaseTestRadiusPostAuth,
 )
 from openwisp_radius.tests.test_models import TestRadiusReply as BaseTestRadiusReply
+from openwisp_radius.tests.test_saml.test_views import (
+    TestAssertionConsumerServiceView as BaseTestAssertionConsumerServiceView,
+)
+from openwisp_radius.tests.test_saml.test_views import (
+    TestLoginView as BaseTestLoginView,
+)
 from openwisp_radius.tests.test_social import TestSocial as BaseTestSocial
 from openwisp_radius.tests.test_token import TestPhoneToken as BaseTestPhoneToken
 from openwisp_radius.tests.test_token import TestRadiusToken as BaseTestRadiusToken
@@ -178,6 +184,14 @@ class TestOrganizationRadiusSettings(BaseTestOrganizationRadiusSettings):
     pass
 
 
+class TestAssertionConsumerServiceView(BaseTestAssertionConsumerServiceView):
+    pass
+
+
+class TestLoginView(BaseTestLoginView):
+    pass
+
+
 del BaseTestAdmin
 del BaseTestApi
 del BaseTestFreeradiusApi
@@ -206,3 +220,4 @@ class TestOrganizationRadiusSettings(BaseTestOrganizationRadiusSettings):
 del BaseTestUtils
 del BaseTestUpgradeFromDjangoFreeradius
 del BaseTestOrganizationRadiusSettings
+del BaseTestAssertionConsumerServiceView
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index b8d69902..98f02714 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -48,10 +48,16 @@
     'drf_yasg',
     'django_extensions',
     'openwisp2.integrations',
+    'djangosaml2',
 ]
 
 LOGIN_REDIRECT_URL = 'admin:index'
 
+AUTHENTICATION_BACKENDS = (
+    'openwisp_users.backends.UsersAuthenticationBackend',
+    'djangosaml2.backends.Saml2Backend',
+)
+
 AUTH_USER_MODEL = 'openwisp_users.User'
 SITE_ID = 1
 
@@ -69,8 +75,16 @@
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    'djangosaml2.middleware.SamlSessionMiddleware',
 ]
 
+SESSION_COOKIE_SECURE = True
+SESSION_EXPIRE_AT_BROWSER_CLOSE = True
+SAML_ALLOWED_HOSTS = []
+SAML_USE_NAME_ID_AS_USERNAME = True
+SAML_CREATE_UNKNOWN_USER = True
+SAML_CONFIG = {}
+
 ROOT_URLCONF = 'openwisp2.urls'
 
 TEMPLATES = [
diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py
index 4440f5fc..a9465644 100644
--- a/tests/openwisp2/urls.py
+++ b/tests/openwisp2/urls.py
@@ -15,12 +15,16 @@
     # please import them, otherwise pass `None` in place
     # of these values
     from .sample_radius.api import views as api_views
+    from .sample_radius.saml import views as saml_views
     from .sample_radius.social import views as social_views
 
-    radius_urls = path('', include((get_urls(api_views, social_views), 'radius')))
+    radius_urls = path(
+        '', include((get_urls(api_views, social_views, saml_views), 'radius'))
+    )
 else:
     api_views = None
     social_views = None
+    saml_views = None
     radius_urls = path('', include('openwisp_radius.urls'))
 
 urlpatterns = [