Repository: goauthentik/authentik

Issue:
OAuth source is added to user attributes each time user write is run
**Describe the bug**
The user attribute `goauthentik.io/user/sources` gets appended each time an user write stage is run.

```
goauthentik.io/user/sources:
  - Wordpress
  - Wordpress
  - Wordpress
  - Wordpress
  - Wordpress
  ...
```

**To Reproduce**
Steps to reproduce the behavior:

1. Configure OAuth source
2. Add User Write Stage to default-source-authentication flow
3. Login a few times
4. Each login adds a new entry under `goauthentik.io/user/sources`

**Expected behavior**
It should only add the source if it isn't there already

**Version and Deployment (please complete the following information):**

-   authentik version: 2023.5.4
-   Deployment: docker


Discussion:

Resolving patch:
diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py
--- a/authentik/stages/user_write/stage.py
+++ b/authentik/stages/user_write/stage.py
@@ -124,7 +124,8 @@ def update_user(self, user: User):
             connection: UserSourceConnection = self.executor.plan.context[
                 PLAN_CONTEXT_SOURCES_CONNECTION
             ]
-            user.attributes[USER_ATTRIBUTE_SOURCES].append(connection.source.name)
+            if connection.source.name not in user.attributes[USER_ATTRIBUTE_SOURCES]:
+                user.attributes[USER_ATTRIBUTE_SOURCES].append(connection.source.name)
 
     def get(self, request: HttpRequest) -> HttpResponse:
         """Save data in the current flow to the currently pending user. If no user is pending,


Test patch:
diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py
--- a/authentik/stages/user_write/tests.py
+++ b/authentik/stages/user_write/tests.py
@@ -97,6 +97,47 @@ def test_user_update(self):
         self.assertEqual(user_qs.first().attributes["foo"], "bar")
         self.assertNotIn("some_ignored_attribute", user_qs.first().attributes)
 
+    def test_user_update_source(self):
+        """Test update of existing user with a source"""
+        new_password = generate_key()
+        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+        plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create(
+            username="unittest",
+            email="test@goauthentik.io",
+            attributes={
+                USER_ATTRIBUTE_SOURCES: [
+                    self.source.name,
+                ]
+            },
+        )
+        plan.context[PLAN_CONTEXT_SOURCES_CONNECTION] = UserSourceConnection(source=self.source)
+        plan.context[PLAN_CONTEXT_PROMPT] = {
+            "username": "test-user-new",
+            "password": new_password,
+            "attributes.some.custom-attribute": "test",
+            "attributes": {
+                "foo": "bar",
+            },
+            "some_ignored_attribute": "bar",
+        }
+        session = self.client.session
+        session[SESSION_KEY_PLAN] = plan
+        session.save()
+
+        response = self.client.post(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
+        user_qs = User.objects.filter(username=plan.context[PLAN_CONTEXT_PROMPT]["username"])
+        self.assertTrue(user_qs.exists())
+        self.assertTrue(user_qs.first().check_password(new_password))
+        self.assertEqual(user_qs.first().attributes["some"]["custom-attribute"], "test")
+        self.assertEqual(user_qs.first().attributes["foo"], "bar")
+        self.assertEqual(user_qs.first().attributes[USER_ATTRIBUTE_SOURCES], [self.source.name])
+        self.assertNotIn("some_ignored_attribute", user_qs.first().attributes)
+
     @patch(
         "authentik.flows.views.executor.to_stage_response",
         TO_STAGE_RESPONSE_MOCK,