goauthentik / goauthentik/authentik
Stock 'profile' scope mapping unexpectedly includes a 'groups' field, causing duplicate group entries when combined with a custom 'groups' scope mapping
- Dominant language
- Python
- Stars
- 25.6k
- Forks
- 2k
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 659
Description
### Describe the bug
The built-in OAuth2/OIDC scope mapping `authentik default OAuth Mapping: OpenID 'profile'` (managed id `goauthentik.io/providers/oauth2/scope-profile`) includes its own `"groups"` field:
```python
return {
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.username,
"nickname": request.user.username,
"groups": [group.name for group in request.user.groups.all()],
}
```
This is unexpected for the standard OIDC `profile` scope, which per spec covers name/nickname/etc., not group membership. It becomes a real problem for any provider that (very commonly) also attaches its own dedicated `groups` scope mapping (a standard pattern for exposing group membership under a client-requestable `groups` scope, since `groups` isn't a standard OIDC scope authentik ships a mapping for out of the box under that name besides this hidden one inside `profile`).
If a client requests both `profile` and `groups` scopes (a very common combination — e.g. Dex's OIDC connector, or any client that wants both group membership and basic profile info), `UserInfoView.get_claims()` (`authentik/providers/oauth2/views/userinfo.py`) evaluates **both** matching `ScopeMapping` objects and merges their return values with `deepmerge.always_merger`. That merger's list strategy is `"append"`, not `"append_unique"` (`DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES = [(list, "append"), ...]` in the `deepmerge` package). Since both mappings return the *identical* list of group names, the final `"groups"` claim ends up with every group name duplicated (once per mapping that emits it) rather than the correct, deduplicated set.
This affects both the `/application/o/userinfo/` endpoint and the ID token (when `include_claims_in_id_token` is enabled), since `IDToken.new()` calls the exact same `UserInfoView.get_claims()` method.
### To Reproduce
1. Create an OAuth2/OpenID provider.
2. Attach the stock scope mappings for `openid`, `email`, `profile` (`default_oauth_mappings`/managed ids), plus a **custom** scope mapping with `scope_name = "groups"` returning `{"groups": [group.name for group in request.user.groups.all()]}` (a very natural, commonly-recommended pattern for exposing groups under an explicit scope).
3. Have a client request scopes `openid profile email groups` and log in as a user who is a member of at least one group.
4. Decode the resulting ID token or call `/application/o/userinfo/` — the `groups` claim contains every group name **twice**.
Confirmed via a live Django shell (`ak shell`) against the actual provider/user in question:
```python
>>> u.groups.all()
[] # exactly one, confirmed clean via raw SQL too
>>> sm = ScopeMapping.objects.get(provider=p, scope_name="groups") # our custom mapping
>>> sm.evaluate(user=u, request=None, provider=p, token=None)
{'groups': ['customers/entra-test/k8s_project:all_viewer']} # clean, one element, on its own
>>> profile_mapping = ScopeMapping.objects.get(provider=p, scope_name="profile") # stock mapping
>>> profile_mapping.evaluate(user=u, request=None, provider=p, token=fake_token)
{'name': ..., 'given_name': ..., 'preferred_username': ..., 'nickname': ...,
'groups': ['customers/entra-test/k8s_project:all_viewer']} # <-- also returns groups!
>>> view.get_claims(p, fake_token) # UserInfoView.get_claims(), scope=[openid,openid,profile,email,groups]
{'email': ..., 'groups': ['customers/entra-test/k8s_project:all_viewer',
'customers/entra-test/k8s_project:all_viewer'], # <-- duplicated
'name': ..., ...}
```
Also confirmed independently of any downstream client (Dex, in our case) by calling authentik's own `/application/o/token/` and `/application/o/userinfo/` endpoints directly with `curl` — the duplicate is present in authentik's own signed response, before any other party touches it.
### Expected behavior
Either:
- The stock `profile` scope mapping shouldn't include a `groups` field at all (it's out of scope for the `profile` OIDC scope and duplicates what a dedicated `groups` scope mapping is meant to provide), or
- `get_claims()`'s merge step should deduplicate list values across scope mappings (e.g. use a merger with `"append_unique"` instead of `"append"` for lists), so that two mappings independently emitting the same group name don't produce duplicates.
### Version
2026.5.2 (also present on 2026.5.1, based on the installed provider version during testing)
### Deployment Method
Kubernetes (Helm)
### Additional context
Our workaround: replace `scope-profile` in the provider's `property_mappings` with an identical copy of the stock mapping minus the `groups` field. Works, but every consumer of this pattern has to independently discover and reimplement the same shadow-copy — the underlying stock mapping still ships with a scope-inappropriate field, and the merge behavior is still non-deduplicating for any other pair of mappings that might overlap on a list-typed key in the future.
Contributor guide
Research direction
Start with authentik/providers/oauth2/views/userinfo.py and trace UserInfoView.get_claims(), then inspect the managed OpenID profile mapping identified by scope-profile. Reproduce the profile-plus-groups combination described in the issue and verify the resulting claims in both UserInfo and ID-token flows. Done means group names are not duplicated when overlapping scope mappings are evaluated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- django, python
- Domain
- api, authentication, authorization, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 62/100