Files
zitadel/internal/api/oidc/integration_test/userinfo_project_roles_test.go
masum-msphere 9514a626b8 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)
2025-09-30 07:11:37 +02:00

107 lines
3.6 KiB
Go

//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)
}
}