Repository: pennersr/django-allauth
Pull request #3364 (2023-08-02 08:00:25)

Settings for checking outdated data when authorizing via telegram.
Added the ability to set the period for checking outdated data when authorizing via telegram.


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..4a3e3977 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -59,10 +59,12 @@ Francis Brito
 Frantisek Malina
 Fred Palmer
 Fábio Santos
+Felipe Faria
 George Whewell
 Griffith Rees
 Guignard Javier
 Guilhem Saurel
+Guillaume Schurck
 Guillaume Vincent
 Guoyu Hao
 Haesung Park
@@ -178,3 +180,4 @@ William Li
 Yaroslav Muravsky
 Yuri Kriachko
 Youcef Mammar
+Anton Goncharov
diff --git a/ChangeLog.rst b/ChangeLog.rst
index d7ab7eb6..76be9d58 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -25,6 +25,14 @@ Note worthy changes
   trend over the years has been towards the simpler and more streamlined form
   "email".
 
+- Added support for SAML 2.0. Thanks to `Dskrpt <https://dskrpt.de>`_
+  for sponsoring the development of this feature!
+
+- Fixed Twitter OAuth2 authentication by using basic auth and adding scope `tweet.read`.
+
+- Added (optional) support for authentication by email for social logins (see
+  ``SOCIALACCOUNT_EMAIL_AUTHENTICATION``).
+
 
 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/account/managers.py b/allauth/account/managers.py
index 74638c49..02e89509 100644
--- a/allauth/account/managers.py
+++ b/allauth/account/managers.py
@@ -1,3 +1,4 @@
+import functools
 from datetime import timedelta
 
 from django.db import models
@@ -101,6 +102,13 @@ class EmailAddressManager(models.Manager):
     def is_verified(self, email):
         return self.filter(email__iexact=email, verified=True).exists()
 
+    def lookup(self, emails):
+        q_list = [Q(email__iexact=e) for e in emails]
+        if not q_list:
+            return self.none()
+        q = functools.reduce(lambda a, b: a | b, q_list)
+        return self.filter(q)
+
 
 class EmailConfirmationManager(models.Manager):
     def all_expired(self):
diff --git a/allauth/account/utils.py b/allauth/account/utils.py
index 78994a99..97e9daac 100644
--- a/allauth/account/utils.py
+++ b/allauth/account/utils.py
@@ -4,7 +4,7 @@ from collections import OrderedDict
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth import update_session_auth_hash
-from django.core.exceptions import FieldDoesNotExist, ValidationError
+from django.core.exceptions import FieldDoesNotExist
 from django.db import models
 from django.db.models import Q
 from django.utils.encoding import force_str
@@ -436,31 +436,29 @@ def user_pk_to_url_str(user):
     This should return a string.
     """
     User = get_user_model()
-    if issubclass(type(User._meta.pk), models.UUIDField):
+    pk_field_class = type(User._meta.pk)
+    if issubclass(pk_field_class, models.UUIDField):
         if isinstance(user.pk, str):
             return user.pk
         return user.pk.hex
+    elif issubclass(pk_field_class, models.IntegerField):
+        return int_to_base36(int(user.pk))
+    return str(user.pk)
 
-    ret = user.pk
-    if isinstance(ret, int):
-        ret = int_to_base36(user.pk)
-    return str(ret)
 
-
-def url_str_to_user_pk(s):
+def url_str_to_user_pk(pk_str):
     User = get_user_model()
-    # TODO: Ugh, isn't there a cleaner way to determine whether or not
-    # the PK is a str-like field?
     remote_field = getattr(User._meta.pk, "remote_field", None)
     if remote_field and getattr(remote_field, "to", None):
         pk_field = User._meta.pk.remote_field.to._meta.pk
     else:
         pk_field = User._meta.pk
-    if issubclass(type(pk_field), models.UUIDField):
-        return pk_field.to_python(s)
-    try:
-        pk_field.to_python("a")
-        pk = s
-    except ValidationError:
-        pk = base36_to_int(s)
+    pk_field_class = type(pk_field)
+    if issubclass(pk_field_class, models.IntegerField):
+        pk = base36_to_int(pk_str)
+        # always call to_python() -- because there are fields like HashidField
+        # that derive from IntegerField.
+        pk = pk_field.to_python(pk)
+    else:
+        pk = pk_field.to_python(pk_str)
     return pk
diff --git a/allauth/locale/nl/LC_MESSAGES/django.po b/allauth/locale/nl/LC_MESSAGES/django.po
index 14633c2e..538ad06c 100644
--- a/allauth/locale/nl/LC_MESSAGES/django.po
+++ b/allauth/locale/nl/LC_MESSAGES/django.po
@@ -9,7 +9,7 @@ msgstr ""
 "Project-Id-Version: django-allauth\n"
 "Report-Msgid-Bugs-To: \n"
 "POT-Creation-Date: 2023-07-24 15:33-0500\n"
-"PO-Revision-Date: 2021-12-09 16:29+0100\n"
+"PO-Revision-Date: 2023-08-06 20:51+0200\n"
 "Last-Translator: pennersr <raymond.penners@intenct.nl>\n"
 "Language-Team: Dutch (http://www.transifex.com/projects/p/django-allauth/"
 "language/nl/)\n"
@@ -227,7 +227,7 @@ msgstr ""
 
 #: socialaccount/models.py:54
 msgid "secret key"
-msgstr ""
+msgstr "geheime sleutel"
 
 #: socialaccount/models.py:57
 msgid "API secret, client secret, or consumer secret"
@@ -235,7 +235,7 @@ msgstr ""
 
 #: socialaccount/models.py:60
 msgid "Key"
-msgstr ""
+msgstr "Sleutel"
 
 #: socialaccount/models.py:79
 msgid "social application"
@@ -247,19 +247,19 @@ msgstr ""
 
 #: socialaccount/models.py:115
 msgid "uid"
-msgstr ""
+msgstr "uid"
 
 #: socialaccount/models.py:117
 msgid "last login"
-msgstr ""
+msgstr "laatst ingelogd"
 
 #: socialaccount/models.py:118
 msgid "date joined"
-msgstr ""
+msgstr "datum toegetreden"
 
 #: socialaccount/models.py:119
 msgid "extra data"
-msgstr ""
+msgstr "extra data"
 
 #: socialaccount/models.py:123
 msgid "social account"
@@ -287,7 +287,7 @@ msgstr ""
 
 #: socialaccount/models.py:167
 msgid "expires at"
-msgstr ""
+msgstr "verloopt op"
 
 #: socialaccount/models.py:172
 msgid "social application token"
@@ -299,7 +299,7 @@ msgstr ""
 
 #: socialaccount/providers/douban/views.py:36
 msgid "Invalid profile data"
-msgstr ""
+msgstr "Ongeldige profiel data"
 
 #: socialaccount/providers/oauth/client.py:84
 #: socialaccount/providers/pocket/client.py:37
@@ -343,11 +343,11 @@ msgstr "Dit account is niet actief"
 
 #: templates/account/base.html:16
 msgid "Messages:"
-msgstr ""
+msgstr "Berichten:"
 
 #: templates/account/base.html:26
 msgid "Menu:"
-msgstr ""
+msgstr "Menu:"
 
 #: templates/account/base.html:29 templates/account/email_change.html:31
 #, fuzzy
@@ -433,10 +433,20 @@ msgid ""
 "\n"
 "%(password_reset_url)s"
 msgstr ""
+"Je ontvangt deze e-mail omdat jij of iemand anders geprobeerd heeft een account\n"
+"te registreren met dit e-mail adres:\n"
+"\n"
+"%(email)s\n"
+"\n"
+"Echter, er bestaat al een account met dit e-mail adres.  Als je dit vergeten\n"
+"was, gebruik dan de wachtwoord vergeten procedure om de toegang tot je account\n"
+"te herstellen:\n"
+"\n"
+"%(password_reset_url)s"
 
 #: templates/account/email/account_already_exists_subject.txt:3
 msgid "Account Already Exists"
-msgstr ""
+msgstr "Account bestaat al"
 
 #: templates/account/email/base_message.txt:1
 #, python-format
diff --git a/allauth/socialaccount/app_settings.py b/allauth/socialaccount/app_settings.py
index e8ce7e0e..844665e7 100644
--- a/allauth/socialaccount/app_settings.py
+++ b/allauth/socialaccount/app_settings.py
@@ -84,6 +84,25 @@ class AppSettings(object):
 
         return self._setting("EMAIL_VERIFICATION", account_settings.EMAIL_VERIFICATION)
 
+    @property
+    def EMAIL_AUTHENTICATION(self):
+        """Consider a scenario where a social login occurs, and the social
+        account comes with a verified email address (verified by the account
+        provider), but that email address is already taken by a local user
+        account. Additionally, assume that the local user account does not have
+        any social account connected. Now, if the provider can be fully trusted,
+        you can argue that we should treat this scenario as a login to the
+        existing local user account even if the local account does not already
+        have the social account connected, because -- according to the provider
+        -- the user logging in has ownership of the email address.  This is how
+        this scenario is handled when `EMAIL_AUTHENTICATION` is set to
+        `True`. As this implies that an untrustworthy provider can login to any
+        local account by fabricating social account data, this setting defaults
+        to `False`. Only set it to `True` if you are using providers that can be
+        fully trusted.
+        """
+        return self._setting("EMAIL_AUTHENTICATION", False)
+
     @property
     def ADAPTER(self):
         return self._setting(
diff --git a/allauth/socialaccount/models.py b/allauth/socialaccount/models.py
index 58b0cb17..e12374fe 100644
--- a/allauth/socialaccount/models.py
+++ b/allauth/socialaccount/models.py
@@ -253,7 +253,6 @@ class SocialLogin(object):
         Saves a new account. Note that while the account is new,
         the user may be an existing one (when connecting accounts)
         """
-        assert not self.is_existing
         user = self.user
         user.save()
         self.account.user = user
@@ -269,15 +268,22 @@ class SocialLogin(object):
 
     @property
     def is_existing(self):
+        """When `False`, this social login represents a temporary account, not
+        yet backed by a database record.
         """
-        Account is temporary, not yet backed by a database record.
-        """
-        return self.account.pk is not None
+        return self.user.pk is not None
 
     def lookup(self):
+        """Look up the existing local user account to which this social login
+        points, if any.
         """
-        Lookup existing account, if any.
-        """
+        if not self._lookup_by_socialaccount():
+            if app_settings.EMAIL_AUTHENTICATION or app_settings.PROVIDERS.get(
+                self.account.provider, {}
+            ).get("EMAIL_AUTHENTICATION", False):
+                self._lookup_by_email()
+
+    def _lookup_by_socialaccount(self):
         assert not self.is_existing
         try:
             a = SocialAccount.objects.get(
@@ -306,9 +312,20 @@ class SocialLogin(object):
                 except SocialToken.DoesNotExist:
                     self.token.account = a
                     self.token.save()
+            return True
         except SocialAccount.DoesNotExist:
             pass
 
+    def _lookup_by_email(self):
+        emails = [e.email for e in self.email_addresses if e.verified]
+        if not emails:
+            return
+        address = (
+            EmailAddress.objects.lookup(emails).order_by("-verified", "user_id").first()
+        )
+        if address:
+            self.user = address.user
+
     def get_redirect_url(self, request):
         url = self.state.get("next")
         return url
diff --git a/allauth/socialaccount/providers/amazon/views.py b/allauth/socialaccount/providers/amazon/views.py
index 56bff54b..b72481ef 100644
--- a/allauth/socialaccount/providers/amazon/views.py
+++ b/allauth/socialaccount/providers/amazon/views.py
@@ -15,7 +15,6 @@ class AmazonOAuth2Adapter(OAuth2Adapter):
     authorize_url = "http://www.amazon.com/ap/oa"
     profile_url = "https://api.amazon.com/user/profile"
     supports_state = False
-    redirect_uri_protocol = "https"
 
     def complete_login(self, request, app, token, **kwargs):
         response = requests.get(self.profile_url, params={"access_token": token})
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/dropbox/views.py b/allauth/socialaccount/providers/dropbox/views.py
index 8a27086b..0c0c3046 100644
--- a/allauth/socialaccount/providers/dropbox/views.py
+++ b/allauth/socialaccount/providers/dropbox/views.py
@@ -14,7 +14,6 @@ class DropboxOAuth2Adapter(OAuth2Adapter):
     access_token_url = "https://api.dropbox.com/oauth2/token"
     authorize_url = "https://www.dropbox.com/oauth2/authorize"
     profile_url = "https://api.dropbox.com/2/users/get_current_account"
-    redirect_uri_protocol = "https"
 
     def complete_login(self, request, app, token, **kwargs):
         response = requests.post(
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/allauth/socialaccount/providers/telegram/provider.py b/allauth/socialaccount/providers/telegram/provider.py
index 801bd75b..4a369922 100644
--- a/allauth/socialaccount/providers/telegram/provider.py
+++ b/allauth/socialaccount/providers/telegram/provider.py
@@ -32,5 +32,17 @@ class TelegramProvider(Provider):
             ret["username"] = data.get("username")
         return ret
 
+    def get_auth_date_validity(self):
+        auth_date_validity = 30
+        settings = self.get_settings()
+        if "AUTH_PARAMS" in settings:
+            auth_date_validity = settings.get("AUTH_PARAMS").get(
+                "auth_date_validity", auth_date_validity
+            )
+        auth_date_validity = self.app.settings.get(
+            "auth_date_validity", auth_date_validity
+        )
+        return auth_date_validity
+
 
 provider_classes = [TelegramProvider]
diff --git a/allauth/socialaccount/providers/telegram/views.py b/allauth/socialaccount/providers/telegram/views.py
index 9b44f337..5ee7fe35 100644
--- a/allauth/socialaccount/providers/telegram/views.py
+++ b/allauth/socialaccount/providers/telegram/views.py
@@ -62,7 +62,8 @@ class CallbackView(View):
             token_sha256, payload.encode(), hashlib.sha256
         ).hexdigest()
         auth_date = int(data.pop("auth_date"))
-        if hash != expected_hash or time.time() - auth_date > 30:
+        auth_date_validity = provider.get_auth_date_validity()
+        if hash != expected_hash or time.time() - auth_date > auth_date_validity:
             return render_authentication_error(
                 request, provider_id=provider.id, extra_context={"response": data}
             )
diff --git a/allauth/socialaccount/providers/twitter_oauth2/provider.py b/allauth/socialaccount/providers/twitter_oauth2/provider.py
index 87884e55..f0587515 100644
--- a/allauth/socialaccount/providers/twitter_oauth2/provider.py
+++ b/allauth/socialaccount/providers/twitter_oauth2/provider.py
@@ -49,7 +49,7 @@ class TwitterOAuth2Provider(OAuth2Provider):
         return settings.get("FIELDS", default_fields)
 
     def get_default_scope(self):
-        return ["users.read"]
+        return ["users.read", "tweet.read"]
 
 
 provider_classes = [TwitterOAuth2Provider]
diff --git a/allauth/socialaccount/providers/twitter_oauth2/views.py b/allauth/socialaccount/providers/twitter_oauth2/views.py
index c9e833be..112568d1 100644
--- a/allauth/socialaccount/providers/twitter_oauth2/views.py
+++ b/allauth/socialaccount/providers/twitter_oauth2/views.py
@@ -14,6 +14,7 @@ class TwitterOAuth2Adapter(OAuth2Adapter):
     access_token_url = "https://api.twitter.com/2/oauth2/token"
     authorize_url = "https://twitter.com/i/oauth2/authorize"
     profile_url = "https://api.twitter.com/2/users/me"
+    basic_auth = True
 
     def complete_login(self, request, app, access_token, **kwargs):
         extra_data = self.get_user_info(access_token)
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 874d8be5..fd2a3ac3 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -298,6 +298,30 @@ SOCIALACCOUNT_AUTO_SIGNUP (=True)
   arises due to a duplicate email address the signup form will still
   kick in.
 
+SOCIALACCOUNT_EMAIL_AUTHENTICATION (=False)
+  Consider a scenario where a social login occurs, and the social account comes
+  with a verified email address (verified by the account provider), but that
+  email address is already taken by a local user account. Additionally, assume
+  that the local user account does not have any social account connected. Now,
+  if the provider can be fully trusted, you can argue that we should treat this
+  scenario as a login to the existing local user account even if the local
+  account does not already have the social account connected, because --
+  according to the provider -- the user logging in has ownership of the email
+  address.  This is how this scenario is handled when
+  ``SOCIALACCOUNT_EMAIL_AUTHENTICATION`` is set to ``True``. As this implies
+  that an untrustworthy provider can login to any local account by fabricating
+  social account data, this setting defaults to ``False``. Only set it to
+  ``True`` if you are using providers that can be fully trusted. Instead of
+  turning this on globally, you can also turn it on selectively per provider,
+  for example::
+
+      SOCIALACCOUNT_PROVIDERS = {
+        'google': {
+            'EMAIL_AUTHENTICATION': True
+        }
+      }
+
+
 SOCIALACCOUNT_EMAIL_VERIFICATION (=ACCOUNT_EMAIL_VERIFICATION)
   As ``ACCOUNT_EMAIL_VERIFICATION``, but for social accounts.
 
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/docs/providers/telegram.rst b/docs/providers/telegram.rst
index a7ddf3e6..706cc679 100644
--- a/docs/providers/telegram.rst
+++ b/docs/providers/telegram.rst
@@ -1,6 +1,14 @@
 Telegram
 --------
 
+Telegram does not strictly regulate the authorization expiration
+time, so you may need to set your own expiration time, which can be
+less than the default value.
+You can also set this parameter in Social applications settings as
+json ``{"auth_date_validity": 100}``.
+The default value of the ``auth_date_validity`` is 30 seconds.
+
+
 .. code-block:: python
 
     SOCIALACCOUNT_PROVIDERS = {
@@ -8,6 +16,13 @@ Telegram
             'APP': {
                 'client_id': '<bot_id>',
                 'secret': '<bot token>',
-            }
+            },
+            'AUTH_PARAMS': {'auth_date_validity': 30},
         }
     }
+
+.. code-block:: python
+
+
+Attention! If your server time is different from the telegram
+server time, you need NTP.
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/account/tests/test_utils.py b/allauth/account/tests/test_utils.py
index 8a3fda29..526b4139 100644
--- a/allauth/account/tests/test_utils.py
+++ b/allauth/account/tests/test_utils.py
@@ -42,8 +42,10 @@ class UtilsTests(TestCase):
             self.assertEqual(url_str_to_user_pk(self.user_id), uuid.UUID(self.user_id))
 
     def test_pk_to_url_string_identifies_UUID_as_stringlike(self):
-        user = UUIDUser(is_active=True, email="john@example.com", username="john")
-        self.assertEqual(user_pk_to_url_str(user), str(user.pk))
+        with patch("allauth.account.utils.get_user_model") as mocked_gum:
+            mocked_gum.return_value = UUIDUser
+            user = UUIDUser(is_active=True, email="john@example.com", username="john")
+            self.assertEqual(user_pk_to_url_str(user), user.pk.hex)
 
     @override_settings(ACCOUNT_PRESERVE_USERNAME_CASING=False)
     def test_username_lower_cased(self):
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/conftest.py b/allauth/socialaccount/conftest.py
index b122a189..a3bf68f8 100644
--- a/allauth/socialaccount/conftest.py
+++ b/allauth/socialaccount/conftest.py
@@ -6,9 +6,9 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
 
 @pytest.fixture
 def sociallogin_factory(user_factory):
-    def factory(email=None, with_email=True):
+    def factory(email=None, with_email=True, provider="unittest-server", uid="123"):
         user = user_factory(email=email, commit=False, with_email=with_email)
-        account = SocialAccount(provider="unittest-server", uid="123")
+        account = SocialAccount(provider=provider, uid=uid)
         sociallogin = SocialLogin(user=user, account=account)
         if with_email:
             sociallogin.email_addresses = [
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/allauth/socialaccount/tests/test_login.py b/allauth/socialaccount/tests/test_login.py
new file mode 100644
index 00000000..552cb317
--- /dev/null
+++ b/allauth/socialaccount/tests/test_login.py
@@ -0,0 +1,52 @@
+import uuid
+
+from django.contrib.auth.models import AnonymousUser
+from django.contrib.messages.middleware import MessageMiddleware
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.urls import reverse
+
+import pytest
+
+from allauth.socialaccount.helpers import complete_social_login
+from allauth.socialaccount.models import SocialAccount
+
+
+@pytest.mark.parametrize("setting", ["off", "on-global", "on-provider"])
+def test_email_authentication(
+    db, setting, settings, user_factory, sociallogin_factory, client, rf, mailoutbox
+):
+    """Tests that when an already existing email is given at the social signup
+    form, enumeration preventation kicks in.
+    """
+    settings.ACCOUNT_EMAIL_REQUIRED = True
+    settings.ACCOUNT_UNIQUE_EMAIL = True
+    settings.ACCOUNT_USERNAME_REQUIRED = False
+    settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
+    settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
+    settings.SOCIALACCOUNT_AUTO_SIGNUP = True
+    if setting == "on-global":
+        settings.SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
+    elif setting == "on-provider":
+        settings.SOCIALACCOUNT_PROVIDERS["google"] = {"EMAIL_AUTHENTICATION": True}
+    else:
+        settings.SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
+
+    user = user_factory()
+    SocialAccount.objects.create(
+        user=user,
+        provider="google",
+        uid=uuid.uuid4().hex,
+    )
+
+    sociallogin = sociallogin_factory(email=user.email, provider="google")
+
+    request = rf.get("/")
+    SessionMiddleware(lambda request: None).process_request(request)
+    MessageMiddleware(lambda request: None).process_request(request)
+    request.user = AnonymousUser()
+
+    resp = complete_social_login(request, sociallogin)
+    if setting == "off":
+        assert resp["location"] == reverse("account_email_verification_sent")
+    else:
+        assert resp["location"] == "/accounts/profile/"
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",