Repository: pennersr/django-allauth
Pull request #3316 (2023-05-15 23:30:47)

feat(Notion): Added Notion provider.
# Submitting Pull Requests

## General

 - [x] Make sure you use [semantic commit messages](https://seesparkbox.com/foundry/semantic_commit_messages).
       Examples: `"fix(google): Fixed foobar bug"`, `"feat(accounts): Added foobar feature"`.
 - [x] All Python code must formatted using Black, and clean from pep8 and isort issues.
 - [x] JavaScript code should adhere to [StandardJS](https://standardjs.com).
 - [x] If your changes are significant, please update `ChangeLog.rst`.
 - [x] If your change is substantial, feel free to add yourself to `AUTHORS`.

 ## Provider Specifics

 In case you add a new provider:

- [x] Make sure unit tests are available.
- [x] Add an entry of your provider in `test_settings.py::INSTALLED_APPS` and `docs/installation.rst::INSTALLED_APPS`.
- [x] Add documentation to `docs/providers.rst`.
- [x] Add an entry to the list of supported providers over at `docs/overview.rst`.

synchronizing: Closes #3112.
synchronizing: @pennersr Any interest on this addition?
coveralls: 
[![Coverage Status](https://coveralls.io/builds/61288428/badge)](https://coveralls.io/builds/61288428)

coverage: 91.244% (+0.1%) from 91.149% when pulling **acf09534631b464be5aea072347aeda58cae8172 on synchronizing:master** into **786a5b8afec41f7362b026169e46410e86604e12 on pennersr:main**.

pennersr: > @pennersr Any interest on this addition?

Definitely!
pennersr: There are conflicts -- can you please rebase?
pennersr: > Merged in main.

There still seem to be conflicts...
synchronizing: > > Merged in main.
> 
> There still seem to be conflicts...

Don't see it, tbh. In my branch seeing 

> This branch is 11 commits ahead of pennersr:main.

Branch looks to be in sync with your main. Coveralls went down 0.2% which caused the check failure - tests look all good. Everything else (other than your change request) looks ok. If you don't mind directing me towards where you see conflicts, I would appreciate it. 😄

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6615d18b..a6c4b8be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,6 +47,8 @@ jobs:
       uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
+    - name: Install xmlsec
+      run: sudo apt-get install -y xmlsec1 libxmlsec1-dev
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
@@ -77,6 +79,8 @@ jobs:
       uses: actions/setup-python@v4
       with:
         python-version: '3.11'
+    - name: Install xmlsec
+      run: sudo apt-get install -y xmlsec1 libxmlsec1-dev
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
diff --git a/AUTHORS b/AUTHORS
index 372b3d1a..eca6f2a6 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -59,6 +59,7 @@ Francis Brito
 Frantisek Malina
 Fred Palmer
 Fábio Santos
+Felipe Faria
 George Whewell
 Griffith Rees
 Guignard Javier
diff --git a/ChangeLog.rst b/ChangeLog.rst
index d7ab7eb6..f54328e4 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -25,6 +25,8 @@ Note worthy changes
   trend over the years has been towards the simpler and more streamlined form
   "email".
 
+- Added support for SAML 2.0.
+
 
 Security notice
 ---------------
diff --git a/README.rst b/README.rst
index da6902ff..2cf47112 100644
--- a/README.rst
+++ b/README.rst
@@ -87,6 +87,9 @@ Features
     compatible* provider, many *OAuth 1.0/2.0* providers, as well as
     custom protocols such as, for example, *Telegram* authentication.
 
+**💼 Enterprise ready**
+    Supports SAML 2.0, which is often used in a B2B context.
+
 **🕵️ Battle-tested**
     The package has been out in the open since 2010. It is in use by many
     commercial companies whose business depends on it and has hence been
diff --git a/allauth/socialaccount/providers/base/provider.py b/allauth/socialaccount/providers/base/provider.py
index e6198479..276d99a7 100644
--- a/allauth/socialaccount/providers/base/provider.py
+++ b/allauth/socialaccount/providers/base/provider.py
@@ -78,7 +78,11 @@ class Provider(object):
             provider=self.app.provider_id or self.app.provider,
         )
         email_addresses = self.extract_email_addresses(response)
-        self.cleanup_email_addresses(common_fields.get("email"), email_addresses)
+        self.cleanup_email_addresses(
+            common_fields.get("email"),
+            email_addresses,
+            email_verified=common_fields.get("email_verified"),
+        )
         sociallogin = SocialLogin(
             account=socialaccount, email_addresses=email_addresses
         )
@@ -118,10 +122,12 @@ class Provider(object):
         """
         return {}
 
-    def cleanup_email_addresses(self, email, addresses):
+    def cleanup_email_addresses(self, email, addresses, email_verified=False):
         # Move user.email over to EmailAddress
         if email and email.lower() not in [a.email.lower() for a in addresses]:
-            addresses.append(EmailAddress(email=email, verified=False, primary=True))
+            addresses.append(
+                EmailAddress(email=email, verified=bool(email_verified), primary=True)
+            )
         # Force verified emails
         settings = self.get_settings()
         verified_email = settings.get("VERIFIED_EMAIL", False)
diff --git a/allauth/socialaccount/providers/notion/__init__.py b/allauth/socialaccount/providers/notion/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/socialaccount/providers/notion/client.py b/allauth/socialaccount/providers/notion/client.py
new file mode 100644
index 00000000..92ae6fb2
--- /dev/null
+++ b/allauth/socialaccount/providers/notion/client.py
@@ -0,0 +1,41 @@
+import requests
+from requests.auth import HTTPBasicAuth
+from urllib.parse import parse_qsl
+
+from django.utils.http import urlencode
+
+from allauth.socialaccount.providers.oauth2.client import (
+    OAuth2Client,
+    OAuth2Error,
+)
+
+
+class NotionOAuth2Client(OAuth2Client):
+    def get_redirect_url(self, authorization_url, extra_params):
+        params = {
+            "client_id": self.consumer_key,
+            "scope": self.scope,
+            "response_type": "code",
+            "owner": "user",
+        }
+        if self.state:
+            params["state"] = self.state
+        return "%s?%s" % (authorization_url, urlencode(params))
+
+    def get_access_token(self, code, pkce_code_verifier=None):
+        resp = requests.request(
+            self.access_token_method,
+            self.access_token_url,
+            auth=HTTPBasicAuth(self.consumer_key, self.consumer_secret),
+            json={"code": code, "grant_type": "authorization_code"},
+            headers=self.headers,
+        )
+        access_token = None
+        if resp.status_code in [200, 201]:
+            try:
+                access_token = resp.json()
+            except ValueError:
+                access_token = dict(parse_qsl(resp.text))
+        if not access_token or "access_token" not in access_token:
+            raise OAuth2Error("Error retrieving access token: %s" % resp.content)
+        return access_token
diff --git a/allauth/socialaccount/providers/notion/provider.py b/allauth/socialaccount/providers/notion/provider.py
new file mode 100644
index 00000000..77e2bdb6
--- /dev/null
+++ b/allauth/socialaccount/providers/notion/provider.py
@@ -0,0 +1,53 @@
+from allauth.account.models import EmailAddress
+from allauth.socialaccount.providers.base import ProviderAccount
+from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
+
+
+class NotionAccount(ProviderAccount):
+    def get_user(self):
+        return self.account.extra_data["owner"]["user"]
+
+    def get_name(self):
+        return self.get_user()["name"]
+
+    def get_avatar_url(self):
+        return self.get_user()["avatar_url"]
+
+    def get_workspace_name(self):
+        return self.account.extra_data["workspace_name"]
+
+    def get_workspace_icon(self):
+        return self.account.extra_data["workspace_icon"]
+
+    def to_str(self):
+        name = self.get_name()
+        workspace = self.get_workspace_name()
+        return f"{name} ({workspace})"
+
+
+class NotionProvider(OAuth2Provider):
+    id = "notion"
+    name = "Notion"
+    account_class = NotionAccount
+
+    def extract_uid(self, data):
+        """
+        The unique identifer for Notion is a combination of the User ID
+        and the Workspace ID they have authorized the application with.
+        """
+        user_id = data["owner"]["user"]["id"]
+        workspace_id = data["workspace_id"]
+        return "user-%s_workspace-%s" % (user_id, workspace_id)
+
+    def extract_common_fields(self, data):
+        user = data["owner"]["user"]
+        user["email"] = user["person"]["email"]
+        return user
+
+    def extract_email_addresses(self, data):
+        user = data["owner"]["user"]
+        email = user["person"]["email"]
+        return [EmailAddress(email=email, verified=True, primary=True)]
+
+
+provider_classes = [NotionProvider]
diff --git a/allauth/socialaccount/providers/notion/urls.py b/allauth/socialaccount/providers/notion/urls.py
new file mode 100644
index 00000000..4d283dab
--- /dev/null
+++ b/allauth/socialaccount/providers/notion/urls.py
@@ -0,0 +1,5 @@
+from allauth.socialaccount.providers.notion.provider import NotionProvider
+from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
+
+
+urlpatterns = default_urlpatterns(NotionProvider)
diff --git a/allauth/socialaccount/providers/notion/views.py b/allauth/socialaccount/providers/notion/views.py
new file mode 100644
index 00000000..faff5b8a
--- /dev/null
+++ b/allauth/socialaccount/providers/notion/views.py
@@ -0,0 +1,26 @@
+from allauth.socialaccount.providers.oauth2.views import (
+    OAuth2Adapter,
+    OAuth2CallbackView,
+    OAuth2LoginView,
+)
+
+from .client import NotionOAuth2Client
+from .provider import NotionProvider
+
+
+class NotionOAuth2Adapter(OAuth2Adapter):
+    provider_id = NotionProvider.id
+    basic_auth = True
+    client_class = NotionOAuth2Client
+
+    authorize_url = "https://api.notion.com/v1/oauth/authorize"
+    access_token_url = "https://api.notion.com/v1/oauth/token"
+
+    def complete_login(self, request, app, token, **kwargs):
+        return self.get_provider().sociallogin_from_response(
+            request, kwargs["response"]
+        )
+
+
+oauth2_login = OAuth2LoginView.adapter_view(NotionOAuth2Adapter)
+oauth2_callback = OAuth2CallbackView.adapter_view(NotionOAuth2Adapter)
diff --git a/allauth/socialaccount/providers/saml/provider.py b/allauth/socialaccount/providers/saml/provider.py
new file mode 100644
index 00000000..a443cf73
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/provider.py
@@ -0,0 +1,87 @@
+from django.urls import reverse
+from django.utils.http import urlencode
+
+from allauth.socialaccount.providers.base import Provider, ProviderAccount
+
+
+class SAMLAccount(ProviderAccount):
+    def to_str(self):
+        return super().to_str()
+
+
+class SAMLProvider(Provider):
+    id = "saml"
+    account_class = SAMLAccount
+    default_attribute_mapping = {
+        "uid": [
+            "http://schemas.auth0.com/clientID",
+            "urn:oasis:names:tc:SAML:attribute:subject-id",
+        ],
+        "email": [
+            "urn:oid:0.9.2342.19200300.100.1.3",
+            "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+        ],
+        "email_verified": [
+            "http://schemas.auth0.com/email_verified",
+        ],
+        "first_name": [
+            "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
+            "urn:oid:2.5.4.42",
+        ],
+        "last_name": [
+            "urn:oid:2.5.4.4",
+        ],
+        "username": [
+            "http://schemas.auth0.com/nickname",
+        ],
+    }
+
+    @property
+    def name(self):
+        return self.app.name or self.app.client_id or self.id
+
+    def get_login_url(self, request, **kwargs):
+        url = reverse("saml_login", kwargs={"organization_slug": self.app.client_id})
+        if kwargs:
+            url = url + "?" + urlencode(kwargs)
+        return url
+
+    def extract_extra_data(self, data):
+        return data.get_attributes()
+
+    def extract_uid(self, data):
+        """
+        The `uid` is not unique across different SAML IdP's. Therefore,
+        we're using a fully qualified ID: <uid>@<entity_id>.
+        """
+        return self._extract(data)["uid"]
+
+    def extract_common_fields(self, data):
+        ret = self._extract(data)
+        ret.pop("uid", None)
+        return ret
+
+    def _extract(self, data):
+        provider_config = self.app.settings
+        raw_attributes = data.get_attributes()
+        attributes = {}
+        attribute_mapping = provider_config.get(
+            "attribute_mapping", self.default_attribute_mapping
+        )
+        # map configured provider attributes
+        for key, provider_keys in attribute_mapping.items():
+            if isinstance(provider_keys, str):
+                provider_keys = [provider_keys]
+            for provider_key in provider_keys:
+                attribute_list = raw_attributes.get(provider_key, [""])
+                if len(attribute_list) > 0:
+                    attributes[key] = attribute_list[0]
+                    break
+        email_verified = attributes.get("email_verified")
+        if email_verified:
+            email_verified = email_verified.lower() in ["true", "1", "t", "y", "yes"]
+            attributes["email_verified"] = email_verified
+        return attributes
+
+
+provider_classes = [SAMLProvider]
diff --git a/allauth/socialaccount/providers/saml/urls.py b/allauth/socialaccount/providers/saml/urls.py
new file mode 100644
index 00000000..66d6f417
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/urls.py
@@ -0,0 +1,39 @@
+from django.urls import include, path, re_path
+
+from . import views
+
+
+urlpatterns = [
+    re_path(
+        r"^saml/(?P<organization_slug>[^/]+)/",
+        include(
+            [
+                path(
+                    "acs/",
+                    views.acs,
+                    name="saml_acs",
+                ),
+                path(
+                    "acs/finish/",
+                    views.finish_acs,
+                    name="saml_finish_acs",
+                ),
+                path(
+                    "sls/",
+                    views.sls,
+                    name="saml_sls",
+                ),
+                path(
+                    "metadata/",
+                    views.metadata,
+                    name="saml_metadata",
+                ),
+                path(
+                    "login/",
+                    views.login,
+                    name="saml_login",
+                ),
+            ]
+        ),
+    )
+]
diff --git a/allauth/socialaccount/providers/saml/utils.py b/allauth/socialaccount/providers/saml/utils.py
new file mode 100644
index 00000000..31dedb2d
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/utils.py
@@ -0,0 +1,117 @@
+from django.core.cache import cache
+from django.core.exceptions import ImproperlyConfigured
+from django.http import Http404
+from django.urls import reverse
+
+from onelogin.saml2.constants import OneLogin_Saml2_Constants
+from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
+
+from allauth.socialaccount.adapter import get_adapter
+from allauth.socialaccount.models import SocialApp
+
+from .provider import SAMLProvider
+
+
+def get_app_or_404(request, organization_slug):
+    adapter = get_adapter(request)
+    try:
+        return adapter.get_app(
+            request, provider=SAMLProvider.id, client_id=organization_slug
+        )
+    except SocialApp.DoesNotExist:
+        raise Http404(f"no SocialApp found with client_id={organization_slug}")
+
+
+def prepare_django_request(request):
+    result = {
+        "https": "on" if request.is_secure() else "off",
+        "http_host": request.META["HTTP_HOST"],
+        "script_name": request.META["PATH_INFO"],
+        "get_data": request.GET.copy(),
+        # 'lowercase_urlencoding': True,
+        "post_data": request.POST.copy(),
+    }
+    return result
+
+
+def build_sp_config(request, provider_config, org):
+    acs_url = request.build_absolute_uri(reverse("saml_acs", args=[org]))
+    sls_url = request.build_absolute_uri(reverse("saml_sls", args=[org]))
+    metadata_url = request.build_absolute_uri(reverse("saml_metadata", args=[org]))
+    sp_config = {
+        "entityId": metadata_url,
+        "assertionConsumerService": {
+            "url": acs_url,
+            "binding": OneLogin_Saml2_Constants.BINDING_HTTP_POST,
+        },
+        "singleLogoutService": {
+            "url": sls_url,
+            "binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
+        },
+    }
+    avd = provider_config.get("advanced", {})
+    if avd.get("x509cert") is not None:
+        sp_config["x509cert"] = avd["x509cert"]
+
+    if avd.get("private_key") is not None:
+        sp_config["privateKey"] = avd["private_key"]
+    return sp_config
+
+
+def fetch_metadata_url_config(idp_config):
+    metadata_url = idp_config["metadata_url"]
+    entity_id = idp_config["entity_id"]
+    cache_key = f"saml.metadata.{metadata_url}.{entity_id}"
+    saml_config = cache.get(cache_key)
+    if saml_config is None:
+        saml_config = OneLogin_Saml2_IdPMetadataParser.parse_remote(
+            metadata_url,
+            entity_id=entity_id,
+            timeout=idp_config.get("metadata_request_timeout", 10),
+        )
+        cache.set(
+            cache_key,
+            saml_config,
+            idp_config.get("metadata_cache_timeout", 60 * 60 * 4),
+        )
+    return saml_config
+
+
+def build_saml_config(request, provider_config, org):
+    avd = provider_config.get("advanced", {})
+    security_config = {
+        "authnRequestsSigned": avd.get("authn_request_signed", False),
+        "digestAlgorithm": avd.get("digest_algorithm", OneLogin_Saml2_Constants.SHA256),
+        "logoutRequestSigned": avd.get("logout_request_signed", False),
+        "logoutResponseSigned": avd.get("logout_response_signed", False),
+        "requestedAuthnContext": False,
+        "signatureAlgorithm": avd.get(
+            "signature_algorithm", OneLogin_Saml2_Constants.RSA_SHA256
+        ),
+        "signMetadata": avd.get("metadata_signed", False),
+        "wantAssertionsEncrypted": avd.get("want_assertion_encrypted", False),
+        "wantAssertionsSigned": avd.get("want_assertion_signed", False),
+        "wantMessagesSigned": avd.get("want_message_signed", False),
+        "wantNameId": False,
+    }
+    saml_config = {
+        "strict": avd.get("strict", True),
+        "security": security_config,
+    }
+
+    idp = provider_config.get("idp")
+    if idp is None:
+        raise ImproperlyConfigured("`idp` missing")
+    metadata_url = idp.get("metadata_url")
+    if metadata_url:
+        meta_config = fetch_metadata_url_config(idp)
+        saml_config["idp"] = meta_config["idp"]
+    else:
+        saml_config["idp"] = {
+            "entityId": idp["entity_id"],
+            "x509cert": idp["x509cert"],
+            "singleSignOnService": {"url": idp["sso_url"]},
+            "singleLogoutService": {"url": idp["slo_url"]},
+        }
+    saml_config["sp"] = build_sp_config(request, provider_config, org)
+    return saml_config
diff --git a/allauth/socialaccount/providers/saml/views.py b/allauth/socialaccount/providers/saml/views.py
new file mode 100644
index 00000000..0f6c4e41
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/views.py
@@ -0,0 +1,172 @@
+import binascii
+import logging
+
+from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+
+from onelogin.saml2.auth import OneLogin_Saml2_Auth, OneLogin_Saml2_Settings
+
+from allauth.account.adapter import get_adapter as get_account_adapter
+from allauth.socialaccount.helpers import (
+    complete_social_login,
+    render_authentication_error,
+)
+from allauth.socialaccount.models import SocialLogin
+from allauth.socialaccount.providers.base.constants import (
+    AuthError,
+    AuthProcess,
+)
+from allauth.socialaccount.sessions import LoginSession
+
+from .utils import build_saml_config, get_app_or_404, prepare_django_request
+
+
+logger = logging.getLogger(__name__)
+
+
+class SAMLViewMixin:
+    def build_auth(self, provider, organization_slug):
+        req = prepare_django_request(self.request)
+        config = build_saml_config(
+            self.request, provider.app.settings, organization_slug
+        )
+        auth = OneLogin_Saml2_Auth(req, config)
+        return auth
+
+    def get_app(self, organization_slug):
+        app = get_app_or_404(self.request, organization_slug)
+        return app
+
+    def get_provider(self, organization_slug):
+        app = self.get_app(organization_slug)
+        return app.get_provider(self.request)
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class ACSView(SAMLViewMixin, View):
+    def dispatch(self, request, organization_slug):
+        provider = self.get_provider(organization_slug)
+        auth = self.build_auth(provider, organization_slug)
+        try:
+            auth.process_response()
+        except binascii.Error:
+            errors = ["invalid_response"]
+        else:
+            errors = auth.get_errors()
+        if errors:
+            # e.g. ['invalid_response']
+            logger.error(
+                "Error processing SAML response: %s: %s"
+                % (", ".join(errors), auth.get_last_error_reason())
+            )
+            return render_authentication_error(
+                request,
+                provider.id,
+                extra_context={
+                    "saml_errors": errors,
+                    "saml_last_error_reason": auth.get_last_error_reason(),
+                },
+            )
+        if not auth.is_authenticated():
+            return render_authentication_error(
+                request, provider.id, error=AuthError.CANCELLED
+            )
+
+        relay_state = request.POST.get("RelayState")
+        login = provider.sociallogin_from_response(request, auth)
+        if relay_state == request.build_absolute_uri(
+            reverse("socialaccount_connections")
+        ):
+            login.state["process"] = AuthProcess.CONNECT
+
+        acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
+        acs_session.store["login"] = login.serialize()
+        url = reverse(
+            "saml_finish_acs",
+            kwargs={"organization_slug": organization_slug},
+        )
+        response = HttpResponseRedirect(url)
+        acs_session.save(response)
+        return response
+
+
+acs = ACSView.as_view()
+
+
+class FinishACSView(SAMLViewMixin, View):
+    def dispatch(self, request, organization_slug):
+        provider = self.get_provider(organization_slug)
+        acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
+        serialized_login = acs_session.store.get("login")
+        if not serialized_login:
+            logger.error("Unable to finish login, SAML ACS session missing")
+            return render_authentication_error(request, provider.id)
+        acs_session.delete()
+        login = SocialLogin.deserialize(serialized_login)
+        return complete_social_login(request, login)
+
+
+finish_acs = FinishACSView.as_view()
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class SLSView(SAMLViewMixin, View):
+    def dispatch(self, request, organization_slug):
+        provider = self.get_provider(organization_slug)
+        auth = self.build_auth(provider, organization_slug)
+        should_logout = request.user.is_authenticated
+        account_adapter = get_account_adapter(request)
+
+        def force_logout():
+            account_adapter.logout(request)
+
+        redirect_to = auth.process_slo(
+            delete_session_cb=force_logout, keep_local_session=not should_logout
+        )
+        if not redirect_to:
+            redirect_to = account_adapter.get_logout_redirect_url(request)
+        return HttpResponseRedirect(redirect_to)
+
+
+sls = SLSView.as_view()
+
+
+class MetadataView(SAMLViewMixin, View):
+    def dispatch(self, request, organization_slug):
+        provider = self.get_provider(organization_slug)
+        config = build_saml_config(
+            self.request, provider.app.settings, organization_slug
+        )
+        saml_settings = OneLogin_Saml2_Settings(
+            settings=config, sp_validation_only=True
+        )
+        metadata = saml_settings.get_sp_metadata()
+        errors = saml_settings.validate_metadata(metadata)
+
+        if len(errors) > 0:
+            resp = JsonResponse({"errors": errors})
+            resp.status_code = 500
+            return resp
+
+        return HttpResponse(content=metadata, content_type="text/xml")
+
+
+metadata = MetadataView.as_view()
+
+
+class LoginView(SAMLViewMixin, View):
+    def dispatch(self, request, organization_slug):
+        provider = self.get_provider(organization_slug)
+        auth = self.build_auth(provider, organization_slug)
+        process = self.request.GET.get("process")
+        return_to = None
+        if process == AuthProcess.CONNECT:
+            return_to = request.build_absolute_uri(reverse("socialaccount_connections"))
+        redirect = auth.login(return_to=return_to)
+        return HttpResponseRedirect(redirect)
+
+
+login = LoginView.as_view()
diff --git a/docs/installation.rst b/docs/installation.rst
index 7a80e706..13563c1f 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -117,6 +117,7 @@ settings.py (Important - Please note 'django.contrib.sites' is required as INSTA
         'allauth.socialaccount.providers.microsoft',
         'allauth.socialaccount.providers.naver',
         'allauth.socialaccount.providers.nextcloud',
+        'allauth.socialaccount.providers.notion',
         'allauth.socialaccount.providers.odnoklassniki',
         'allauth.socialaccount.providers.openid',
         'allauth.socialaccount.providers.openid_connect',
diff --git a/docs/overview.rst b/docs/overview.rst
index bee00860..444f699c 100644
--- a/docs/overview.rst
+++ b/docs/overview.rst
@@ -173,6 +173,8 @@ Supported Providers
 
 - NextCloud (OAuth2)
 
+- Notion (OAuth2)
+
 - Odnoklassniki (OAuth2)
 
 - Okta (OAuth2)
@@ -201,6 +203,8 @@ Supported Providers
 
 - Salesforce (OAuth2)
 
+- SAML 2.0
+
 - ShareFile (OAuth2)
 
 - Shopify (OAuth2)
diff --git a/docs/providers/index.rst b/docs/providers/index.rst
index ed10e09e..f81f4c58 100644
--- a/docs/providers/index.rst
+++ b/docs/providers/index.rst
@@ -93,6 +93,7 @@ Provider Specifics
    naver
    netiq
    nextcloud
+   notion
    odnoklassniki
    okta
    openid_connect
@@ -106,6 +107,7 @@ Provider Specifics
    questrade
    quickbooks
    reddit
+   saml
    salesforce
    sharefile
    shopify
diff --git a/docs/providers/notion.rst b/docs/providers/notion.rst
new file mode 100644
index 00000000..27f9c41f
--- /dev/null
+++ b/docs/providers/notion.rst
@@ -0,0 +1,14 @@
+Notion
+------
+
+After creating an integration perform the following steps:
+
+1. Navigate to your integration then Capabilities, User Capabilities, and select 'Read user information including email addresses.' 
+2. Click 'Distribution' and check 'yes' on 'Do you want to make this integration public?'.
+3. In the 'Redirect URL' paste the callback URL below. Notion only allows using 'http' for 'localhost' - do not use 127.0.0.1.
+
+App registration (get your key and secret here)
+    https://www.notion.so/my-integrations
+
+Development callback URL
+    http://localhost:8000/accounts/notion/login/callback/
diff --git a/docs/providers/saml.rst b/docs/providers/saml.rst
new file mode 100644
index 00000000..2248f0fd
--- /dev/null
+++ b/docs/providers/saml.rst
@@ -0,0 +1,117 @@
+SAML
+----
+
+SAML 2.0 is supported out of the box. However, the required dependencies are not
+installed by default. Therefore, you will need to specifcy the ``saml`` extra when
+installing the package::
+
+    $ pip install django-allauth[saml]
+
+When you need to support SAML based authentication, often you need to support
+multiple organizations, each having their own SAML based Identity Provider
+(IdP). The way this translates to allauth is as follows:
+
+- The allauth SAML endpoints all include an organization slug. For example,
+  ``/accounts/saml/<organization_slug>/login/`` is used to initiate the SAML login
+  flow.
+
+- The organization slug is used to lookup the SAML IdP configuration, which is
+  stored in a ``SocialApp``, or, its settings based
+  ``SOCIALACCOUNT_PROVIDERS['saml']['APPS']`` counterpart.
+
+- As per the SAML specification, each IdP is identified by an entity ID. Entity
+  IDs typically use a URI based notation, and are therefore not suitable to be
+  used as the organization slug.  So, you need to be aware that allauth has two
+  ways of identifying the ``SocialApp`` -- via the SAML entity ID, and, via the
+  organization slug.
+
+- For each user signing in via the SAML provider, a ``SocialAccount`` record is
+  created. Here, the combination of ``SocialAccount.provider`` and
+  ``SocialAccount.uid`` is expected to be unique. The ``uid`` identitifer is the
+  SAML account ID which is locally unique within the IdP. Therefore, if you are
+  using multiple IdP's ``SocialAccount.provider`` cannot be set to
+  ``"saml"``. Instead, the IdP entity ID would be a good candidate.
+
+- The SAML account attributes can differ per IdP. Therefore, additional
+  configuration is needed to be able to extract relevant account attributes,
+  such as the ``uid`` and ``email``. This is done by setting up an attribute
+  mapping.
+
+How all of the above is configured in practice is shown below. Note that here we
+are using the settings based configuration, but you can setup the ``SocialApp``
+via the Django admin as well:
+
+.. code-block:: python
+
+    SOCIALACCOUNT_PROVIDERS = {
+        "saml": {
+            # Here, each app represents the SAML provider configuration of one
+            # organization.
+            "APPS": [
+                {
+                    # Used for display purposes, e.g. over by: {% get_providers %}
+                    "name": "Acme Inc",
+
+                    # Accounts signed up via this provider will have their
+                    # `SocialAccount.provider` value set to this ID. The combination
+                    # of this value and the `uid` must be unique. The IdP entity ID is a
+                    # good choice for this.
+                    "provider_id": "urn:dev-123.us.auth0.com",
+
+                    # The organization slug is configured by setting the
+                    # `client_id` value. In this example, the SAML login URL is:
+                    #
+                    #     /accounts/saml/acme-inc/login/
+                    "client_id": "acme-inc",
+
+                    # The fields above are common `SocialApp` fields. For SAML,
+                    # additional configuration is needed, which is placed in
+                    # `SocialApp.settings`:
+                    "settings": {
+                        # Mapping account attributes to upstream (IdP specific) attributes.
+                        # If left empty, an attempt will be done to map the attributes using
+                        # built-in defaults.
+                        "attribute_mapping": {
+                            "uid": "http://schemas.auth0.com/clientID",
+                            "email_verified": "http://schemas.auth0.com/email_verified",
+                            "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+                        },
+                        # The configuration of the IdP.
+                        "idp": {
+                            # The entity ID of the IdP is required.
+                            "entity_id": "urn:dev-123.us.auth0.com",
+
+                            # Then, you can either specify the IdP's metadata URL:
+                            "metadata_url": "https://dev-123.us.auth0.com/samlp/metadata/456",
+
+                            # Or, you can inline the IdP parameters here as follows:
+                            "sso_url": "https://dev-123.us.auth0.com/samlp/456",
+                            "slo_url": "https://dev-123.us.auth0.com/samlp/456",
+                            "x509cert": """
+    -----BEGIN CERTIFICATE-----
+    MIIDHTCCAgWgAwIBAgIJLogff5x+S0BlMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV
+    BAMTIWRldi1uYXAybWY1ZTFwMXR3Z2Rv................................
+    ................................G7qmyqcXRaf9HAuL/MvWz6zd96Ay6WHM
+    pXk92/DyUV48JxK/Bl7Bj8qjl5w5R7Dwps6wj+69PIAg
+    -----END CERTIFICATE-----
+    """,
+                        },
+                    },
+                },
+
+
+In your templates, you can construct login URLs using the following template tag::
+
+    {% load socialaccount %}
+    {% provider_login_url "<provider_id>" %}
+
+
+The SAML provider has the following endpoints:
+
+- ``/accounts/saml/<organization_slug>/login/``: Initiate a login.
+
+- ``/accounts/saml/<organization_slug>/acs/``: Assertion Consumer Service URL.
+
+- ``/accounts/saml/<organization_slug>/sls/``: Single Logout Service URL.
+
+- ``/accounts/saml/<organization_slug>/metadata/``: Metadata URL.
diff --git a/example/example/settings.py b/example/example/settings.py
index 862068cd..d8c9c027 100644
--- a/example/example/settings.py
+++ b/example/example/settings.py
@@ -148,6 +148,7 @@ INSTALLED_APPS = (
     "allauth.socialaccount.providers.pinterest",
     "allauth.socialaccount.providers.pocket",
     "allauth.socialaccount.providers.reddit",
+    "allauth.socialaccount.providers.saml",
     "allauth.socialaccount.providers.shopify",
     "allauth.socialaccount.providers.slack",
     "allauth.socialaccount.providers.snapchat",
diff --git a/setup.cfg b/setup.cfg
index 4a0cbf2d..80419443 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -51,6 +51,9 @@ install_requires =
     requests >= 2.0.0
     pyjwt[crypto] >= 1.7
 
+[options.extras_require]
+saml = python3-saml>=1.15.0,<2.0.0
+
 
 [options.packages.find]
 exclude =
diff --git a/tox.ini b/tox.ini
index 3813c7a1..a77e1f15 100644
--- a/tox.ini
+++ b/tox.ini
@@ -23,6 +23,7 @@ deps =
     django41: Django==4.1.*
     django42: Django==4.2.*
     djangomain: git+https://github.com/django/django.git@main#egg=django
+    python3-saml>=1.15.0,<2.0.0
 commands =
     coverage run -m pytest {posargs:allauth}
     coverage report


Test patch:
diff --git a/allauth/conftest.py b/allauth/conftest.py
index 4a6cdc3d..bb6ac584 100644
--- a/allauth/conftest.py
+++ b/allauth/conftest.py
@@ -8,7 +8,18 @@ from allauth.utils import get_user_model
 
 
 @pytest.fixture
-def user_factory(db, email_factory):
+def user(user_factory):
+    return user_factory()
+
+
+@pytest.fixture
+def auth_client(client, user):
+    client.force_login(user)
+    return client
+
+
+@pytest.fixture
+def user_factory(email_factory, db):
     def factory(
         email=None, username=None, commit=True, with_email=True, email_verified=True
     ):
diff --git a/allauth/socialaccount/providers/notion/tests.py b/allauth/socialaccount/providers/notion/tests.py
new file mode 100644
index 00000000..5fa7f193
--- /dev/null
+++ b/allauth/socialaccount/providers/notion/tests.py
@@ -0,0 +1,89 @@
+from urllib.parse import parse_qs, urlparse
+
+from django.urls import reverse
+from django.utils.http import urlencode
+
+from allauth.socialaccount.tests import OAuth2TestsMixin
+from allauth.tests import MockedResponse, TestCase, mocked_response
+
+from .provider import NotionProvider
+
+
+class NotionTests(OAuth2TestsMixin, TestCase):
+    provider_id = NotionProvider.id
+    pkce_enabled_default = False  # Notion does not support PKCE.
+
+    def get_mocked_response(self):
+        return MockedResponse(
+            200,
+            """
+            {
+                "workspace_id": "workspace-abc",
+                "owner": {
+                    "user": {
+                        "id": "test123",
+                        "name": "John Doe",
+                        "avatar_url": "",
+                        "person": {"email": "john@example.com"},
+                    }
+                },
+            }
+            """,
+        )  # noqa
+
+    def get_login_response_json(self, with_refresh_token=False):
+        """
+        Docs here:
+        https://developers.notion.com/docs/authorization#step-4-notion-responds-with-an-access_token-and-additional-information
+        """
+        return """
+        {
+            "access_token": "test123",
+            "bot_id": "bot-abc",
+            "duplicated_template_id": "template-abc",
+            "owner": {
+            "workspace_id": "workspace-abc",
+                "user": {
+                    "id": "test123",
+                    "name": "John Doe",
+                    "avatar_url": "",
+                    "person": {
+                        "email": "john@example.com"
+                    }
+                }
+            },
+            "workspace_icon": "https://example.com/icon.png",
+            "workspace_id": "workspace-abc",
+            "workspace_name": "My Workspace"
+        }
+        """
+
+    def login(self, resp_mock=None, process="login", with_refresh_token=True):
+        resp = self.client.post(
+            reverse(self.provider.id + "_login")
+            + "?"
+            + urlencode(dict(process=process))
+        )
+
+        p = urlparse(resp["location"])
+        q = parse_qs(p.query)
+
+        complete_url = reverse(self.provider.id + "_callback")
+        response_json = self.get_login_response_json(
+            with_refresh_token=with_refresh_token
+        )
+
+        if isinstance(resp_mock, list):
+            resp_mocks = resp_mock
+        elif resp_mock is None:
+            resp_mocks = []
+        else:
+            resp_mocks = [resp_mock]
+
+        with mocked_response(
+            MockedResponse(200, response_json, {"content-type": "application/json"}),
+            *resp_mocks,
+        ):
+            resp = self.client.get(complete_url, self.get_complete_parameters(q))
+
+        return resp
diff --git a/allauth/socialaccount/providers/saml/__init__.py b/allauth/socialaccount/providers/saml/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/socialaccount/providers/saml/conftest.py b/allauth/socialaccount/providers/saml/conftest.py
new file mode 100644
index 00000000..d1210f87
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/conftest.py
@@ -0,0 +1,177 @@
+import base64
+from unittest.mock import patch
+
+from django.test.client import Client
+
+import pytest
+
+
+@pytest.fixture
+def client():
+    client = Client(HTTP_HOST="example.com")
+    return client
+
+
+@pytest.fixture
+def saml_settings(settings):
+    settings.SOCIALACCOUNT_PROVIDERS = {
+        "saml": {
+            "APPS": [
+                {
+                    "client_id": "org",
+                    "provider_id": "urn:dev-123.us.auth0.com",
+                    "settings": {
+                        "attribute_mapping": {
+                            "uid": "http://schemas.auth0.com/clientID",
+                            "email_verified": "http://schemas.auth0.com/email_verified",
+                            "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+                        },
+                        "idp": {
+                            "name": "Test IdP",
+                            "entity_id": "urn:dev-123.us.auth0.com",
+                            "sso_url": "https://dev-123.us.auth0.com/samlp/456",
+                            "slo_url": "https://dev-123.us.auth0.com/samlp/456",
+                            "x509cert": "",
+                        },
+                        "advanced": {
+                            "strict": False,
+                        },
+                    },
+                }
+            ]
+        }
+    }
+
+
+@pytest.fixture
+def acs_saml_response():
+    xml = """<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="123"  InResponseTo="ONELOGIN_456"  Version="2.0" IssueInstant="2023-07-08T08:24:14.141Z"  Destination="https://allauth.org/accounts/org/acs/">
+  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:dev-123.us.auth0.com
+  </saml:Issuer>
+  <samlp:Status>
+    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
+  </samlp:Status>
+  <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="123" IssueInstant="2023-07-08T08:24:14.094Z">
+    <saml:Issuer>urn:dev-123.us.auth0.com
+    </saml:Issuer>
+    <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
+      <SignedInfo>
+        <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+        <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+        <Reference URI="#123">
+          <Transforms>
+            <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
+            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+          </Transforms>
+          <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+          <DigestValue>123
+          </DigestValue>
+        </Reference>
+      </SignedInfo>
+      <SignatureValue>If7dFg...
+      </SignatureValue>
+      <KeyInfo>
+        <X509Data>
+          <X509Certificate>MIIDHTCC...
+          </X509Certificate>
+        </X509Data>
+      </KeyInfo>
+    </Signature>
+    <saml:Subject>
+      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">google-oauth2|108204123456789
+      </saml:NameID>
+      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+        <saml:SubjectConfirmationData NotOnOrAfter="2023-07-08T09:24:14.094Z" Recipient="https://allauth.org/accounts/org/acs/" InResponseTo="ONELOGIN_f293b01d18bb0ac85a611b35e0c898af582bcfdd"/>
+      </saml:SubjectConfirmation>
+    </saml:Subject>
+    <saml:Conditions NotBefore="2023-07-08T08:24:14.094Z" NotOnOrAfter="2023-07-08T09:24:14.094Z">
+      <saml:AudienceRestriction>
+        <saml:Audience>https://allauth.org/accounts/org/metadata/
+        </saml:Audience>
+      </saml:AudienceRestriction>
+    </saml:Conditions>
+    <saml:AuthnStatement AuthnInstant="2023-07-08T08:24:14.094Z" SessionIndex="_qPrYdL0O8w3vdb8eCEY5ZtHe76LA8-JU">
+      <saml:AuthnContext>
+        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified
+        </saml:AuthnContextClassRef>
+      </saml:AuthnContext>
+    </saml:AuthnStatement>
+    <saml:AttributeStatement xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">google-oauth2|108204123456789
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">john.doe@email.org
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">John
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">John
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">john.doe@email.org
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/identities/default/provider" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">google-oauth2
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/identities/default/connection" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">google-oauth2
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/identities/default/isSocial" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:boolean">true
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/clientID" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">dummysamluid
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/created_at" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:anyType">Wed Jun 28 2023 17:53:49 GMT+0000 (Coordinated Universal Time)
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/email_verified" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:boolean">true
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/locale" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">en
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/nickname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">john.doe
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/picture" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:string">https://lh3.googleusercontent.com/a/AAcHTtfZ0fEyL3BKP1Hk2v1bNwpJd6ckIeo6jSExlkVjMXaIpsY=s96-c
+        </saml:AttributeValue>
+      </saml:Attribute>
+      <saml:Attribute Name="http://schemas.auth0.com/updated_at" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+        <saml:AttributeValue xsi:type="xs:anyType">Sat Jul 08 2023 06:13:07 GMT+0000 (Coordinated Universal Time)
+        </saml:AttributeValue>
+      </saml:Attribute>
+    </saml:AttributeStatement>
+  </saml:Assertion>
+</samlp:Response>
+"""
+    return base64.b64encode(xml.encode("utf8")).decode("utf8")
+
+
+@pytest.fixture
+def sls_saml_request():
+    xml = "<dummy></dummy>"
+    return base64.b64encode(xml.encode("utf8")).decode("utf8")
+
+
+@pytest.fixture
+def mocked_signature_validation():
+    with patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign") as mock:
+        mock.return_value = True
+        yield
diff --git a/allauth/socialaccount/providers/saml/tests.py b/allauth/socialaccount/providers/saml/tests.py
new file mode 100644
index 00000000..45eeef53
--- /dev/null
+++ b/allauth/socialaccount/providers/saml/tests.py
@@ -0,0 +1,129 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from django.utils.http import urlencode
+
+import pytest
+
+from allauth.account.models import EmailAddress
+from allauth.socialaccount.models import SocialAccount
+from allauth.socialaccount.providers.saml.utils import build_saml_config
+
+
+def test_acs(client, db, saml_settings, acs_saml_response, mocked_signature_validation):
+    data = {"SAMLResponse": acs_saml_response}
+    resp = client.post(
+        reverse("saml_acs", kwargs={"organization_slug": "org"}), data=data
+    )
+    finish_url = reverse("saml_finish_acs", kwargs={"organization_slug": "org"})
+    assert resp.status_code == 302
+    assert resp["location"] == finish_url
+    resp = client.get(finish_url)
+    assert resp["location"] == "/accounts/profile/"
+    account = SocialAccount.objects.get(
+        provider="urn:dev-123.us.auth0.com", uid="dummysamluid"
+    )
+
+    email = EmailAddress.objects.get(user=account.user)
+    assert email.email == "john.doe@email.org"
+
+
+def test_acs_error(client, db, saml_settings):
+    data = {"SAMLResponse": "bad-response"}
+    resp = client.post(
+        reverse("saml_acs", kwargs={"organization_slug": "org"}), data=data
+    )
+    assert resp.status_code == 200
+    assert "socialaccount/authentication_error.html" in (t.name for t in resp.templates)
+
+
+def test_login(
+    client,
+    db,
+    saml_settings,
+):
+    resp = client.get(reverse("saml_login", kwargs={"organization_slug": "org"}))
+    assert resp.status_code == 302
+    assert resp["location"].startswith(
+        "https://dev-123.us.auth0.com/samlp/456?SAMLRequest="
+    )
+
+
+def test_metadata(
+    client,
+    db,
+    saml_settings,
+):
+    resp = client.get(reverse("saml_metadata", kwargs={"organization_slug": "org"}))
+    assert resp.status_code == 200
+    assert resp.content.startswith(
+        b'<?xml version="1.0"?>\n<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata'
+    )
+
+
+def test_sls(auth_client, db, saml_settings, user_factory, sls_saml_request):
+    with patch("allauth.account.adapter.DefaultAccountAdapter.logout") as logout_mock:
+        resp = auth_client.get(
+            reverse("saml_sls", kwargs={"organization_slug": "org"})
+            + "?"
+            + urlencode({"SAMLRequest": sls_saml_request})
+        )
+        assert logout_mock.call_count == 1
+    assert resp.status_code == 302
+    assert resp["location"].startswith(
+        "https://dev-123.us.auth0.com/samlp/456?SAMLResponse="
+    )
+
+
+@pytest.mark.parametrize(
+    "provider_config",
+    [
+        {
+            "idp": {
+                "entity_id": "dummy",
+                "sso_url": "https://idp.org/sso/",
+                "slo_url": "https://idp.saml.org/slo/",
+                "x509cert": "cert",
+            }
+        },
+    ],
+)
+def test_build_saml_config_without_metadata_url(rf, provider_config):
+    request = rf.get("/")
+    config = build_saml_config(request, provider_config, "org")
+    assert config["idp"]["entityId"] == "dummy"
+    assert config["idp"]["x509cert"] == "cert"
+    assert config["idp"]["singleSignOnService"] == {"url": "https://idp.org/sso/"}
+    assert config["idp"]["singleLogoutService"] == {"url": "https://idp.saml.org/slo/"}
+
+
+@pytest.mark.parametrize(
+    "provider_config",
+    [
+        {
+            "idp": {
+                "entity_id": "dummy",
+                "metadata_url": "https://idp.org/sso/",
+            }
+        },
+    ],
+)
+def test_build_saml_config(rf, provider_config):
+    request = rf.get("/")
+    with patch(
+        "onelogin.saml2.idp_metadata_parser.OneLogin_Saml2_IdPMetadataParser.parse_remote"
+    ) as parse_mock:
+        parse_mock.return_value = {
+            "idp": {
+                "entityId": "dummy",
+                "singleSignOnService": {"url": "https://idp.org/sso/"},
+                "singleLogoutService": {"url": "https://idp.saml.org/slo/"},
+                "x509cert": "cert",
+            }
+        }
+        config = build_saml_config(request, provider_config, "org")
+
+    assert config["idp"]["entityId"] == "dummy"
+    assert config["idp"]["x509cert"] == "cert"
+    assert config["idp"]["singleSignOnService"] == {"url": "https://idp.org/sso/"}
+    assert config["idp"]["singleLogoutService"] == {"url": "https://idp.saml.org/slo/"}
diff --git a/test_settings.py b/test_settings.py
index ddca26f4..aeaab7c0 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -130,6 +130,7 @@ INSTALLED_APPS = (
     "allauth.socialaccount.providers.naver",
     "allauth.socialaccount.providers.netiq",
     "allauth.socialaccount.providers.nextcloud",
+    "allauth.socialaccount.providers.notion",
     "allauth.socialaccount.providers.odnoklassniki",
     "allauth.socialaccount.providers.openid",
     "allauth.socialaccount.providers.openid_connect",
@@ -144,6 +145,7 @@ INSTALLED_APPS = (
     "allauth.socialaccount.providers.reddit",
     "allauth.socialaccount.providers.robinhood",
     "allauth.socialaccount.providers.salesforce",
+    "allauth.socialaccount.providers.saml",
     "allauth.socialaccount.providers.sharefile",
     "allauth.socialaccount.providers.shopify",
     "allauth.socialaccount.providers.slack",