mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 01:27:32 +00:00
feat: project roles (#843)
* fix logging * token verification * feat: assert roles * feat: add project role assertion on project and token type on app * id and access token role assertion * add project role check * user grant required step in login * update library * fix merge * fix merge * fix merge * update oidc library * fix tests * add tests for GrantRequiredStep * add missing field ProjectRoleCheck on project view model * fix project create * fix project create
This commit is contained in:
@@ -3,6 +3,7 @@ package oidc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/caos/zitadel/internal/api/http/middleware"
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
proj_model "github.com/caos/zitadel/internal/project/model"
|
||||
grant_model "github.com/caos/zitadel/internal/usergrant/model"
|
||||
)
|
||||
|
||||
@@ -19,6 +21,14 @@ func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest
|
||||
if !ok {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-sd436", "no user agent id")
|
||||
}
|
||||
app, err := o.repo.ApplicationByClientID(ctx, req.ClientID)
|
||||
if err != nil {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-AEG4d", "Errors.Internal")
|
||||
}
|
||||
req.Scopes, err = o.assertProjectRoleScopes(app, req.Scopes)
|
||||
if err != nil {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-Gqrfg", "Errors.Internal")
|
||||
}
|
||||
authRequest := CreateAuthRequestToBusiness(ctx, req, userAgentID, userID)
|
||||
resp, err := o.repo.CreateAuthRequest(ctx, authRequest)
|
||||
if err != nil {
|
||||
@@ -102,3 +112,22 @@ func (o *OPStorage) GetKeySet(ctx context.Context) (*jose.JSONWebKeySet, error)
|
||||
func (o *OPStorage) SaveNewKeyPair(ctx context.Context) error {
|
||||
return o.repo.GenerateSigningKeyPair(ctx, o.signingKeyAlgorithm)
|
||||
}
|
||||
|
||||
func (o *OPStorage) assertProjectRoleScopes(app *proj_model.ApplicationView, scopes []string) ([]string, error) {
|
||||
if !app.ProjectRoleAssertion {
|
||||
return scopes, nil
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
|
||||
return scopes, nil
|
||||
}
|
||||
}
|
||||
roles, err := o.repo.ProjectRolesByProjectID(app.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
scopes = append(scopes, ScopeProjectRolePrefix+role.Key)
|
||||
}
|
||||
return scopes, nil
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
proj_model "github.com/caos/zitadel/internal/project/model"
|
||||
user_model "github.com/caos/zitadel/internal/user/model"
|
||||
grant_model "github.com/caos/zitadel/internal/usergrant/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,6 +26,9 @@ const (
|
||||
scopePhone = "phone"
|
||||
scopeAddress = "address"
|
||||
|
||||
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
|
||||
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
|
||||
|
||||
oidcCtx = "oidc"
|
||||
)
|
||||
|
||||
@@ -35,7 +40,15 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (op.Clie
|
||||
if client.State != proj_model.AppStateActive {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-sdaGg", "client is not active")
|
||||
}
|
||||
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultAccessTokenLifetime, o.defaultIdTokenLifetime)
|
||||
projectRoles, err := o.repo.ProjectRolesByProjectID(client.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowedScopes := make([]string, len(projectRoles))
|
||||
for i, role := range projectRoles {
|
||||
allowedScopes[i] = ScopeProjectRolePrefix + role.Key
|
||||
}
|
||||
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultAccessTokenLifetime, o.defaultIdTokenLifetime, allowedScopes)
|
||||
}
|
||||
|
||||
func (o *OPStorage) GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
|
||||
@@ -65,10 +78,10 @@ func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secr
|
||||
return o.repo.AuthorizeOIDCApplication(ctx, id, secret)
|
||||
}
|
||||
|
||||
func (o *OPStorage) GetUserinfoFromToken(ctx context.Context, tokenID, subject, origin string) (*oidc.Userinfo, error) {
|
||||
token, err := o.repo.TokenByID(ctx, tokenID, subject)
|
||||
func (o *OPStorage) GetUserinfoFromToken(ctx context.Context, tokenID, subject, origin string) (oidc.UserInfo, error) {
|
||||
token, err := o.repo.TokenByID(ctx, subject, tokenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired")
|
||||
}
|
||||
if token.ApplicationID != "" {
|
||||
app, err := o.repo.ApplicationByClientID(ctx, token.ApplicationID)
|
||||
@@ -79,65 +92,126 @@ func (o *OPStorage) GetUserinfoFromToken(ctx context.Context, tokenID, subject,
|
||||
return nil, errors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed")
|
||||
}
|
||||
}
|
||||
return o.GetUserinfoFromScopes(ctx, token.UserID, token.Scopes)
|
||||
return o.GetUserinfoFromScopes(ctx, token.UserID, token.ApplicationID, token.Scopes)
|
||||
}
|
||||
|
||||
func (o *OPStorage) GetUserinfoFromScopes(ctx context.Context, userID string, scopes []string) (*oidc.Userinfo, error) {
|
||||
func (o *OPStorage) GetUserinfoFromScopes(ctx context.Context, userID, applicationID string, scopes []string) (oidc.UserInfo, error) {
|
||||
user, err := o.repo.UserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userInfo := new(oidc.Userinfo)
|
||||
userInfo := oidc.NewUserInfo()
|
||||
roles := make([]string, 0)
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case scopeOpenID:
|
||||
userInfo.Subject = user.ID
|
||||
case scopeEmail:
|
||||
case oidc.ScopeOpenID:
|
||||
userInfo.SetSubject(user.ID)
|
||||
case oidc.ScopeEmail:
|
||||
if user.HumanView == nil {
|
||||
continue
|
||||
}
|
||||
userInfo.Email = user.Email
|
||||
userInfo.EmailVerified = user.IsEmailVerified
|
||||
case scopeProfile:
|
||||
userInfo.PreferredUsername = user.PreferredLoginName
|
||||
userInfo.UpdatedAt = user.ChangeDate
|
||||
userInfo.SetEmail(user.Email, user.IsEmailVerified)
|
||||
case oidc.ScopeProfile:
|
||||
userInfo.SetPreferredUsername(user.PreferredLoginName)
|
||||
userInfo.SetUpdatedAt(user.ChangeDate)
|
||||
if user.HumanView != nil {
|
||||
userInfo.Name = user.DisplayName
|
||||
userInfo.FamilyName = user.LastName
|
||||
userInfo.GivenName = user.FirstName
|
||||
userInfo.Nickname = user.NickName
|
||||
userInfo.Gender = oidc.Gender(getGender(user.Gender))
|
||||
userInfo.Locale, err = language.Parse(user.PreferredLanguage)
|
||||
userInfo.SetName(user.DisplayName)
|
||||
userInfo.SetFamilyName(user.LastName)
|
||||
userInfo.SetGivenName(user.FirstName)
|
||||
userInfo.SetNickname(user.NickName)
|
||||
userInfo.SetGender(oidc.Gender(getGender(user.Gender)))
|
||||
locale, _ := language.Parse(user.PreferredLanguage)
|
||||
userInfo.SetLocale(locale)
|
||||
} else {
|
||||
userInfo.Name = user.MachineView.Name
|
||||
userInfo.SetName(user.MachineView.Name)
|
||||
}
|
||||
case scopePhone:
|
||||
case oidc.ScopePhone:
|
||||
if user.HumanView == nil {
|
||||
continue
|
||||
}
|
||||
userInfo.PhoneNumber = user.Phone
|
||||
userInfo.PhoneNumberVerified = user.IsPhoneVerified
|
||||
case scopeAddress:
|
||||
userInfo.SetPhone(user.Phone, user.IsPhoneVerified)
|
||||
case oidc.ScopeAddress:
|
||||
if user.HumanView == nil {
|
||||
continue
|
||||
}
|
||||
if user.StreetAddress == "" && user.Locality == "" && user.Region == "" && user.PostalCode == "" && user.Country == "" {
|
||||
continue
|
||||
}
|
||||
userInfo.Address = &oidc.UserinfoAddress{
|
||||
StreetAddress: user.StreetAddress,
|
||||
Locality: user.Locality,
|
||||
Region: user.Region,
|
||||
PostalCode: user.PostalCode,
|
||||
Country: user.Country,
|
||||
}
|
||||
userInfo.SetAddress(oidc.NewUserInfoAddress(user.StreetAddress, user.Locality, user.Region, user.PostalCode, user.Country, ""))
|
||||
default:
|
||||
userInfo.Authorizations = append(userInfo.Authorizations, scope)
|
||||
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
|
||||
roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(roles) == 0 || applicationID == "" {
|
||||
return userInfo, nil
|
||||
}
|
||||
projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(projectRoles) > 0 {
|
||||
userInfo.AppendClaims(ClaimProjectRoles, projectRoles)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, applicationID string, scopes []string) (claims map[string]interface{}, err error) {
|
||||
roles := make([]string, 0)
|
||||
for _, scope := range scopes {
|
||||
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
|
||||
roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix))
|
||||
}
|
||||
}
|
||||
if len(roles) == 0 || applicationID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(projectRoles) > 0 {
|
||||
claims = map[string]interface{}{ClaimProjectRoles: projectRoles}
|
||||
}
|
||||
return claims, err
|
||||
}
|
||||
|
||||
func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles []string) (map[string]map[string]string, error) {
|
||||
app, err := o.repo.ApplicationByClientID(ctx, applicationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants, err := o.repo.UserGrantsByProjectAndUserID(app.ProjectID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projectRoles := make(map[string]map[string]string)
|
||||
for _, requestedRole := range requestedRoles {
|
||||
for _, grant := range grants {
|
||||
checkGrantedRoles(projectRoles, grant, requestedRole)
|
||||
}
|
||||
}
|
||||
return projectRoles, nil
|
||||
}
|
||||
|
||||
func checkGrantedRoles(roles map[string]map[string]string, grant *grant_model.UserGrantView, requestedRole string) {
|
||||
for _, grantedRole := range grant.RoleKeys {
|
||||
if requestedRole == grantedRole {
|
||||
appendRole(roles, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRole(roles map[string]map[string]string, role, orgID, orgPrimaryDomain string) {
|
||||
if roles[role] == nil {
|
||||
roles[role] = make(map[string]string, 0)
|
||||
}
|
||||
roles[role][orgID] = orgPrimaryDomain
|
||||
}
|
||||
|
||||
func getGender(gender user_model.Gender) string {
|
||||
switch gender {
|
||||
case user_model.GenderFemale:
|
||||
|
@@ -1,9 +1,9 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
@@ -15,13 +15,20 @@ type Client struct {
|
||||
defaultLoginURL string
|
||||
defaultAccessTokenLifetime time.Duration
|
||||
defaultIdTokenLifetime time.Duration
|
||||
allowedScopes []string
|
||||
}
|
||||
|
||||
func ClientFromBusiness(app *model.ApplicationView, defaultLoginURL string, defaultAccessTokenLifetime, defaultIdTokenLifetime time.Duration) (op.Client, error) {
|
||||
func ClientFromBusiness(app *model.ApplicationView, defaultLoginURL string, defaultAccessTokenLifetime, defaultIdTokenLifetime time.Duration, allowedScopes []string) (op.Client, error) {
|
||||
if !app.IsOIDC {
|
||||
return nil, errors.ThrowInvalidArgument(nil, "OIDC-d5bhD", "client is not a proper oidc application")
|
||||
}
|
||||
return &Client{ApplicationView: app, defaultLoginURL: defaultLoginURL, defaultAccessTokenLifetime: defaultAccessTokenLifetime, defaultIdTokenLifetime: defaultIdTokenLifetime}, nil
|
||||
return &Client{
|
||||
ApplicationView: app,
|
||||
defaultLoginURL: defaultLoginURL,
|
||||
defaultAccessTokenLifetime: defaultAccessTokenLifetime,
|
||||
defaultIdTokenLifetime: defaultIdTokenLifetime,
|
||||
allowedScopes: allowedScopes},
|
||||
nil
|
||||
}
|
||||
|
||||
func (c *Client) ApplicationType() op.ApplicationType {
|
||||
@@ -56,6 +63,18 @@ func (c *Client) DevMode() bool {
|
||||
return c.ApplicationView.DevMode
|
||||
}
|
||||
|
||||
func (c *Client) AllowedScopes() []string {
|
||||
return c.allowedScopes
|
||||
}
|
||||
|
||||
func (c *Client) AssertAdditionalIdTokenScopes() bool {
|
||||
return c.IDTokenRoleAssertion
|
||||
}
|
||||
|
||||
func (c *Client) AssertAdditionalAccessTokenScopes() bool {
|
||||
return c.AccessTokenRoleAssertion
|
||||
}
|
||||
|
||||
func (c *Client) AccessTokenLifetime() time.Duration {
|
||||
return c.defaultAccessTokenLifetime //PLANNED: impl from real client
|
||||
}
|
||||
@@ -65,7 +84,18 @@ func (c *Client) IDTokenLifetime() time.Duration {
|
||||
}
|
||||
|
||||
func (c *Client) AccessTokenType() op.AccessTokenType {
|
||||
return op.AccessTokenTypeBearer //PLANNED: impl from real client
|
||||
return accessTokenTypeToOIDC(c.ApplicationView.AccessTokenType)
|
||||
}
|
||||
|
||||
func accessTokenTypeToOIDC(tokenType model.OIDCTokenType) op.AccessTokenType {
|
||||
switch tokenType {
|
||||
case model.OIDCTokenTypeBearer:
|
||||
return op.AccessTokenTypeBearer
|
||||
case model.OIDCTokenTypeJWT:
|
||||
return op.AccessTokenTypeJWT
|
||||
default:
|
||||
return op.AccessTokenTypeBearer
|
||||
}
|
||||
}
|
||||
|
||||
func authMethodToOIDC(authType model.OIDCAuthMethodType) op.AuthMethod {
|
||||
|
Reference in New Issue
Block a user