Repository: goauthentik/authentik

Issue:
Can't connect Social Logins from user settings in 2024.4.2
**Describe the bug**
There appears to be a regression when upgrading from 2024.4.2 from 2024.4.1. Attempting to add a new social login (only tested with Apple) on the user settings page will say that it was successful, but it is not connected and the connect button remains. Reverting to 2024.4.1 I was able to connect Apple successfully.

**To Reproduce**
1. Have authentik 2024.4.2 installed
2. Have a Social Login Source configured such as Apple
3. Login as a user without any social login connections
4. Go to '/if/user/#/settings;%7B"page"%3A"page-sources"%7D'
5. Click "Connect" for Apple or other source.
6. Follow the external flow back to a success screen on user settings
7. Visually verify that the Social source was not in fact connected

**Expected behavior**
Expected the social login to be connected to the user as it was with 2024.4.1

**Screenshots**
N/A, can provide on request.

**Logs**
Can provide as needed.

**Version and Deployment (please complete the following information):**

-   authentik version: 2024.4.2
-   Deployment: helm

**Additional context**
Not sure if Enrollment/Auth flows are relevant from the user settings context, if so I can provide those as needed. They should mostly match the defaults and worked on 2024.4.1 at least!


Discussion:
I'm assuming this is a regression from https://github.com/goauthentik/authentik/pull/9559, although this should've been caught by e2e tests

Resolving patch:
diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py
--- a/authentik/core/sources/flow_manager.py
+++ b/authentik/core/sources/flow_manager.py
@@ -13,7 +13,7 @@
 from structlog.stdlib import get_logger
 
 from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
-from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
+from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostSourceStage
 from authentik.events.models import Event, EventAction
 from authentik.flows.exceptions import FlowNonApplicableException
 from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage
@@ -206,13 +206,9 @@ def error_handler(self, error: Exception) -> HttpResponse:
 
     def get_stages_to_append(self, flow: Flow) -> list[Stage]:
         """Hook to override stages which are appended to the flow"""
-        if not self.source.enrollment_flow:
-            return []
-        if flow.slug == self.source.enrollment_flow.slug:
-            return [
-                in_memory_stage(PostUserEnrollmentStage),
-            ]
-        return []
+        return [
+            in_memory_stage(PostSourceStage),
+        ]
 
     def _prepare_flow(
         self,
@@ -266,6 +262,8 @@ def _prepare_flow(
             )
         # We run the Flow planner here so we can pass the Pending user in the context
         planner = FlowPlanner(flow)
+        # We append some stages so the initial flow we get might be empty
+        planner.allow_empty_flows = True
         plan = planner.plan(self.request, kwargs)
         for stage in self.get_stages_to_append(flow):
             plan.append_stage(stage)
@@ -324,7 +322,7 @@ def handle_existing_link(
             reverse(
                 "authentik_core:if-user",
             )
-            + f"#/settings;page-{self.source.slug}"
+            + "#/settings;page-sources"
         )
 
     def handle_enroll(
diff --git a/authentik/core/sources/stage.py b/authentik/core/sources/stage.py
--- a/authentik/core/sources/stage.py
+++ b/authentik/core/sources/stage.py
@@ -10,7 +10,7 @@
 PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
 
 
-class PostUserEnrollmentStage(StageView):
+class PostSourceStage(StageView):
     """Dynamically injected stage which saves the Connection after
     the user has been enrolled."""
 
@@ -21,10 +21,12 @@ def dispatch(self, request: HttpRequest) -> HttpResponse:
         ]
         user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
         connection.user = user
+        linked = connection.pk is None
         connection.save()
-        Event.new(
-            EventAction.SOURCE_LINKED,
-            message="Linked Source",
-            source=connection.source,
-        ).from_http(self.request)
+        if linked:
+            Event.new(
+                EventAction.SOURCE_LINKED,
+                message="Linked Source",
+                source=connection.source,
+            ).from_http(self.request)
         return self.executor.stage_ok()


Test patch:
diff --git a/authentik/core/tests/test_source_flow_manager.py b/authentik/core/tests/test_source_flow_manager.py
--- a/authentik/core/tests/test_source_flow_manager.py
+++ b/authentik/core/tests/test_source_flow_manager.py
@@ -2,11 +2,15 @@
 
 from django.contrib.auth.models import AnonymousUser
 from django.test import TestCase
+from django.urls import reverse
 from guardian.utils import get_anonymous_user
 
 from authentik.core.models import SourceUserMatchingModes, User
 from authentik.core.sources.flow_manager import Action
+from authentik.core.sources.stage import PostSourceStage
 from authentik.core.tests.utils import create_test_flow
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views.executor import SESSION_KEY_PLAN
 from authentik.lib.generators import generate_id
 from authentik.lib.tests.utils import get_request
 from authentik.policies.denied import AccessDeniedResponse
@@ -21,41 +25,55 @@ class TestSourceFlowManager(TestCase):
 
     def setUp(self) -> None:
         super().setUp()
-        self.source: OAuthSource = OAuthSource.objects.create(name="test")
+        self.authentication_flow = create_test_flow()
+        self.enrollment_flow = create_test_flow()
+        self.source: OAuthSource = OAuthSource.objects.create(
+            name=generate_id(),
+            slug=generate_id(),
+            authentication_flow=self.authentication_flow,
+            enrollment_flow=self.enrollment_flow,
+        )
         self.identifier = generate_id()
 
     def test_unauthenticated_enroll(self):
         """Test un-authenticated user enrolling"""
-        flow_manager = OAuthSourceFlowManager(
-            self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
-        )
+        request = get_request("/", user=AnonymousUser())
+        flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
         action, _ = flow_manager.get_action()
         self.assertEqual(action, Action.ENROLL)
-        flow_manager.get_flow()
+        response = flow_manager.get_flow()
+        self.assertEqual(response.status_code, 302)
+        flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
+        self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
 
     def test_unauthenticated_auth(self):
         """Test un-authenticated user authenticating"""
         UserOAuthSourceConnection.objects.create(
             user=get_anonymous_user(), source=self.source, identifier=self.identifier
         )
-
-        flow_manager = OAuthSourceFlowManager(
-            self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
-        )
+        request = get_request("/", user=AnonymousUser())
+        flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
         action, _ = flow_manager.get_action()
         self.assertEqual(action, Action.AUTH)
-        flow_manager.get_flow()
+        response = flow_manager.get_flow()
+        self.assertEqual(response.status_code, 302)
+        flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
+        self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
 
     def test_authenticated_link(self):
         """Test authenticated user linking"""
         user = User.objects.create(username="foo", email="foo@bar.baz")
-        flow_manager = OAuthSourceFlowManager(
-            self.source, get_request("/", user=user), self.identifier, {}
-        )
+        request = get_request("/", user=user)
+        flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
         action, connection = flow_manager.get_action()
         self.assertEqual(action, Action.LINK)
         self.assertIsNone(connection.pk)
-        flow_manager.get_flow()
+        response = flow_manager.get_flow()
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(
+            response.url,
+            reverse("authentik_core:if-user") + "#/settings;page-sources",
+        )
 
     def test_unauthenticated_link(self):
         """Test un-authenticated user linking"""