fix: Revert "feat(oidc): Added new claim in userinfo response to return all requested audience roles (#9861)" (#10874)

# Which Problems Are Solved

#9861 added a `urn:zitadel:iam:org:projects:roles` claims to include all
roles from all requested roles. The intention was to return them on the
userinfo endpoint. But since the claims might also be returned in the id
and access tokens, they can grow big quite fast and break the size
limits for headers.

# How the Problems Are Solved

This PR revert the feature. The information for roles of other projects
is already available as a dedicated claim (for each project):
```json
  "urn:zitadel:iam:org:project:328813096124547391:roles": {
    "r2": {
      "306639557921669515": "zitadel.localhost"
    },
    "r3": {
      "306639557921669515": "zitadel.localhost"
    },
    "role": {
      "306639557921669515": "zitadel.localhost"
    }
  },
  "urn:zitadel:iam:org:project:341406882914631999:roles": {
    "role": {
      "306639557921669515": "zitadel.localhost",
      "328237605990695334": "aa.localhost"
    },
    "test": {
      "306639557921669515": "zitadel.localhost",
      "328237605990695334": "aa.localhost"
    }
  },
  "urn:zitadel:iam:org:project:roles": {
    "r2": {
      "306639557921669515": "zitadel.localhost"
    },
    "r3": {
      "306639557921669515": "zitadel.localhost"
    },
    "role": {
      "306639557921669515": "zitadel.localhost"
    }
  }
 ```

# Additional Changes

None

# Additional Context

- relates to #9861
- noted issues in production
- requires backport to v4.x

(cherry picked from commit b8bff3cdea)
This commit is contained in:
Livio Spring
2025-10-09 12:29:49 +02:00
parent c110ddda87
commit 8a3b5848dc
6 changed files with 9 additions and 141 deletions

View File

@@ -35,7 +35,6 @@ Please check below the matrix for an overview where which scope is asserted.
| sub | Yes | Yes | Yes | When JWT |
| urn:zitadel:iam:org:domain:primary:\{domainname} | When requested | When requested | When requested | When JWT and requested |
| urn:zitadel:iam:org:project:roles | When requested | When requested | When requested or configured | When JWT and requested or configured |
| urn:zitadel:iam:org:projects:roles | When requested | When requested | When requested or configured | When JWT and requested or configured |
| urn:zitadel:iam:user:metadata | When requested | When requested | When requested | When JWT and requested |
| urn:zitadel:iam:user:resourceowner:id | When requested | When requested | When requested | When JWT and requested |
| urn:zitadel:iam:user:resourceowner:name | When requested | When requested | When requested | When JWT and requested |
@@ -110,7 +109,6 @@ ZITADEL reserves some claims to assert certain data. Please check out the [reser
| urn:zitadel:iam:action:\{actionname}:log | `{"urn:zitadel:iam:action:appendCustomClaims:log": ["test log", "another test log"]}` | This claim is set during Actions as a log, e.g. if two custom claims with the same keys are set. |
| urn:zitadel:iam:org:domain:primary:\{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. |
| urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on the current project (where your client belongs to). |
| urn:zitadel:iam:org:projects:roles | `{"urn:zitadel:iam:org:projects:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL provides the `id` and `primaryDomain` for each role. This allows you to identify the organizations where a user has roles across all requested projects by audience. |
| urn:zitadel:iam:org:project:\{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. |
| urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. |
| urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the user's organization ID. |

View File

@@ -25,7 +25,6 @@ const (
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
ClaimProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
ClaimUserMetaData = ScopeUserMetaData
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
@@ -96,7 +95,7 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string,
func checkGrantedRoles(roles *projectsRoles, grant query.UserGrant, requestedRole string, isRequested bool) {
for _, grantedRole := range grant.Roles {
if requestedRole == grantedRole {
roles.Add(grant.ProjectID, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain, isRequested, false)
roles.Add(grant.ProjectID, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain, isRequested)
}
}
}
@@ -107,11 +106,9 @@ type projectsRoles struct {
projects map[string]projectRoles
requestProjectID string
requestAudIDs map[string]struct{}
}
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []string, roleAudience []string) *projectsRoles {
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []string) *projectsRoles {
roles := new(projectsRoles)
// if specific roles where requested, check if they are granted and append them in the roles list
if len(requestedRoles) > 0 {
@@ -120,19 +117,18 @@ func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles
checkGrantedRoles(roles, grant, requestedRole, grant.ProjectID == projectID)
}
}
return roles
}
// no specific roles were requested, so convert any grants into roles
for _, grant := range grants {
for _, role := range grant.Roles {
for _, projectAud := range roleAudience {
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID, grant.ProjectID == projectAud)
}
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID)
}
}
return roles
}
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool, isAudienceReq bool) {
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool) {
if p.projects == nil {
p.projects = make(map[string]projectRoles, 1)
}
@@ -142,12 +138,6 @@ func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequeste
if isRequested {
p.requestProjectID = projectID
}
if p.requestAudIDs == nil {
p.requestAudIDs = make(map[string]struct{}, 1)
}
if isAudienceReq {
p.requestAudIDs[projectID] = struct{}{}
}
p.projects[projectID].Add(roleKey, orgID, domain)
}

View File

@@ -1,106 +0,0 @@
//go:build integration
package oidc_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/domain"
)
func TestServer_UserInfo_ProjectsRolesClaim(t *testing.T) {
const (
roleFoo = "foo"
roleBar = "bar"
roleBaz = "baz"
)
clientID, projectID1 := createClient(t, Instance)
_, projectID2 := createClient(t, Instance)
addProjectRolesGrants(t, User.GetUserId(), projectID1, roleFoo, roleBar)
addProjectRolesGrants(t, User.GetUserId(), projectID2, roleBaz)
tests := []struct {
name string
prepare func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims]
scope []string
assertions []func(*testing.T, *oidc.UserInfo)
wantErr bool
}{
{
name: "project role and audience scope with only current project",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
domain.ProjectIDScope + projectID1 + domain.AudSuffix, domain.ProjectsIDScope,
},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectsRolesClaims(t, ui.Claims, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
},
},
},
{
name: "project role and audience scope with multiple projects as audience",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
domain.ProjectIDScope + projectID1 + domain.AudSuffix,
domain.ProjectIDScope + projectID2 + domain.AudSuffix,
domain.ProjectsIDScope,
},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectsRolesClaims(t, ui.Claims, []string{roleFoo, roleBar, roleBaz}, []string{Instance.DefaultOrg.Id})
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := tt.prepare(t, clientID, tt.scope)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, assertion := range tt.assertions {
assertion(t, userinfo)
}
})
}
}
// assertProjectsRoleClaims asserts the projectRoles in the claims.
func assertProjectsRolesClaims(t *testing.T, claims map[string]any, wantRoles, wantRoleOrgIDs []string) {
t.Helper()
projectsRoleClaims := make([]string, 0, 2)
projectsRoleClaims = append(projectsRoleClaims, oidc_api.ClaimProjectsRoles)
for _, claim := range projectsRoleClaims {
roleMap, ok := claims[claim].(map[string]any) // map of multiple roles
require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
gotRoles := make([]string, 0, len(roleMap))
for roleKey := range roleMap {
role, ok := roleMap[roleKey].(map[string]any) // map of multiple org IDs to org domains
require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
gotRoles = append(gotRoles, roleKey)
gotRoleOrgIDs := make([]string, 0, len(role))
for orgID := range role {
gotRoleOrgIDs = append(gotRoleOrgIDs, orgID)
}
assert.ElementsMatch(t, wantRoleOrgIDs, gotRoleOrgIDs)
}
assert.ElementsMatch(t, wantRoles, gotRoles)
}
}

View File

@@ -111,7 +111,7 @@ func TestServer_UserInfo(t *testing.T) {
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
},
},
},
@@ -125,7 +125,7 @@ func TestServer_UserInfo(t *testing.T) {
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
},
},
},
@@ -267,7 +267,7 @@ func TestServer_UserInfo_Issue6662(t *testing.T) {
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider)
require.NoError(t, err)
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
}
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {

View File

@@ -209,7 +209,7 @@ func assertRoles(projectID string, user *query.OIDCUserInfo, roleAudience, reque
}
// prevent returning obtained grants if none where requested
if (projectID != "" && len(requestedRoles) > 0) || len(roleAudience) > 0 {
setUserInfoRoleClaims(info, newProjectRoles(projectID, user.UserGrants, requestedRoles, roleAudience))
setUserInfoRoleClaims(info, newProjectRoles(projectID, user.UserGrants, requestedRoles))
}
}
@@ -278,25 +278,12 @@ func setUserInfoOrgClaims(user *query.OIDCUserInfo, out *oidc.UserInfo) {
func setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) {
if roles != nil && len(roles.projects) > 0 {
// Create a map to store accumulated roles for ClaimProjectsRoles
projectsRoles := make(projectRoles)
for requestAudID := range roles.requestAudIDs {
if projectRole, ok := roles.projects[requestAudID]; ok {
maps.Copy(projectsRoles, projectRole)
}
}
if roles, ok := roles.projects[roles.requestProjectID]; ok {
userInfo.AppendClaims(ClaimProjectRoles, roles)
}
for projectID, roles := range roles.projects {
userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles)
}
// Finally, set the accumulated ClaimProjectsRoles
if len(projectsRoles) > 0 {
userInfo.AppendClaims(ClaimProjectsRoles, projectsRoles)
}
}
}

View File

@@ -7,7 +7,6 @@ const (
OrgDomainPrimaryClaim = "urn:zitadel:iam:org:domain:primary"
OrgIDClaim = "urn:zitadel:iam:org:id"
ProjectIDScope = "urn:zitadel:iam:org:project:id:"
ProjectsIDScope = "urn:zitadel:iam:org:projects:roles"
ProjectIDScopeZITADEL = "zitadel"
AudSuffix = ":aud"
ProjectScopeZITADEL = ProjectIDScope + ProjectIDScopeZITADEL + AudSuffix