mirror of
https://github.com/zitadel/zitadel.git
synced 2025-11-01 00:46:23 +00:00
feat(oidc): Added new claim in userinfo response to return all requested audience roles (#9861)
# Which Problems Are Solved
The /userinfo endpoint only returns roles for the current project, even
if the access token includes multiple project aud scopes.
This prevents clients from retrieving all user roles across multiple
projects, making multi-project access control ineffective.
# How the Problems Are Solved
Modified the /userinfo handler logic to resolve roles across all valid
project audience scopes provided in the token, not just the current
project.
Ensured that if **urn:zitadel:iam:org:projects:roles is in the scopes**,
roles from all declared project audiences are collected and included in
the response in **urn:zitadel:iam:org:projects:roles claim**.
# Additional Changes
# Additional Context
This change enables service-to-service authorization workflows and SPA
role resolution across multiple project contexts with a single token.
- Closes #9831
---------
Co-authored-by: Masum Patel <patelmasum98@gmail.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
(cherry picked from commit 295584648d)
This commit is contained in:
committed by
Livio Spring
parent
8b5dcdcf45
commit
9514a626b8
@@ -35,6 +35,7 @@ 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 |
|
||||
@@ -109,6 +110,7 @@ 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. |
|
||||
|
||||
@@ -25,6 +25,7 @@ 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"
|
||||
@@ -95,7 +96,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)
|
||||
roles.Add(grant.ProjectID, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain, isRequested, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,9 +107,11 @@ type projectsRoles struct {
|
||||
projects map[string]projectRoles
|
||||
|
||||
requestProjectID string
|
||||
|
||||
requestAudIDs map[string]struct{}
|
||||
}
|
||||
|
||||
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []string) *projectsRoles {
|
||||
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []string, roleAudience []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 {
|
||||
@@ -117,18 +120,19 @@ 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 {
|
||||
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID)
|
||||
for _, projectAud := range roleAudience {
|
||||
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID, grant.ProjectID == projectAud)
|
||||
}
|
||||
}
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool) {
|
||||
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool, isAudienceReq bool) {
|
||||
if p.projects == nil {
|
||||
p.projects = make(map[string]projectRoles, 1)
|
||||
}
|
||||
@@ -138,6 +142,12 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -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}, []string{Instance.DefaultOrg.Id})
|
||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []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}, []string{Instance.DefaultOrg.Id})
|
||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []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}, []string{Instance.DefaultOrg.Id})
|
||||
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
|
||||
}
|
||||
|
||||
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
|
||||
|
||||
@@ -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))
|
||||
setUserInfoRoleClaims(info, newProjectRoles(projectID, user.UserGrants, requestedRoles, roleAudience))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,12 +278,25 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user