diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index c82e3a3883b..ab3f120b425 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -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. | diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index ced270e5456..045ef4e196d 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -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) } diff --git a/internal/api/oidc/integration_test/userinfo_project_roles_test.go b/internal/api/oidc/integration_test/userinfo_project_roles_test.go new file mode 100644 index 00000000000..39906b57689 --- /dev/null +++ b/internal/api/oidc/integration_test/userinfo_project_roles_test.go @@ -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) + } +} diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index b1b63686903..2fce70920d0 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -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) { diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 15ec5b21e02..83b806cd285 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -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) + } } } diff --git a/internal/domain/request.go b/internal/domain/request.go index 92e45c0d2f5..5dfad584d52 100644 --- a/internal/domain/request.go +++ b/internal/domain/request.go @@ -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