Repository: goauthentik/authentik

Issue:
Multiple custom user attribute outputs from LDAP provider
**Describe the bug**
After upgrading to 2023.3.0 querying the user database via LDAP generates the same custom user attribute 2 or 3 times into the result which makes client authentication process crash (Dovecot auth-worker dies).

**To Reproduce**
Steps to reproduce the behavior:
1. Create a new User in Authentik
2. Create custom attributes for it, eg.: { attr1: value1, attr2: value2 }
3. Query the user via LDAP outpost and provider
4. You can see the custom attributes multiple times in the answer like: attr1: value1, attr2: value2, attr1: value1, attr1: value1, attr2: value2

**Expected behavior**
all custom user attributes exist only one time in the LDAP result.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Logs**
INF baseDN=ou=users,dc=auth,dc=example,dc=com bindDN=cn=ldap,dc=auth,dc=example,dc=com client=x.x.x.x event=Search request filter=(&(objectClass=user)(memberOf=cn=MAIL,ou=groups,dc=auth,dc=example,dc=com)(mailbox=user@example.com)) requestId=4fede29c-7627-4fee-8a83-462297e79ad2 scope=Whole Subtree timestamp=2023-03-16T09:26:44Z took-ms=0

**Version and Deployment (please complete the following information):**
 - authentik version: 2023.3.0
 - Deployment: docker-compose

**Additional context**

Dovecot log:
`auth-worker(10399): Error: conn unix:auth-worker (pid=3179,uid=0): auth-worker<2>: ldap(user@example.com,x.x.x.x,<ZPz1IQH3sshZhWBu>): LDAP search returned multiple entries
imap-login: Info: Disconnected: Aborted login by logging out (auth service reported temporary failure): user=<user@example.com>, method=PLAIN, rip=x.x.x.x, lip=y.y.y.y, TLS, session=<ZPz1IQH3sshZhWBu>`

ldapsearch relevant output:
`\# extended LDIF
\#
\# LDAPv3
\# base <DC=auth,DC=example,DC=com> with scope subtree
\# filter: (&(objectClass=user)(memberOf=cn=MAIL,ou=groups,dc=auth,dc=example,dc=com)(ak-active=true))
\# requesting: ALL
\#

\# user, users, auth.example.com
dn: cn=user,ou=users,dc=auth,dc=example,dc=com
attr1: val1
attr2: val2
attr1: val1
attr2: val2
attr2: val2
ak-superuser: false
sAMAccountName: user
uidNumber: 2009
goauthentik.io/ldap/superuser: false
ak-active: true
name: User Name
objectClass: user
objectClass: organizationalPerson
objectClass: inetOrgPerson
objectClass: goauthentik.io/ldap/user
goauthentik.io/ldap/active: true
gidNumber: 2009
homeDirectory: /home/user
sn: User Name
mail: user@example.com
memberOf: cn=MAIL,ou=groups,dc=auth,dc=example,dc=com
memberOf: cn=PORTAINER,ou=groups,dc=auth,dc=example,dc=com
cn: user
uid: 5776113a045611d96ba6fcd0a985c912b67f329ac04f20806be8d4fc0c7801d2
displayName: User Name`

postmap output:
`postmap -q user@example.com ldap:/etc/postfix/ldap/virtual_mailbox_maps.cf
example.com/user@example.com/,example.com/user@example.com/`


Discussion:

Resolving patch:
diff --git a/authentik/__init__.py b/authentik/__init__.py
--- a/authentik/__init__.py
+++ b/authentik/__init__.py
@@ -2,7 +2,7 @@
 from os import environ
 from typing import Optional
 
-__version__ = "2023.3.0"
+__version__ = "2023.3.1"
 ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 
 
diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py
--- a/authentik/providers/oauth2/views/authorize.py
+++ b/authentik/providers/oauth2/views/authorize.py
@@ -514,7 +514,12 @@ def create_response_uri(self) -> str:
                 return urlunsplit(uri)
 
             if self.params.response_mode == ResponseMode.FRAGMENT:
-                query_fragment = self.create_implicit_response(code)
+                query_fragment = {}
+                if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
+                    query_fragment["code"] = code.code
+                    query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
+                else:
+                    query_fragment = self.create_implicit_response(code)
 
                 uri = uri._replace(
                     fragment=uri.fragment + urlencode(query_fragment, doseq=True),


Test patch:
diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py
--- a/authentik/providers/oauth2/tests/test_authorize.py
+++ b/authentik/providers/oauth2/tests/test_authorize.py
@@ -355,6 +355,62 @@ def test_full_implicit(self):
                 delta=5,
             )
 
+    def test_full_fragment_code(self):
+        """Test full authorization"""
+        flow = create_test_flow()
+        provider: OAuth2Provider = OAuth2Provider.objects.create(
+            name=generate_id(),
+            client_id="test",
+            client_secret=generate_key(),
+            authorization_flow=flow,
+            redirect_uris="http://localhost",
+            signing_key=self.keypair,
+        )
+        Application.objects.create(name="app", slug="app", provider=provider)
+        state = generate_id()
+        user = create_test_admin_user()
+        self.client.force_login(user)
+        with patch(
+            "authentik.providers.oauth2.id_token.get_login_event",
+            MagicMock(
+                return_value=Event(
+                    action=EventAction.LOGIN,
+                    context={PLAN_CONTEXT_METHOD: "password"},
+                    created=now(),
+                )
+            ),
+        ):
+            # Step 1, initiate params and get redirect to flow
+            self.client.get(
+                reverse("authentik_providers_oauth2:authorize"),
+                data={
+                    "response_type": "code",
+                    "response_mode": "fragment",
+                    "client_id": "test",
+                    "state": state,
+                    "scope": "openid",
+                    "redirect_uri": "http://localhost",
+                    "nonce": generate_id(),
+                },
+            )
+            response = self.client.get(
+                reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+            )
+            code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
+            self.assertJSONEqual(
+                response.content.decode(),
+                {
+                    "component": "xak-flow-redirect",
+                    "type": ChallengeTypes.REDIRECT.value,
+                    "to": (f"http://localhost#code={code.code}" f"&state={state}"),
+                },
+            )
+            self.assertAlmostEqual(
+                code.expires.timestamp() - now().timestamp(),
+                timedelta_from_string(provider.access_code_validity).total_seconds(),
+                delta=5,
+            )
+
     def test_full_form_post_id_token(self):
         """Test full authorization (form_post response)"""
         flow = create_test_flow()