goauthentik / goauthentik/authentik

Stock 'profile' scope mapping unexpectedly includes a 'groups' field, causing duplicate group entries when combined with a custom 'groups' scope mapping

Open
#25,842 1 comment 0 reactions 0 assignees View on GitHub
bug bug/confirmed
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.