mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-29 10:32:15 +00:00
feat: handle Zitadel provider IdP login via login v1 (#12469)
# Which Problems Are Solved This PR adds support for using a ZITADEL IdP provider in the login v1 UI flow and conditionally grants a support-user instance membership based on a ZITADEL-specific project-roles token claim. # How the Problems Are Solved - Introduced ZITADEL IdP handling in the login v1 handler, including extracting the `urn:zitadel:iam:org:project:roles` claim and granting `IAM_OWNER_VIEWER` instance membership when configured orgs match. - Added a new `AddInstanceMemberFromLogin` to assign an instance membership during the login v1 ZITADEL-IdP flow without permission checks (as the authorization is established based on the project roles in the token claim) - Added unit tests # Additional Changes N/A # Additional Context - Closes https://github.com/zitadel/zitadel/issues/12446
This commit is contained in:
@@ -3,8 +3,14 @@ package login
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/idp"
|
||||
openid "github.com/zitadel/zitadel/internal/idp/providers/oidc"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
provideridp "github.com/zitadel/zitadel/internal/repository/idp"
|
||||
)
|
||||
|
||||
func Test_hasEmailChanged(t *testing.T) {
|
||||
@@ -284,3 +290,312 @@ func Test_hasPhoneChanged(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_projectRolesFromIDPUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user idp.User
|
||||
want map[string]map[string]string
|
||||
}{
|
||||
{
|
||||
"not an oidc user",
|
||||
struct{ idp.User }{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"no claims",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: nil}),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"claim absent",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{"some": "other"}}),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"claim wrong type",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{zitadelProjectRolesClaim: "not-a-map"}}),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"role with non-map orgs is skipped",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{
|
||||
zitadelProjectRolesClaim: map[string]any{
|
||||
"IAM_OWNER_VIEWER": "not-a-map",
|
||||
}},
|
||||
}),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"single role and org",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{
|
||||
zitadelProjectRolesClaim: map[string]any{
|
||||
"IAM_OWNER_VIEWER": map[string]any{
|
||||
"orgID1": "org1.example.com",
|
||||
},
|
||||
}},
|
||||
}),
|
||||
map[string]map[string]string{
|
||||
"IAM_OWNER_VIEWER": {"orgID1": "org1.example.com"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"multiple roles and orgs",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{
|
||||
zitadelProjectRolesClaim: map[string]any{
|
||||
"IAM_OWNER_VIEWER": map[string]any{
|
||||
"orgID1": "org1.example.com",
|
||||
"orgID2": "org2.example.com",
|
||||
},
|
||||
"OTHER_ROLE": map[string]any{
|
||||
"orgID3": "org3.example.com",
|
||||
},
|
||||
}},
|
||||
}),
|
||||
map[string]map[string]string{
|
||||
"IAM_OWNER_VIEWER": {"orgID1": "org1.example.com", "orgID2": "org2.example.com"},
|
||||
"OTHER_ROLE": {"orgID3": "org3.example.com"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"non-string org domain is skipped",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{
|
||||
zitadelProjectRolesClaim: map[string]any{
|
||||
"IAM_OWNER_VIEWER": map[string]any{
|
||||
"orgID1": 55,
|
||||
},
|
||||
}},
|
||||
}),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"role keeps only orgs with string domains",
|
||||
openid.NewUser(&oidc.UserInfo{Claims: map[string]any{
|
||||
zitadelProjectRolesClaim: map[string]any{
|
||||
"IAM_OWNER_VIEWER": map[string]any{
|
||||
"orgID1": "org1.example.com",
|
||||
"orgID2": 55,
|
||||
},
|
||||
}},
|
||||
}),
|
||||
map[string]map[string]string{
|
||||
"IAM_OWNER_VIEWER": {"orgID1": "org1.example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, projectRolesFromIDPUser(tt.user))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_claimMatchesConfiguredOrg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claimOrgs map[string]string
|
||||
template *query.ZitadelIDPTemplate
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"id and domain match",
|
||||
map[string]string{"orgID1": "org1.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"id matches, domain differs",
|
||||
map[string]string{"orgID1": "evil.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"domain matches, id differs",
|
||||
map[string]string{"otherID": "org1.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"neither id nor domain match",
|
||||
map[string]string{"orgIDx": "orgx.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"no configured orgs",
|
||||
map[string]string{"orgID1": "org1.example.com"},
|
||||
&query.ZitadelIDPTemplate{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"no claim orgs",
|
||||
map[string]string{},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"one of multiple claim orgs matches",
|
||||
map[string]string{"orgIDx": "orgx.example.com", "orgID1": "org1.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"one of multiple configured orgs matches",
|
||||
map[string]string{"orgID2": "org2.example.com"},
|
||||
&query.ZitadelIDPTemplate{InstanceRolesInfo: []provideridp.RolesInfo{
|
||||
{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"},
|
||||
{OrganizationID: "orgID2", OrganizationDomain: "org2.example.com"},
|
||||
}},
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, claimMatchesConfiguredOrg(tt.claimOrgs, tt.template))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_preserveProjectRoles(t *testing.T) {
|
||||
roles := map[string]map[string]string{
|
||||
"IAM_OWNER_VIEWER": {"orgID1": "org1.example.com"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
user *domain.ExternalUser
|
||||
linkingUsers []*domain.ExternalUser
|
||||
want map[string]map[string]string
|
||||
}{
|
||||
{
|
||||
// the form-mapped user has no roles claim; it must be restored from the linking user
|
||||
name: "roles copied from the single linking user",
|
||||
user: &domain.ExternalUser{},
|
||||
linkingUsers: []*domain.ExternalUser{{ProjectRoles: roles}},
|
||||
want: roles,
|
||||
},
|
||||
{
|
||||
// linking users accumulate on account-linking flows; the most recent one is mapped
|
||||
name: "roles copied from the most recent linking user",
|
||||
user: &domain.ExternalUser{},
|
||||
linkingUsers: []*domain.ExternalUser{
|
||||
{ProjectRoles: map[string]map[string]string{"OTHER_ROLE": {"orgIDx": "orgx.example.com"}}},
|
||||
{ProjectRoles: roles},
|
||||
},
|
||||
want: roles,
|
||||
},
|
||||
{
|
||||
name: "no linking users, nil",
|
||||
user: &domain.ExternalUser{},
|
||||
linkingUsers: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "last linking user without roles, nil",
|
||||
user: &domain.ExternalUser{},
|
||||
linkingUsers: []*domain.ExternalUser{{ProjectRoles: roles}, {}},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
preserveProjectRoles(tt.user, tt.linkingUsers)
|
||||
assert.Equal(t, tt.want, tt.user.ProjectRoles)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_supportUserInstanceRoles(t *testing.T) {
|
||||
configured := &query.ZitadelIDPTemplate{
|
||||
InstanceRolesInfo: []provideridp.RolesInfo{{OrganizationID: "orgID1", OrganizationDomain: "org1.example.com"}},
|
||||
}
|
||||
supportClaim := map[string]map[string]string{
|
||||
domain.RoleIAMOwnerViewer: {"orgID1": "org1.example.com"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *query.IDPTemplate
|
||||
externalUser *domain.ExternalUser
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "instance zitadel provider, support role claim, matching org",
|
||||
provider: &query.IDPTemplate{OwnerType: domain.IdentityProviderTypeSystem, Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: supportClaim},
|
||||
want: []string{domain.RoleIAMOwnerViewer},
|
||||
},
|
||||
{
|
||||
name: "multiple IAM roles claimed, all returned sorted",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{
|
||||
domain.RoleIAMOwnerViewer: {"orgID1": "org1.example.com"},
|
||||
domain.RoleIAMOwner: {"orgID1": "org1.example.com"},
|
||||
}},
|
||||
want: []string{domain.RoleIAMOwner, domain.RoleIAMOwnerViewer},
|
||||
},
|
||||
{
|
||||
name: "only the roles of a configured org are returned",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{
|
||||
domain.RoleIAMOwnerViewer: {"orgID1": "org1.example.com"},
|
||||
domain.RoleIAMOwner: {"orgID2": "org2.example.com"},
|
||||
}},
|
||||
want: []string{domain.RoleIAMOwnerViewer},
|
||||
},
|
||||
{
|
||||
name: "non-IAM roles are not returned",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{
|
||||
domain.RoleIAMOwnerViewer: {"orgID1": "org1.example.com"},
|
||||
domain.RoleOrgOwner: {"orgID1": "org1.example.com"},
|
||||
}},
|
||||
want: []string{domain.RoleIAMOwnerViewer},
|
||||
},
|
||||
{
|
||||
name: "org-scoped zitadel provider, matching org, skipped",
|
||||
provider: &query.IDPTemplate{OwnerType: domain.IdentityProviderTypeOrg, Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: supportClaim},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "non-zitadel provider, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeOIDC, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: supportClaim},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "zitadel type but no zitadel template, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: supportClaim},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no roles claim at all, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "roles claim without an IAM role, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{"OTHER_ROLE": {"orgID1": "org1.example.com"}}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "support role present but empty org set, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{domain.RoleIAMOwnerViewer: {}}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "support role claim for a non-configured org, skipped",
|
||||
provider: &query.IDPTemplate{Type: domain.IDPTypeZitadel, ZitadelIDPTemplate: configured},
|
||||
externalUser: &domain.ExternalUser{ProjectRoles: map[string]map[string]string{domain.RoleIAMOwnerViewer: {"orgID1": "evil.example.com"}}},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, supportUserInstanceRoles(tt.provider, tt.externalUser))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user