Repository: goauthentik/authentik

Issue:
Allow setting a custom attribute for oidc provider sub claim
**Is your feature request related to a problem? Please describe.**
I have an external auth source and I'm using authentik as an authentication hub between the source and other applications. That auth source has unique user ids that I save in authentik as a custom attribute. I would like to use it as the oidc subject.

**Describe the solution you'd like**
Add a subject mode option "Based on a user attribute" with a text field where one enter the attribute. Alternatively it could be an expression similar to property mappings.

This would be quite similar to the current "Based on the User's UPN" and it may even make sense to replace it entirely, but that would require migrating existing configurations to the new type with upn as the attribute.

**Describe alternatives you've considered**
I could set the external uid as the username in authentik as I'm not currently using the username for anything


Discussion:
I think you might be able to return `sub` in a scope mapping as a key-value entry in the dictionary and override the default `sub` claim, but I haven't tried that myself (looking at the code it should work though)
Seems to not have worked. I added a property mapping for scope `sub_custom_attribute`, enabled it for the provider and added it to the application scopes. This is the expression:
```
return {
    "sub": request.user.attributes.get('custom_attribute')
}
```

But authentik returned the sub selected with the subject type config option.
I’m assuming you’ve also reconfigured the application to ask for this new scope?
yes, but just noticed that enabling "Include claims in id_token" causes it to work, which makes sense. So now it works the way I'd like it to, but it's very hacky.
I don’t think it’s hacky since this is the intended way for it to function, but it definitely needs to be documented. With this we could probably also remove the UPN option 
it would be good to be able to return sub like this without needing to have "Include claims in id_token" enabled
I just updated all the way from 2023.5 (or 2023.6 maybe) to 2024.2 (in steps) and this no longer works and authentik returns the sub claim selected with subject mode instead of the one returned by property mapping. I first noticed this on 2023.10 but it's possible it was broken on 2023.8 or even 2023.6 and I just failed to test it before moving to the next update.
After looking more into this, it seems sub is set correctly in id_token but wrong in userinfo. Discourse has a nice error message for it:
```
(oidc) Authentication failure! openid_connect_sub_mismatch: OmniAuth::Strategies::OpenIDConnect::SubVerifyError, OIDC `sub` mismatch. ID Token value: "67". UserInfo value: "e291876f256dec9df658f614afd3bd985c4a04b56725e4ffd76977bbb1a8cc32"
```
Ideal solution would probably be a sub claim option, that would allow you to reference an expression policy.
I wonder how simple adding something like that would be? It seems rather database-oriented when I looked at the different sub choices. :)

Resolving patch:
diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py
--- a/authentik/providers/oauth2/views/userinfo.py
+++ b/authentik/providers/oauth2/views/userinfo.py
@@ -101,8 +101,8 @@ def get_claims(self, provider: OAuth2Provider, token: BaseGrantModel) -> dict[st
                     value=value,
                 )
                 continue
-            LOGGER.debug("updated scope", scope=scope)
             always_merger.merge(final_claims, value)
+            LOGGER.debug("updated scope", scope=scope)
         return final_claims
 
     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
@@ -121,8 +121,9 @@ def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
         """Handle GET Requests for UserInfo"""
         if not self.token:
             return HttpResponseBadRequest()
-        claims = self.get_claims(self.token.provider, self.token)
-        claims["sub"] = self.token.id_token.sub
+        claims = {}
+        claims.setdefault("sub", self.token.id_token.sub)
+        claims.update(self.get_claims(self.token.provider, self.token))
         if self.token.id_token.nonce:
             claims["nonce"] = self.token.id_token.nonce
         response = TokenResponse(claims)


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
@@ -344,7 +344,12 @@ def test_full_implicit(self):
                 ]
             )
         )
-        Application.objects.create(name="app", slug="app", provider=provider)
+        provider.property_mappings.add(
+            ScopeMapping.objects.create(
+                name=generate_id(), scope_name="test", expression="""return {"sub": "foo"}"""
+            )
+        )
+        Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
         state = generate_id()
         user = create_test_admin_user()
         self.client.force_login(user)
@@ -365,7 +370,7 @@ def test_full_implicit(self):
                     "response_type": "id_token",
                     "client_id": "test",
                     "state": state,
-                    "scope": "openid",
+                    "scope": "openid test",
                     "redirect_uri": "http://localhost",
                     "nonce": generate_id(),
                 },
@@ -390,6 +395,7 @@ def test_full_implicit(self):
             )
             jwt = self.validate_jwt(token, provider)
             self.assertEqual(jwt["amr"], ["pwd"])
+            self.assertEqual(jwt["sub"], "foo")
             self.assertAlmostEqual(
                 jwt["exp"] - now().timestamp(),
                 expires,