feat(group): add user groups to token claims (#11009)

# Which Problems Are Solved

If the groups of a user is requested through a scope, details related to
the user's groups are added in the token claims.

# How the Problems Are Solved
By querying the user groups that the user is a part of, and setting them
in the token claims.

This PR introduces two scopes to request user groups: `groups` and
`urn:zitadel:iam:user:groups`
* `groups` returns an array of group names in the token claims with the
same name
* `urn:zitadel:iam:user:groups` returns an array of JSON objects with
information about the user groups in the token claims with the same name

# Additional Changes

N/A

# Additional Context

- Closes #9702 
- Follow-up for PRs:
  * #10455 
  * #10853 
  * #10758 
  * #10940

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Gayathri Vijayan
2025-11-11 09:06:45 +01:00
committed by GitHub
co-authored by Livio Spring
parent e4a959c321
commit a9846498a9
8 changed files with 140 additions and 5 deletions
+4
View File
@@ -32,6 +32,10 @@ const (
ClaimResourceOwnerName = ScopeResourceOwner + ":name"
ClaimResourceOwnerPrimaryDomain = ScopeResourceOwner + ":primary_domain"
ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log"
ScopeUserGroups = "groups"
ClaimUserGroups = ScopeUserGroups
ScopeCustomUserGroups = "urn:zitadel:iam:user:groups"
ClaimCustomUserGroups = ScopeCustomUserGroups
oidcCtx = "oidc"
)
+3
View File
@@ -271,5 +271,8 @@ func isScopeAllowed(scope string, allowedScopes ...string) bool {
if scope == ScopeProjectsRoles {
return true
}
if scope == ScopeCustomUserGroups || scope == ScopeUserGroups {
return true
}
return slices.Contains(allowedScopes, scope)
}
@@ -30,6 +30,13 @@ func TestServer_UserInfo(t *testing.T) {
clientID, projectID := createClient(t, Instance)
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
// create user groups
group1 := Instance.CreateGroup(CTXIAM, t, Instance.DefaultOrg.GetId(), "group1")
group2 := Instance.CreateGroup(CTXIAM, t, Instance.DefaultOrg.GetId(), "group2")
// add the user to groups
Instance.AddUsersToGroup(CTXIAM, t, group1.GetId(), []string{User.GetUserId()})
Instance.AddUsersToGroup(CTXIAM, t, group2.GetId(), []string{User.GetUserId()})
tests := []struct {
name string
prepare func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims]
@@ -154,6 +161,39 @@ func TestServer_UserInfo(t *testing.T) {
},
},
},
{
name: "custom group scope",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
oidc_api.ScopeCustomUserGroups},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, info *oidc.UserInfo) {
expectedUserGroups := []map[string]interface{}{
{"id": group1.GetId(), "name": "group1"},
{"id": group2.GetId(), "name": "group2"},
}
userGroupsClaim := info.Claims[oidc_api.ClaimCustomUserGroups]
require.Len(t, userGroupsClaim, len(expectedUserGroups))
assert.ElementsMatch(t, expectedUserGroups, userGroupsClaim)
},
},
},
{
name: "group scope",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
oidc_api.ScopeUserGroups},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, info *oidc.UserInfo) {
expectedUserGroups := []string{"group1", "group2"}
userGroupsClaim := info.Claims[oidc_api.ClaimUserGroups]
require.Len(t, userGroupsClaim, len(expectedUserGroups))
assert.ElementsMatch(t, expectedUserGroups, userGroupsClaim)
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+22
View File
@@ -190,6 +190,10 @@ func userInfoToOIDC(user *query.OIDCUserInfo, userInfoAssertion bool, scope []st
setUserInfoMetadata(user.Metadata, out)
case ScopeResourceOwner:
setUserInfoOrgClaims(user, out)
case ScopeCustomUserGroups:
setUserInfoCustomUserGroups(user.UserGroups, out)
case ScopeUserGroups:
setUserInfoUserGroups(user.UserGroups, out)
default:
if claim, ok := strings.CutPrefix(s, domain.OrgDomainPrimaryScope); ok {
out.AppendClaims(domain.OrgDomainPrimaryClaim, claim)
@@ -287,6 +291,24 @@ func setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) {
}
}
func setUserInfoCustomUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserInfo) {
if len(userGroups) == 0 {
return
}
out.AppendClaims(ClaimCustomUserGroups, userGroups)
}
func setUserInfoUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserInfo) {
if len(userGroups) == 0 {
return
}
groups := make([]string, len(userGroups))
for i, userGroup := range userGroups {
groups[i] = userGroup.Name
}
out.AppendClaims(ClaimUserGroups, groups)
}
//nolint:gocognit
func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string) (err error) {
ctx, span := tracing.NewSpan(ctx)
+46
View File
@@ -179,6 +179,16 @@ func Test_userInfoToOIDC(t *testing.T) {
Name: "orgName",
PrimaryDomain: "orgDomain",
}
userGroups := []query.UserInfoUserGroup{
{
Name: "group1",
ID: "group1-id",
},
{
Name: "group2",
ID: "group2-id",
},
}
humanUserInfo := &query.OIDCUserInfo{
User: &query.User{
ID: "human1",
@@ -226,6 +236,7 @@ func Test_userInfoToOIDC(t *testing.T) {
UserResourceOwner: "org1",
},
},
UserGroups: userGroups,
}
machineUserInfo := &query.OIDCUserInfo{
User: &query.User{
@@ -503,6 +514,41 @@ func Test_userInfoToOIDC(t *testing.T) {
},
},
},
{
name: "human, scope custom user groups, found",
args: args{
user: humanUserInfo,
scope: []string{ScopeCustomUserGroups},
},
want: &oidc.UserInfo{
Subject: "human1",
Claims: map[string]any{
ClaimCustomUserGroups: []query.UserInfoUserGroup{
{
Name: "group1",
ID: "group1-id",
},
{
Name: "group2",
ID: "group2-id",
},
},
},
},
},
{
name: "human, scope user groups (group names), found",
args: args{
user: humanUserInfo,
scope: []string{ScopeUserGroups},
},
want: &oidc.UserInfo{
Subject: "human1",
Claims: map[string]any{
ClaimUserGroups: []string{"group1", "group2"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {