Repository: pennersr/django-allauth
Pull request #3828 (2024-05-17 21:54:19)

feat(atlassian): add user's name as account string representation
# 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] N/A ~~JavaScript code should adhere to [StandardJS](https://standardjs.com).~~
 - [x] N/A ~~If your changes are significant, please update `ChangeLog.rst`.~~
 - [x] N/A ~~If your change is substantial, feel free to add yourself to `AUTHORS`.~~

 ## Provider Specifics

 In case you add a new provider:

- [x] N/A ~~Make sure unit tests are available.~~
- [x] N/A ~~Add an entry of your provider in `test_settings.py::INSTALLED_APPS` and `docs/installation.rst::INSTALLED_APPS`.~~
- [x] N/A ~~Add documentation to `docs/providers/<provider name>.rst` and `docs/providers/index.rst` Provider Specifics toctree.~~
- [x] N/A ~~Add an entry to the list of supported providers over at `docs/overview.rst`.~~


coveralls: 
[![Coverage Status](https://coveralls.io/builds/67565386/badge)](https://coveralls.io/builds/67565386)

coverage: 95.591%. remained the same
when pulling **7642261a7da402705959eaf597adc37f902cfcff on Ahuahuachi:main**
into **5e4a7e30abb0021d929dbd05f82d09f8132e710f on pennersr:main**.


Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index c403c569..3b130f42 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -1,7 +1,14 @@
 0.63.2 (unreleased)
 *******************
 
-- ...
+- ``allauth.headless`` now supports the ``is_open_for_signup()`` adapter method.
+  In case signup is closed, a 403 is returned during signup.
+
+- Connecting a third-party account in ``HEADLESS_ONLY`` mode failed if the
+  connections view could not be reversed, fixed.
+
+- In case a headless attempt was made to connect a third-party account that was already
+  connected to a different account, no error was communicated to the frontend. Fixed.
 
 
 0.63.1 (2024-05-17)
diff --git a/allauth/account/internal/flows/reauthentication.py b/allauth/account/internal/flows/reauthentication.py
index 827c51df..e3a09e21 100644
--- a/allauth/account/internal/flows/reauthentication.py
+++ b/allauth/account/internal/flows/reauthentication.py
@@ -1,5 +1,19 @@
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+
 from allauth.account.authentication import record_authentication
 
 
+STATE_SESSION_KEY = "account_reauthentication_state"
+
+
 def reauthenticate_by_password(request):
     record_authentication(request, method="password", reauthenticated=True)
+
+
+def stash_and_reauthenticate(request, state, callback):
+    request.session[STATE_SESSION_KEY] = {
+        "state": state,
+        "callback": callback,
+    }
+    return HttpResponseRedirect(reverse("account_reauthenticate"))
diff --git a/allauth/account/reauthentication.py b/allauth/account/reauthentication.py
index e8ca39ef..0d2137fe 100644
--- a/allauth/account/reauthentication.py
+++ b/allauth/account/reauthentication.py
@@ -2,12 +2,16 @@ import time
 
 from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.http import HttpResponseRedirect
-from django.urls import resolve, reverse
+from django.urls import resolve
 from django.utils.http import urlencode
 
 from allauth.account import app_settings
 from allauth.account.adapter import get_adapter
 from allauth.account.authentication import get_authentication_records
+from allauth.account.internal.flows.reauthentication import (
+    STATE_SESSION_KEY,
+    stash_and_reauthenticate,
+)
 from allauth.core.exceptions import ReauthenticationRequired
 from allauth.core.internal.httpkit import (
     deserialize_request,
@@ -16,9 +20,6 @@ from allauth.core.internal.httpkit import (
 from allauth.utils import import_callable
 
 
-STATE_SESSION_KEY = "account_reauthentication_state"
-
-
 def suspend_request(request, redirect_to):
     path = request.get_full_path()
     if request.method == "POST":
@@ -52,11 +53,7 @@ def reauthenticate_then_callback(request, serialize_state, callback):
     # XHR.
     if did_recently_authenticate(request):
         return None
-    request.session[STATE_SESSION_KEY] = {
-        "state": serialize_state(request),
-        "callback": callback,
-    }
-    return HttpResponseRedirect(reverse("account_reauthenticate"))
+    return stash_and_reauthenticate(request, serialize_state(request), callback)
 
 
 def raise_if_reauthentication_required(request):
diff --git a/allauth/core/exceptions.py b/allauth/core/exceptions.py
index 8d5a655b..84a7e30f 100644
--- a/allauth/core/exceptions.py
+++ b/allauth/core/exceptions.py
@@ -15,3 +15,11 @@ class ReauthenticationRequired(Exception):
     """
 
     pass
+
+
+class SignupClosedException(Exception):
+    """
+    Throws when attemtping to signup while signup is closed.
+    """
+
+    pass
diff --git a/allauth/headless/account/views.py b/allauth/headless/account/views.py
index 726fb5f0..c782847f 100644
--- a/allauth/headless/account/views.py
+++ b/allauth/headless/account/views.py
@@ -26,7 +26,11 @@ from allauth.headless.account.inputs import (
     SignupInput,
     VerifyEmailInput,
 )
-from allauth.headless.base.response import APIResponse, AuthenticationResponse
+from allauth.headless.base.response import (
+    APIResponse,
+    AuthenticationResponse,
+    ForbiddenResponse,
+)
 from allauth.headless.base.views import APIView, AuthenticatedAPIView
 from allauth.headless.internal.restkit.response import ErrorResponse
 
@@ -80,6 +84,8 @@ class SignupView(APIView):
     input_class = SignupInput
 
     def post(self, request, *args, **kwargs):
+        if not get_account_adapter().is_open_for_signup(request):
+            return ForbiddenResponse(request)
         user, resp = self.input.try_save(request)
         if not resp:
             try:
diff --git a/allauth/headless/base/response.py b/allauth/headless/base/response.py
index 86e359fb..96216e45 100644
--- a/allauth/headless/base/response.py
+++ b/allauth/headless/base/response.py
@@ -94,6 +94,11 @@ class UnauthorizedResponse(BaseAuthenticationResponse):
         super().__init__(request, user=None, status=status)
 
 
+class ForbiddenResponse(APIResponse):
+    def __init__(self, request):
+        super().__init__(request, status=403)
+
+
 def user_data(user):
     """Basic user data, also exposed in partly authenticated scenario's
     (e.g. password reset, email verification).
@@ -112,23 +117,35 @@ def user_data(user):
     return ret
 
 
+def get_config_data(request):
+    data = {
+        "authentication_method": account_settings.AUTHENTICATION_METHOD,
+        "is_open_for_signup": get_account_adapter().is_open_for_signup(request),
+    }
+    return {"account": data}
+
+
 class ConfigResponse(APIResponse):
     def __init__(self, request):
-        data = {
-            "account": {"authentication_method": account_settings.AUTHENTICATION_METHOD}
-        }
+        data = get_config_data(request)
         if allauth_settings.SOCIALACCOUNT_ENABLED:
-            from allauth.headless.socialaccount.response import get_config_data
+            from allauth.headless.socialaccount.response import (
+                get_config_data as get_socialaccount_config_data,
+            )
 
-            data.update(get_config_data(request))
+            data.update(get_socialaccount_config_data(request))
         if allauth_settings.MFA_ENABLED:
-            from allauth.headless.mfa.response import get_config_data
+            from allauth.headless.mfa.response import (
+                get_config_data as get_mfa_config_data,
+            )
 
-            data.update(get_config_data(request))
+            data.update(get_mfa_config_data(request))
         if allauth_settings.USERSESSIONS_ENABLED:
-            from allauth.headless.usersessions.response import get_config_data
+            from allauth.headless.usersessions.response import (
+                get_config_data as get_usersessions_config_data,
+            )
 
-            data.update(get_config_data(request))
+            data.update(get_usersessions_config_data(request))
         return super().__init__(request, data=data)
 
 
diff --git a/allauth/headless/internal/restkit/response.py b/allauth/headless/internal/restkit/response.py
index cc335da0..7adf0a59 100644
--- a/allauth/headless/internal/restkit/response.py
+++ b/allauth/headless/internal/restkit/response.py
@@ -1,3 +1,4 @@
+from django.forms.utils import ErrorList
 from django.http import JsonResponse
 from django.utils.cache import add_never_cache_headers
 
@@ -16,8 +17,11 @@ class APIResponse(JsonResponse):
 
 
 class ErrorResponse(APIResponse):
-    def __init__(self, request, input=None, status=400):
+    def __init__(self, request, exception=None, input=None, status=400):
         errors = []
+        if exception is not None:
+            error_datas = ErrorList(exception.error_list).get_json_data()
+            errors.extend(error_datas)
         if input is not None:
             for field, error_list in input.errors.items():
                 error_datas = error_list.get_json_data()
diff --git a/allauth/headless/socialaccount/inputs.py b/allauth/headless/socialaccount/inputs.py
index 5c6f7720..4cad014b 100644
--- a/allauth/headless/socialaccount/inputs.py
+++ b/allauth/headless/socialaccount/inputs.py
@@ -7,7 +7,8 @@ from allauth.socialaccount.adapter import (
     get_adapter as get_socialaccount_adapter,
 )
 from allauth.socialaccount.forms import SignupForm
-from allauth.socialaccount.models import SocialAccount
+from allauth.socialaccount.models import SocialAccount, SocialApp
+from allauth.socialaccount.providers import registry
 from allauth.socialaccount.providers.base.constants import AuthProcess
 
 
@@ -54,39 +55,56 @@ class ProviderTokenInput(inputs.Input):
         cleaned_data = super().clean()
         token = self.data.get("token")
         adapter = get_adapter()
-        client_id = None
         if not isinstance(token, dict):
             self.add_error("token", adapter.validation_error("invalid_token"))
             token = None
-        else:
-            client_id = token.get("client_id")
-            if not isinstance(client_id, str):
-                self.add_error("token", adapter.validation_error("client_id_required"))
-                client_id = None
 
         provider_id = cleaned_data.get("provider")
         provider = None
-        if provider_id and token and client_id:
-            provider = get_socialaccount_adapter().get_provider(
-                context.request, provider_id, client_id=client_id
-            )
-            if not provider.supports_token_authentication:
-                self.add_error(
-                    "provider",
-                    adapter.validation_error("token_authentication_not_supported"),
-                )
-
-            elif provider.app.client_id != client_id:
-                self.add_error("token", adapter.validation_error("client_id_mismatch"))
+        if provider_id and token:
+            provider_class = registry.get_class(provider_id)
+            # If `provider_id` is a sub provider ID we won't find it by class.
+            client_id_required = provider_class is None or provider_class.uses_apps
+            client_id = token.get("client_id")
+            if client_id_required and not isinstance(client_id, str):
+                self.add_error("token", adapter.validation_error("client_id_required"))
             else:
-                id_token = token.get("id_token")
-                access_token = token.get("access_token")
-                if (
-                    (id_token is not None and not isinstance(id_token, str))
-                    or (access_token is not None and not isinstance(access_token, str))
-                    or (not id_token and not access_token)
-                ):
-                    self.add_error("token", adapter.validation_error("token_required"))
+                try:
+                    provider = get_socialaccount_adapter().get_provider(
+                        context.request, provider_id, client_id=client_id
+                    )
+                except SocialApp.DoesNotExist:
+                    self.add_error("token", adapter.validation_error("invalid_token"))
+                else:
+                    if not provider.supports_token_authentication:
+                        self.add_error(
+                            "provider",
+                            adapter.validation_error(
+                                "token_authentication_not_supported"
+                            ),
+                        )
+                    elif (
+                        provider.uses_apps
+                        and client_id
+                        and provider.app.client_id != client_id
+                    ):
+                        self.add_error(
+                            "token", adapter.validation_error("client_id_mismatch")
+                        )
+                    else:
+                        id_token = token.get("id_token")
+                        access_token = token.get("access_token")
+                        if (
+                            (id_token is not None and not isinstance(id_token, str))
+                            or (
+                                access_token is not None
+                                and not isinstance(access_token, str)
+                            )
+                            or (not id_token and not access_token)
+                        ):
+                            self.add_error(
+                                "token", adapter.validation_error("token_required")
+                            )
         if not self.errors:
             cleaned_data["provider"] = provider
             try:
diff --git a/allauth/headless/socialaccount/internal.py b/allauth/headless/socialaccount/internal.py
index 154c0034..b77bacd7 100644
--- a/allauth/headless/socialaccount/internal.py
+++ b/allauth/headless/socialaccount/internal.py
@@ -1,7 +1,11 @@
+from django.core.exceptions import PermissionDenied, ValidationError
 from django.http import HttpResponseRedirect
 
 from allauth import app_settings as allauth_settings
-from allauth.core.exceptions import ImmediateHttpResponse
+from allauth.core.exceptions import (
+    ImmediateHttpResponse,
+    SignupClosedException,
+)
 from allauth.core.internal import httpkit
 from allauth.headless.internal.authkit import AuthenticationStatus
 from allauth.socialaccount.internal import flows, statekit
@@ -46,30 +50,45 @@ def on_authentication_error(
     raise ImmediateHttpResponse(HttpResponseRedirect(next_url))
 
 
+def complete_token_login(request, sociallogin):
+    flows.login.complete_login(request, sociallogin, raises=True)
+
+
 def complete_login(request, sociallogin):
     """
     Called when `sociallogin.is_headless`.
     """
-    flows.login.complete_login(request, sociallogin)
-    # At this stage, we're either:
-    # 1) logged in (or in of the login pipeline stages, such as email verification)
-    # 2) auto signed up -- a pipeline stage, so see 1)
-    # 3) performing a social signup
-    # 4) Stopped, due to not being open-for-signup
-    # It would be good to refactor the above into a more generic social login
-    # pipeline with clear stages, but for now the /auth endpoint properly responds
-    # for cases 1-3.
-    next_url = sociallogin.state.get("next")
-    status = AuthenticationStatus(request)
-    if all(
-        [
-            not status.is_authenticated,
-            not status.has_pending_signup,
-            not status.get_pending_stage(),
-        ]
-    ):
+    error = None
+    try:
+        flows.login.complete_login(request, sociallogin, raises=True)
+    except SignupClosedException:
+        error = "signup_closed"
+    except PermissionDenied:
+        error = "permission_denied"
+    except ValidationError as e:
+        error = e.code
+    else:
+        # At this stage, we're either:
+        # 1) logged in (or in of the login pipeline stages, such as email verification)
+        # 2) auto signed up -- a pipeline stage, so see 1)
+        # 3) performing a social signup
+
+        # 4) Stopped, due to not being open-for-signup
+        # It would be good to refactor the above into a more generic social login
+        # pipeline with clear stages, but for now the /auth endpoint properly responds
+        status = AuthenticationStatus(request)
+        if all(
+            [
+                not status.is_authenticated,
+                not status.has_pending_signup,
+                not status.get_pending_stage(),
+            ]
+        ):
+            error = AuthError.UNKNOWN
+    next_url = sociallogin.state["next"]
+    if error:
         next_url = httpkit.add_query_params(
             next_url,
-            {"error": AuthError.UNKNOWN, "error_process": sociallogin.state["process"]},
+            {"error": error, "error_process": sociallogin.state["process"]},
         )
     return HttpResponseRedirect(next_url)
diff --git a/allauth/headless/socialaccount/response.py b/allauth/headless/socialaccount/response.py
index 011ea7fc..5cff413c 100644
--- a/allauth/headless/socialaccount/response.py
+++ b/allauth/headless/socialaccount/response.py
@@ -25,7 +25,7 @@ def provider_flows(request):
     if providers:
         redirect_providers = [p.id for p in providers if p.supports_redirect]
         token_providers = [p.id for p in providers if p.supports_token_authentication]
-        if redirect_providers:
+        if redirect_providers and request.allauth.headless.client == Client.BROWSER:
             flows.append(
                 {
                     "id": Flow.PROVIDER_REDIRECT,
diff --git a/allauth/headless/socialaccount/views.py b/allauth/headless/socialaccount/views.py
index dc595d71..485ecc47 100644
--- a/allauth/headless/socialaccount/views.py
+++ b/allauth/headless/socialaccount/views.py
@@ -1,15 +1,23 @@
 from django.core.exceptions import ValidationError
 
-from allauth.headless.base.response import AuthenticationResponse
+from allauth.core.exceptions import SignupClosedException
+from allauth.headless.base.response import (
+    AuthenticationResponse,
+    ForbiddenResponse,
+)
 from allauth.headless.base.views import APIView, AuthenticatedAPIView
+from allauth.headless.internal.restkit.response import ErrorResponse
 from allauth.headless.socialaccount.forms import RedirectToProviderForm
 from allauth.headless.socialaccount.inputs import (
     DeleteProviderAccountInput,
     ProviderTokenInput,
     SignupInput,
 )
-from allauth.headless.socialaccount.internal import complete_login
+from allauth.headless.socialaccount.internal import complete_token_login
 from allauth.headless.socialaccount.response import SocialAccountsResponse
+from allauth.socialaccount.adapter import (
+    get_adapter as get_socialaccount_adapter,
+)
 from allauth.socialaccount.helpers import render_authentication_error
 from allauth.socialaccount.internal import flows
 from allauth.socialaccount.models import SocialAccount
@@ -19,6 +27,10 @@ class ProviderSignupView(APIView):
     input_class = SignupInput
 
     def post(self, request, *args, **kwargs):
+        if not get_socialaccount_adapter().is_open_for_signup(
+            request, self.sociallogin
+        ):
+            return ForbiddenResponse(request)
         flows.signup.signup_by_form(self.request, self.sociallogin, self.input)
         return AuthenticationResponse(request)
 
@@ -75,5 +87,10 @@ class ProviderTokenView(APIView):
 
     def post(self, request, *args, **kwargs):
         sociallogin = self.input.cleaned_data["sociallogin"]
-        complete_login(request, sociallogin)
+        try:
+            complete_token_login(request, sociallogin)
+        except ValidationError as e:
+            return ErrorResponse(self.request, exception=e)
+        except SignupClosedException:
+            return ForbiddenResponse(self.request)
         return AuthenticationResponse(self.request)
diff --git a/allauth/socialaccount/adapter.py b/allauth/socialaccount/adapter.py
index 221be707..aa15a694 100644
--- a/allauth/socialaccount/adapter.py
+++ b/allauth/socialaccount/adapter.py
@@ -43,6 +43,9 @@ class DefaultSocialAccountAdapter(BaseAdapter):
         "disconnect_last": _(
             "You cannot disconnect your last remaining third-party account."
         ),
+        "connected_other": _(
+            "The third-party account is already connected to a different account."
+        ),
     }
 
     def pre_social_login(self, request, sociallogin):
diff --git a/allauth/socialaccount/internal/flows/connect.py b/allauth/socialaccount/internal/flows/connect.py
index c5a4183c..f3578287 100644
--- a/allauth/socialaccount/internal/flows/connect.py
+++ b/allauth/socialaccount/internal/flows/connect.py
@@ -1,14 +1,14 @@
 from django.contrib import messages
+from django.core.exceptions import PermissionDenied, ValidationError
 from django.http import HttpResponseRedirect
 
 from allauth import app_settings as allauth_settings
 from allauth.account import app_settings as account_settings
 from allauth.account.adapter import get_adapter as get_account_adapter
+from allauth.account.internal import flows
 from allauth.account.models import EmailAddress
-from allauth.account.reauthentication import (
-    raise_if_reauthentication_required,
-    reauthenticate_then_callback,
-)
+from allauth.account.reauthentication import raise_if_reauthentication_required
+from allauth.core.exceptions import ReauthenticationRequired
 from allauth.socialaccount import signals
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialAccount, SocialLogin
@@ -70,35 +70,55 @@ def resume_connect(request, serialized_state):
 
 
 def connect(request, sociallogin):
-    if request.user.is_anonymous:
+    try:
+        ok, action, message = do_connect(request, sociallogin)
+    except PermissionDenied:
         # This should not happen. Simply redirect to the connections
         # view (which has a login required)
         connect_redirect_url = get_adapter().get_connect_redirect_url(
             request, sociallogin.account
         )
         return HttpResponseRedirect(connect_redirect_url)
-    if account_settings.REAUTHENTICATION_REQUIRED:
-        response = reauthenticate_then_callback(
+    except ReauthenticationRequired:
+        return flows.reauthentication.stash_and_reauthenticate(
             request,
-            lambda request: sociallogin.serialize(),
+            sociallogin.serialize(),
             "allauth.socialaccount.internal.flows.connect.resume_connect",
         )
-        if response:
-            return response
-    level = messages.INFO
+    except ValidationError:
+        ok, action, message = (
+            False,
+            None,
+            "socialaccount/messages/account_connected_other.txt",
+        )
+    level = messages.INFO if ok else messages.ERROR
+    default_next = get_adapter().get_connect_redirect_url(request, sociallogin.account)
+    next_url = sociallogin.get_redirect_url(request) or default_next
+    get_account_adapter(request).add_message(
+        request,
+        level,
+        message,
+        message_context={"sociallogin": sociallogin, "action": action},
+    )
+    return HttpResponseRedirect(next_url)
+
+
+def do_connect(request, sociallogin):
+    if request.user.is_anonymous:
+        raise PermissionDenied()
+    if account_settings.REAUTHENTICATION_REQUIRED:
+        raise_if_reauthentication_required(request)
     message = "socialaccount/messages/account_connected.txt"
     action = None
+    ok = True
     if sociallogin.is_existing:
         if sociallogin.user != request.user:
             # Social account of other user. For now, this scenario
             # is not supported. Issue is that one cannot simply
             # remove the social account from the other user, as
             # that may render the account unusable.
-            level = messages.ERROR
-            message = "socialaccount/messages/account_connected_other.txt"
+            raise get_adapter().validation_error("connected_other")
         elif not sociallogin.account._state.adding:
-            # This account is already connected -- we give the opportunity
-            # for customized behaviour through use of a signal.
             action = "updated"
             message = "socialaccount/messages/account_connected_updated.txt"
         else:
@@ -108,13 +128,4 @@ def connect(request, sociallogin):
         # New account, let's connect
         action = "added"
         sociallogin.connect(request, request.user)
-    assert request.user.is_authenticated
-    default_next = get_adapter().get_connect_redirect_url(request, sociallogin.account)
-    next_url = sociallogin.get_redirect_url(request) or default_next
-    get_account_adapter(request).add_message(
-        request,
-        level,
-        message,
-        message_context={"sociallogin": sociallogin, "action": action},
-    )
-    return HttpResponseRedirect(next_url)
+    return ok, action, message
diff --git a/allauth/socialaccount/internal/flows/login.py b/allauth/socialaccount/internal/flows/login.py
index 2c360cf2..213e9d46 100644
--- a/allauth/socialaccount/internal/flows/login.py
+++ b/allauth/socialaccount/internal/flows/login.py
@@ -1,12 +1,16 @@
 from django.http import HttpResponseRedirect
+from django.shortcuts import render
 
-from allauth.account import authentication
+from allauth.account import app_settings as account_settings, authentication
 from allauth.account.adapter import get_adapter as get_account_adapter
 from allauth.account.utils import perform_login
-from allauth.core.exceptions import ImmediateHttpResponse
+from allauth.core.exceptions import (
+    ImmediateHttpResponse,
+    SignupClosedException,
+)
 from allauth.socialaccount import app_settings, signals
 from allauth.socialaccount.adapter import get_adapter
-from allauth.socialaccount.internal.flows.connect import connect
+from allauth.socialaccount.internal.flows.connect import connect, do_connect
 from allauth.socialaccount.internal.flows.signup import (
     clear_pending_signup,
     process_signup,
@@ -27,23 +31,39 @@ def _login(request, sociallogin):
     )
 
 
-def complete_login(request, sociallogin):
+def pre_social_login(request, sociallogin):
     clear_pending_signup(request)
     assert not sociallogin.is_existing
     sociallogin.lookup()
+    get_adapter().pre_social_login(request, sociallogin)
+    signals.pre_social_login.send(
+        sender=SocialLogin, request=request, sociallogin=sociallogin
+    )
+
+
+def complete_login(request, sociallogin, raises=False):
     try:
-        get_adapter().pre_social_login(request, sociallogin)
-        signals.pre_social_login.send(
-            sender=SocialLogin, request=request, sociallogin=sociallogin
-        )
+        pre_social_login(request, sociallogin)
         process = sociallogin.state.get("process")
         if process == AuthProcess.REDIRECT:
             return _redirect(request, sociallogin)
         elif process == AuthProcess.CONNECT:
-            return connect(request, sociallogin)
+            if raises:
+                do_connect(request, sociallogin)
+            else:
+                return connect(request, sociallogin)
         else:
             return _authenticate(request, sociallogin)
+    except SignupClosedException:
+        if raises:
+            raise
+        return render(
+            request,
+            "account/signup_closed." + account_settings.TEMPLATE_EXTENSION,
+        )
     except ImmediateHttpResponse as e:
+        if raises:
+            raise
         return e.response
 
 
diff --git a/allauth/socialaccount/internal/flows/signup.py b/allauth/socialaccount/internal/flows/signup.py
index 0f6e5236..a5666303 100644
--- a/allauth/socialaccount/internal/flows/signup.py
+++ b/allauth/socialaccount/internal/flows/signup.py
@@ -1,6 +1,5 @@
 from django.forms import ValidationError
 from django.http import HttpResponseRedirect, HttpResponseServerError
-from django.shortcuts import render
 from django.urls import reverse
 
 from allauth import app_settings as allauth_settings
@@ -11,6 +10,7 @@ from allauth.account.utils import (
     complete_signup,
     user_username,
 )
+from allauth.core.exceptions import SignupClosedException
 from allauth.socialaccount import app_settings
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialLogin
@@ -87,10 +87,7 @@ def process_auto_signup(request, sociallogin):
 
 def process_signup(request, sociallogin):
     if not get_adapter().is_open_for_signup(request, sociallogin):
-        return render(
-            request,
-            "account/signup_closed." + account_settings.TEMPLATE_EXTENSION,
-        )
+        raise SignupClosedException()
     auto_signup, resp = process_auto_signup(request, sociallogin)
     if resp:
         return resp
diff --git a/allauth/socialaccount/providers/atlassian/provider.py b/allauth/socialaccount/providers/atlassian/provider.py
index 566d0ab3..795f4853 100644
--- a/allauth/socialaccount/providers/atlassian/provider.py
+++ b/allauth/socialaccount/providers/atlassian/provider.py
@@ -8,6 +8,10 @@ class AtlassianAccount(ProviderAccount):
     def get_profile_url(self):
         return self.account.extra_data.get("picture")
 
+    def to_str(self):
+        dflt = super().to_str()
+        return self.account.extra_data.get("name", dflt)
+
 
 class AtlassianProvider(OAuth2Provider):
     id = "atlassian"
diff --git a/allauth/socialaccount/providers/dummy/provider.py b/allauth/socialaccount/providers/dummy/provider.py
index 70961184..6a60fb47 100644
--- a/allauth/socialaccount/providers/dummy/provider.py
+++ b/allauth/socialaccount/providers/dummy/provider.py
@@ -1,9 +1,13 @@
+import json
+
 from django.http import HttpResponseRedirect
 from django.urls import reverse
 from django.utils.http import urlencode
 
 from allauth.account.models import EmailAddress
+from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.providers.base import Provider, ProviderAccount
+from allauth.socialaccount.providers.dummy.forms import AuthenticateForm
 
 
 class DummyAccount(ProviderAccount):
@@ -21,6 +25,7 @@ class DummyProvider(Provider):
     account_class = DummyAccount
     uses_apps = False
     supports_redirect = True
+    supports_token_authentication = True
 
     def get_login_url(self, request, **kwargs):
         url = reverse("dummy_login")
@@ -67,5 +72,20 @@ class DummyProvider(Provider):
             )
         return addresses
 
+    def verify_token(self, request, token):
+        # Our ID token is just a JSON payload that can be handed over
+        # to the `AuthenticateForm`.
+        id_token = token.get("id_token")
+        if id_token:
+            try:
+                data = json.loads(id_token)
+            except json.JSONDecodeError:
+                pass
+            else:
+                form = AuthenticateForm(data=data)
+                if form.is_valid():
+                    return self.sociallogin_from_response(request, form.cleaned_data)
+        raise get_adapter().validation_error("invalid_token")
+
 
 provider_classes = [DummyProvider]
diff --git a/allauth/templates/allauth/elements/field.html b/allauth/templates/allauth/elements/field.html
index 357113c0..4ca444be 100644
--- a/allauth/templates/allauth/elements/field.html
+++ b/allauth/templates/allauth/elements/field.html
@@ -1,4 +1,5 @@
 {% load allauth %}
+{{ attrs.errors }}
 <p>
     <label for="{{ attrs.id }}">
         {% slot label %}
diff --git a/docs/headless/api.rst b/docs/headless/api.rst
index b8c04cb8..9042d5e8 100644
--- a/docs/headless/api.rst
+++ b/docs/headless/api.rst
@@ -1,4 +1,12 @@
 API
 ===
 
-You can find the documentation for the API itself in its `OpenAPI Specification <openapi-specification/>`_.
+You can find the documentation for the API itself in its `OpenAPI Specification
+<openapi-specification/>`_.
+
+Example
+*******
+
+The source code repository contains a
+`React example application <https://github.com/pennersr/django-allauth/tree/main/examples/react-spa>`_ that
+demonstrates how the API can be put to use in practice. The documentation for that application can be found in the `README <https://github.com/pennersr/django-allauth/tree/main/examples/react-spa/README.org>`_. Instructions on how to run it locally can be found there as well, though you can also view a copy running over at `react.demo.allauth.org <https://eact.demo.allauth.org>`_.
diff --git a/docs/headless/openapi-specification/openapi.yaml b/docs/headless/openapi-specification/openapi.yaml
index ba85ad9e..4a16f670 100644
--- a/docs/headless/openapi-specification/openapi.yaml
+++ b/docs/headless/openapi-specification/openapi.yaml
@@ -159,6 +159,13 @@ paths:
               examples:
                 pending_email:
                   $ref: "#/components/examples/UnauthenticatedPendingEmailVerification"
+        "403":
+          description: |
+            Forbidden. For example, when signup is closed.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ForbiddenResponse"
   /_allauth/{client}/v1/auth/email/verify:
     get:
       tags:
@@ -369,6 +376,13 @@ paths:
                   $ref: "#/components/examples/UnauthenticatedPending2FA"
                 unauthenticated_pending_email_verification:
                   $ref: "#/components/examples/UnauthenticatedPendingEmailVerification"
+        "403":
+          description: |
+            Forbidden. For example, when signup is closed.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ForbiddenResponse"
   /_allauth/{client}/v1/auth/provider/signup:
     post:
       tags:
@@ -404,6 +418,13 @@ paths:
               examples:
                 unauthenticated_pending_email_verification:
                   $ref: "#/components/examples/UnauthenticatedPendingEmailVerification"
+        "403":
+          description: |
+            Forbidden. For example, when signup is closed.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ForbiddenResponse"
   ######################################################################
   # Authentication: 2FA
   ######################################################################
@@ -1367,8 +1388,11 @@ components:
             - email
             - username
             - username_email
+        is_open_for_signup:
+          type: boolean
       required:
         - authentication_method
+        - is_open_for_signup
     AuthenticationResponse:
       type: object
       description: |
diff --git a/examples/react-spa/README.org b/examples/react-spa/README.org
index a7a07ce6..9afe6d2a 100644
--- a/examples/react-spa/README.org
+++ b/examples/react-spa/README.org
@@ -1,11 +1,96 @@
 * React Single-Page Application
 
-An example React application demonstrating the use of the headless functionality
-of django-allauth.
+This directory contains an example React application demonstrating the use of
+the headless functionality of django-allauth. The example consists of two parts,
+a backend running allauth in =HEADLESS_ONLY= mode, and a frontend containing the
+React application itself.
+
+This example can be found up and running over at [[https://react.demo.allauth.org/][react.demo.allauth.org]]. You can
+run it locally as well, as is documented below.
+
+
+** Backend
+
+The example backend Django project can be found in the =backend/= directory.  It
+is running in =HEADLESS_ONLY= mode, meaning, no frontend facing functionality of
+allauth is used. Hence, there are no templates or additional apps/views to be
+found in this example Django project. The only points of interest of this
+project are:
+
+- The configuration of =HEADLESS_ONLY = True= over at =settings.py=.
+
+- Configuring =HEADLESS_FRONTEND_URLS= so that allauth can construct links pointing
+  to the single-page React application.
+
+- The inclusion of =path("_allauth/", include("allauth.headless.urls"))= over at
+  =urls.py=.
+
+
+** Frontend
+
+The frontend can be found in the =frontend/= directory. It is a fairly minimal
+example React project created using =create-react-app=, extended with
+=react-(router-)dom= for implementing routing within the single-page
+application.
+
+The sole purpose of this project is to demonstrate the technical integration of
+headless allauth within a Javascript single-page application. A pretty look and
+feel is not a goal, and hence, minimal effort has been spent on making the
+frontend visually appealing.
+
+Points of interest of this project:
+
+- [[file:frontend/src/lib/allauth.js][frontend/src/lib/allauth.js]]: This is a (React independent) file offering
+  wrapper methods for the various allauth endpoints.
+
+- [[file:frontend/src/auth/AuthContext.js][frontend/src/auth/AuthContext.js]]: This file offers a React context provider
+  that is able to expose the authentication state to other React components.
+
+- [[file:frontend/src/auth/hooks.js][frontend/src/auth/hooks.js]]: Offers various hooks that use the context provider above to make
+  the current user (=useUser()=), allauth configuration (=useConfig()=) and authentication state (=useAuth()=)
+  easily consumable in components.
+
+- [[file:frontend/src/auth/routing.js][frontend/src/auth/routing.js]]: When the authentication state changes, the user
+  typically needs to be redirected to a different page. That logic is contained
+  within this file.
+
+- [[file:frontend/src/account/][frontend/src/account/]]: This directory contains all components related towards
+  the regular account functionality (e.g. login and signup using a local
+  account, as well as changing your password and email verification). This functionality corresponds
+  with the functionality of the =allauth.account= Django app.
+
+- [[file:frontend/src/socialaccount/][frontend/src/socialaccount/]]: This directory contains all components related
+  towards the logging in with third-party accounts. This functionality corresponds
+  with the functionality of the =allauth.socialaccount= Django app.
+
+- [[file:frontend/src/mfa/][frontend/src/mfa/]]: This directory contains all components related towards
+  Two-Factor Authentication. This functionality corresponds with the
+  functionality of the =allauth.mfa= Django app.
+
+- [[file:frontend/src/usersessions/][frontend/src/usersessions/]]: This directory contains user session management
+  component. This functionality corresponds with the functionality of the
+  =allauth.usersessions= Django app.
+
+- [[file:frontend/src/Router.js][frontend/src/Router.js]]: All routing is containing within this file.
+
 
 ** Running
 
-First, start by running the containers:
+A =docker-compose.yml= file is offered using which you can easily run the
+example project locally. It consists of the following services:
+
+- =backend=: The Django backend application running =allauth.headless=.
+  The backend runs on port 8000.
+
+- =frontend=:  The frontend (React) project, running on port 3000.
+
+- =proxy=: A proxy service (Traefik), running on port 10000, that depending on the URL
+  forwards either to the backend or frontend service.
+
+- =mail=: A dummy email (SMTP) service (MailCatcher) that you can use to test
+  e.g. email verification and password reset flows.
+
+To spin up these services, run:
 
 #+begin_src bash
   docker-compose up
diff --git a/examples/react-spa/backend/backend/accounts/__init__.py b/examples/react-spa/backend/backend/accounts/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/examples/react-spa/backend/backend/settings.py b/examples/react-spa/backend/backend/settings.py
index f1488dd6..b6b3629e 100644
--- a/examples/react-spa/backend/backend/settings.py
+++ b/examples/react-spa/backend/backend/settings.py
@@ -1,15 +1,3 @@
-"""
-Django settings for backend project.
-
-Generated by 'django-admin startproject' using Django 4.2.7.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/4.2/topics/settings/
-
-For the full list of settings and their values, see
-https://docs.djangoproject.com/en/4.2/ref/settings/
-"""
-
 from pathlib import Path
 
 
diff --git a/examples/react-spa/backend/backend/urls.py b/examples/react-spa/backend/backend/urls.py
index 1bf4d234..4f754227 100644
--- a/examples/react-spa/backend/backend/urls.py
+++ b/examples/react-spa/backend/backend/urls.py
@@ -1,19 +1,3 @@
-"""
-URL configuration for backend project.
-
-The `urlpatterns` list routes URLs to views. For more information please see:
-    https://docs.djangoproject.com/en/4.2/topics/http/urls/
-Examples:
-Function views
-    1. Add an import:  from my_app import views
-    2. Add a URL to urlpatterns:  path('', views.home, name='home')
-Class-based views
-    1. Add an import:  from other_app.views import Home
-    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
-Including another URLconf
-    1. Import the include() function: from django.urls import include, path
-    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
-"""
 from django.contrib import admin
 from django.urls import include, path
 
diff --git a/examples/react-spa/backend/backend/wsgi.py b/examples/react-spa/backend/backend/wsgi.py
index f5aa0e90..41275a0e 100644
--- a/examples/react-spa/backend/backend/wsgi.py
+++ b/examples/react-spa/backend/backend/wsgi.py
@@ -1,12 +1,3 @@
-"""
-WSGI config for backend project.
-
-It exposes the WSGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
-"""
-
 import os
 
 from django.core.wsgi import get_wsgi_application
diff --git a/examples/react-spa/backend/requirements.txt b/examples/react-spa/backend/requirements.txt
index fec9d2ec..761dd6cc 100644
--- a/examples/react-spa/backend/requirements.txt
+++ b/examples/react-spa/backend/requirements.txt
@@ -1,2 +1,2 @@
-django-allauth[mfa,socialaccount]==0.60.1
+django-allauth[mfa,socialaccount]>=0.63.1
 qrcode >= 7.0.0
diff --git a/examples/regular-django/requirements.txt b/examples/regular-django/requirements.txt
index 43f88988..d5407360 100644
--- a/examples/regular-django/requirements.txt
+++ b/examples/regular-django/requirements.txt
@@ -1,1 +1,1 @@
-django-allauth[mfa,saml,socialaccount]==0.60.1
+django-allauth[mfa,saml,socialaccount]>=0.63.1


Test patch:
diff --git a/allauth/headless/account/tests/test_signup.py b/allauth/headless/account/tests/test_signup.py
index 7cdb80cf..2c8c61fb 100644
--- a/allauth/headless/account/tests/test_signup.py
+++ b/allauth/headless/account/tests/test_signup.py
@@ -1,4 +1,4 @@
-from unittest.mock import ANY
+from unittest.mock import ANY, patch
 
 from django.contrib.auth.models import User
 
@@ -140,3 +140,29 @@ def test_signup_rate_limit(
         expected_status = 429 if attempt else 200
         assert resp.status_code == expected_status
         assert resp.json()["status"] == expected_status
+
+
+def test_signup_closed(
+    db,
+    client,
+    email_factory,
+    password_factory,
+    settings,
+    headless_reverse,
+    headless_client,
+):
+    with patch(
+        "allauth.account.adapter.DefaultAccountAdapter.is_open_for_signup"
+    ) as iofs:
+        iofs.return_value = False
+        resp = client.post(
+            headless_reverse("headless:account:signup"),
+            data={
+                "username": "wizard",
+                "email": email_factory(),
+                "password": password_factory(),
+            },
+            content_type="application/json",
+        )
+    assert resp.status_code == 403
+    assert not User.objects.filter(username="wizard").exists()
diff --git a/allauth/headless/mfa/tests/test_views.py b/allauth/headless/mfa/tests/test_views.py
index 12493093..c0f994ae 100644
--- a/allauth/headless/mfa/tests/test_views.py
+++ b/allauth/headless/mfa/tests/test_views.py
@@ -49,6 +49,7 @@ def test_auth_unverified_email_and_mfa(
                 "providers": ["dummy", "openid_connect", "openid_connect"],
             }
         )
+    flows.append({"id": "provider_token", "providers": ["dummy"]})
     flows.append(
         {
             "id": "mfa_authenticate",
diff --git a/allauth/headless/socialaccount/tests/test_views.py b/allauth/headless/socialaccount/tests/test_views.py
index 722a5098..974daaeb 100644
--- a/allauth/headless/socialaccount/tests/test_views.py
+++ b/allauth/headless/socialaccount/tests/test_views.py
@@ -1,7 +1,11 @@
+import json
+from unittest.mock import patch
+
 from django.urls import reverse
 
 from pytest_django.asserts import assertTemplateUsed
 
+from allauth.account.models import EmailAddress
 from allauth.socialaccount.models import SocialAccount
 from allauth.socialaccount.providers.base.constants import AuthProcess
 
@@ -82,6 +86,29 @@ def test_disconnect_bad_request(auth_client, user, headless_reverse, provider_id
     }
 
 
+def test_valid_token(client, headless_reverse, db):
+    id_token = json.dumps(
+        {
+            "id": 123,
+            "email": "a@b.com",
+            "email_verified": True,
+        }
+    )
+    resp = client.post(
+        headless_reverse("headless:socialaccount:provider_token"),
+        data={
+            "provider": "dummy",
+            "token": {
+                "id_token": id_token,
+            },
+            "process": AuthProcess.LOGIN,
+        },
+        content_type="application/json",
+    )
+    assert resp.status_code == 200
+    assert EmailAddress.objects.filter(email="a@b.com", verified=True).exists()
+
+
 def test_invalid_token(client, headless_reverse, db, google_provider_settings):
     resp = client.post(
         headless_reverse("headless:socialaccount:provider_token"),
@@ -151,3 +178,202 @@ def test_auth_error_headless_state_request_headless_only(
     settings.HEADLESS_FRONTEND_URLS = {"socialaccount_login_error": "/3rdparty/failure"}
     resp = client.get(reverse("google_callback") + f"?state={state}")
     assert resp["location"] == "/foo?error=unknown&error_process=login"
+
+
+def test_token_signup_closed(client, headless_reverse, db):
+    id_token = json.dumps(
+        {
+            "id": 123,
+            "email": "a@b.com",
+            "email_verified": True,
+        }
+    )
+    with patch(
+        "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.is_open_for_signup"
+    ) as iofs:
+        iofs.return_value = False
+        resp = client.post(
+            headless_reverse("headless:socialaccount:provider_token"),
+            data={
+                "provider": "dummy",
+                "token": {
+                    "id_token": id_token,
+                },
+                "process": AuthProcess.LOGIN,
+            },
+            content_type="application/json",
+        )
+        assert resp.status_code == 403
+    assert not EmailAddress.objects.filter(email="a@b.com", verified=True).exists()
+
+
+def test_provider_signup(client, headless_reverse, db, settings):
+    settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
+    settings.ACCOUNT_EMAIL_REQUIRED = True
+    settings.ACCOUNT_USERNAME_REQUIRED = False
+    id_token = json.dumps(
+        {
+            "id": 123,
+        }
+    )
+    resp = client.post(
+        headless_reverse("headless:socialaccount:provider_token"),
+        data={
+            "provider": "dummy",
+            "token": {
+                "id_token": id_token,
+            },
+            "process": AuthProcess.LOGIN,
+        },
+        content_type="application/json",
+    )
+    assert resp.status_code == 401
+    pending_flow = [f for f in resp.json()["data"]["flows"] if f.get("is_pending")][0]
+    assert pending_flow["id"] == "provider_signup"
+    resp = client.post(
+        headless_reverse("headless:socialaccount:provider_signup"),
+        data={
+            "email": "a@b.com",
+        },
+        content_type="application/json",
+    )
+    assert resp.status_code == 401
+    pending_flow = [f for f in resp.json()["data"]["flows"] if f.get("is_pending")][0]
+    assert pending_flow["id"] == "verify_email"
+    assert EmailAddress.objects.filter(email="a@b.com").exists()
+
+
+def test_signup_closed(client, headless_reverse, db, settings):
+    settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
+    settings.ACCOUNT_EMAIL_REQUIRED = True
+    settings.ACCOUNT_USERNAME_REQUIRED = False
+    id_token = json.dumps(
+        {
+            "id": 123,
+        }
+    )
+    resp = client.post(
+        headless_reverse("headless:socialaccount:provider_token"),
+        data={
+            "provider": "dummy",
+            "token": {
+                "id_token": id_token,
+            },
+            "process": AuthProcess.LOGIN,
+        },
+        content_type="application/json",
+    )
+    assert resp.status_code == 401
+    pending_flow = [f for f in resp.json()["data"]["flows"] if f.get("is_pending")][0]
+    assert pending_flow["id"] == "provider_signup"
+    with patch(
+        "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.is_open_for_signup"
+    ) as iofs:
+        iofs.return_value = False
+        resp = client.post(
+            headless_reverse("headless:socialaccount:provider_signup"),
+            data={
+                "email": "a@b.com",
+            },
+            content_type="application/json",
+        )
+    assert resp.status_code == 403
+
+
+def test_connect(user, auth_client, sociallogin_setup_state, headless_reverse, db):
+    state = sociallogin_setup_state(
+        auth_client, process="connect", next="/foo", headless=True
+    )
+    resp = auth_client.post(
+        reverse("dummy_authenticate") + f"?state={state}",
+        data={
+            "id": 123,
+        },
+    )
+    assert resp.status_code == 302
+    assert resp["location"] == "/foo"
+    assert SocialAccount.objects.filter(user=user, provider="dummy", uid="123").exists()
+
+
+def test_connect_already_connected(
+    user, user_factory, auth_client, sociallogin_setup_state, headless_reverse, db
+):
+    # The other user already connected the account.
+    other_user = user_factory()
+    SocialAccount.objects.create(user=other_user, uid="123", provider="dummy")
+    # Then, this user tries to connect...
+    state = sociallogin_setup_state(
+        auth_client, process=AuthProcess.CONNECT, next="/foo", headless=True
+    )
+    resp = auth_client.post(
+        reverse("dummy_authenticate") + f"?state={state}",
+        data={
+            "id": 123,
+        },
+    )
+    # We're redirected, and an error code is shown.
+    assert resp.status_code == 302
+    assert resp["location"] == "/foo?error=connected_other&error_process=connect"
+    assert not SocialAccount.objects.filter(
+        user=user, provider="dummy", uid="123"
+    ).exists()
+
+
+def test_token_connect(user, auth_client, headless_reverse, db):
+    id_token = json.dumps(
+        {
+            "id": 123,
+            "email": "a@b.com",
+            "email_verified": True,
+        }
+    )
+    resp = auth_client.post(
+        headless_reverse("headless:socialaccount:provider_token"),
+        data={
+            "provider": "dummy",
+            "token": {
+                "id_token": id_token,
+            },
+            "process": AuthProcess.CONNECT,
+        },
+        content_type="application/json",
+    )
+    assert resp.status_code == 200
+    assert SocialAccount.objects.filter(uid="123", user=user).exists()
+
+
+def test_token_connect_already_connected(
+    user, auth_client, headless_reverse, db, user_factory
+):
+    # The other user already connected the account.
+    other_user = user_factory()
+    SocialAccount.objects.create(user=other_user, uid="123", provider="dummy")
+    id_token = json.dumps(
+        {
+            "id": 123,
+            "email": "a@b.com",
+            "email_verified": True,
+        }
+    )
+    resp = auth_client.post(
+        headless_reverse("headless:socialaccount:provider_token"),
+        data={
+            "provider": "dummy",
+            "token": {
+                "id_token": id_token,
+            },
+            "process": AuthProcess.CONNECT,
+        },
+        content_type="application/json",
+    )
+    assert not SocialAccount.objects.filter(uid="123", user=user).exists()
+    assert resp.status_code == 400
+    assert resp.json() == {
+        "status": 400,
+        "errors": [
+            {
+                "code": "connected_other",
+                "message": "The third-party account is already connected to a different account.",
+            }
+        ],
+    }
diff --git a/allauth/socialaccount/tests/test_signup.py b/allauth/socialaccount/tests/test_signup.py
index 8cb59e5d..8691be8f 100644
--- a/allauth/socialaccount/tests/test_signup.py
+++ b/allauth/socialaccount/tests/test_signup.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
 import django
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser
@@ -446,3 +448,22 @@ def test_email_address_conflict_removes_conflicting_email(
     assert mailoutbox[0].subject == "[example.com] Please Confirm Your Email Address"
     assert resp["location"] == settings.LOGIN_REDIRECT_URL
     assert EmailAddress.objects.filter(email=user.email).count() == 1
+
+
+def test_signup_closed(
+    settings,
+    db,
+    client,
+    setup_sociallogin_flow,
+    sociallogin_factory,
+):
+    sociallogin = sociallogin_factory(
+        email="test@example.com", email_verified=False, username="test"
+    )
+    with patch(
+        "allauth.socialaccount.adapter.DefaultSocialAccountAdapter.is_open_for_signup"
+    ) as iofs:
+        iofs.return_value = False
+        resp = setup_sociallogin_flow(client, sociallogin)
+    assert b"Sign Up Closed" in resp.content
+    assert not get_user_model().objects.exists()