mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-23 15:36:47 +00:00
# Which Problems Are Solved
#9861 added a `urn:zitadel:iam:org:projects:roles` claims to include all
roles from all requested roles. The intention was to return them on the
userinfo endpoint. But since the claims might also be returned in the id
and access tokens, they can grow big quite fast and break the size
limits for headers.
# How the Problems Are Solved
This PR revert the feature. The information for roles of other projects
is already available as a dedicated claim (for each project):
```json
"urn:zitadel:iam:org:project:328813096124547391:roles": {
"r2": {
"306639557921669515": "zitadel.localhost"
},
"r3": {
"306639557921669515": "zitadel.localhost"
},
"role": {
"306639557921669515": "zitadel.localhost"
}
},
"urn:zitadel:iam:org:project:341406882914631999:roles": {
"role": {
"306639557921669515": "zitadel.localhost",
"328237605990695334": "aa.localhost"
},
"test": {
"306639557921669515": "zitadel.localhost",
"328237605990695334": "aa.localhost"
}
},
"urn:zitadel:iam:org:project:roles": {
"r2": {
"306639557921669515": "zitadel.localhost"
},
"r3": {
"306639557921669515": "zitadel.localhost"
},
"role": {
"306639557921669515": "zitadel.localhost"
}
}
```
# Additional Changes
None
# Additional Context
- relates to #9861
- noted issues in production
- requires backport to v4.x
(cherry picked from commit b8bff3cdea)
282 lines
9.4 KiB
Go
282 lines
9.4 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"github.com/zitadel/oidc/v3/pkg/op"
|
|
|
|
"github.com/zitadel/zitadel/internal/actions"
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
"github.com/zitadel/zitadel/internal/query"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
const (
|
|
ClaimPrefix = "urn:zitadel:iam"
|
|
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
|
|
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
|
|
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
|
|
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
|
|
ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
|
|
ClaimUserMetaData = ScopeUserMetaData
|
|
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
|
|
ClaimResourceOwnerID = ScopeResourceOwner + ":id"
|
|
ClaimResourceOwnerName = ScopeResourceOwner + ":name"
|
|
ClaimResourceOwnerPrimaryDomain = ScopeResourceOwner + ":primary_domain"
|
|
ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log"
|
|
|
|
oidcCtx = "oidc"
|
|
)
|
|
|
|
// GetClientByClientID implements the op.Storage interface to retrieve an OIDC client by its ID.
|
|
//
|
|
// TODO: Still used for Auth request creation for v1 login.
|
|
func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() {
|
|
err = oidcError(err)
|
|
span.EndWithError(err)
|
|
}()
|
|
client, err := o.query.ActiveOIDCClientByID(ctx, id, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil
|
|
}
|
|
|
|
func (o *OPStorage) GetKeyByIDAndClientID(context.Context, string, string) (*jose.JSONWebKey, error) {
|
|
panic(o.panicErr("GetKeyByIDAndClientID"))
|
|
}
|
|
|
|
func (o *OPStorage) ValidateJWTProfileScopes(context.Context, string, []string) ([]string, error) {
|
|
panic(o.panicErr("ValidateJWTProfileScopes"))
|
|
}
|
|
|
|
func (o *OPStorage) AuthorizeClientIDSecret(context.Context, string, string) error {
|
|
panic(o.panicErr("AuthorizeClientIDSecret"))
|
|
}
|
|
|
|
func (o *OPStorage) SetUserinfoFromToken(context.Context, *oidc.UserInfo, string, string, string) error {
|
|
panic(o.panicErr("SetUserinfoFromToken"))
|
|
}
|
|
|
|
func (o *OPStorage) SetUserinfoFromScopes(context.Context, *oidc.UserInfo, string, string, []string) error {
|
|
panic(o.panicErr("SetUserinfoFromScopes"))
|
|
}
|
|
|
|
func (o *OPStorage) SetUserinfoFromRequest(context.Context, *oidc.UserInfo, op.IDTokenRequest, []string) error {
|
|
panic(o.panicErr("SetUserinfoFromRequest"))
|
|
}
|
|
|
|
func (o *OPStorage) SetIntrospectionFromToken(context.Context, *oidc.IntrospectionResponse, string, string, string) error {
|
|
panic(o.panicErr("SetIntrospectionFromToken"))
|
|
}
|
|
|
|
func (o *OPStorage) ClientCredentialsTokenRequest(context.Context, string, []string) (op.TokenRequest, error) {
|
|
panic(o.panicErr("ClientCredentialsTokenRequest"))
|
|
}
|
|
|
|
func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) {
|
|
panic(o.panicErr("ClientCredentials"))
|
|
}
|
|
|
|
func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string, []string) (map[string]interface{}, error) {
|
|
panic(o.panicErr("GetPrivateClaimsFromScopes"))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// projectsRoles contains all projects with all their roles for a user
|
|
type projectsRoles struct {
|
|
// key is projectID
|
|
projects map[string]projectRoles
|
|
|
|
requestProjectID string
|
|
}
|
|
|
|
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []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 {
|
|
for _, requestedRole := range requestedRoles {
|
|
for _, grant := range grants {
|
|
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)
|
|
}
|
|
}
|
|
return roles
|
|
}
|
|
|
|
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool) {
|
|
if p.projects == nil {
|
|
p.projects = make(map[string]projectRoles, 1)
|
|
}
|
|
if p.projects[projectID] == nil {
|
|
p.projects[projectID] = make(projectRoles)
|
|
}
|
|
if isRequested {
|
|
p.requestProjectID = projectID
|
|
}
|
|
p.projects[projectID].Add(roleKey, orgID, domain)
|
|
}
|
|
|
|
// projectRoles contains the roles of a project of multiple organisations
|
|
//
|
|
// key of the first map is the role key,
|
|
// key of the second map is the org id, value the org domain
|
|
type projectRoles map[string]map[string]string
|
|
|
|
func (p projectRoles) Add(roleKey, orgID, domain string) {
|
|
if p[roleKey] == nil {
|
|
p[roleKey] = make(map[string]string, 1)
|
|
}
|
|
p[roleKey][orgID] = domain
|
|
}
|
|
|
|
func getGender(gender domain.Gender) oidc.Gender {
|
|
switch gender {
|
|
case domain.GenderFemale:
|
|
return "female"
|
|
case domain.GenderMale:
|
|
return "male"
|
|
case domain.GenderDiverse:
|
|
return "diverse"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interface{} {
|
|
return func(c *actions.FieldConfig) interface{} {
|
|
marshalled, err := json.Marshal(userInfo)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
claims := make(map[string]interface{}, 10)
|
|
if err = json.Unmarshal(marshalled, &claims); err != nil {
|
|
panic(err)
|
|
}
|
|
return c.Runtime.ToValue(claims)
|
|
}
|
|
}
|
|
|
|
func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCredentials]) (_ op.Client, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() {
|
|
err = oidcError(err)
|
|
span.EndWithError(err)
|
|
}()
|
|
|
|
if oidc.GrantType(r.Form.Get("grant_type")) == oidc.GrantTypeClientCredentials {
|
|
return s.clientCredentialsAuth(ctx, r.Data.ClientID, r.Data.ClientSecret)
|
|
}
|
|
|
|
clientID, assertion, err := clientIDFromCredentials(ctx, r.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := s.query.ActiveOIDCClientByID(ctx, clientID, assertion)
|
|
if zerrors.IsNotFound(err) {
|
|
return nil, oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("no active client not found")
|
|
}
|
|
if err != nil {
|
|
return nil, err // defaults to server error
|
|
}
|
|
if client.Settings == nil {
|
|
client.Settings = &query.OIDCSettings{
|
|
AccessTokenLifetime: s.defaultAccessTokenLifetime,
|
|
IdTokenLifetime: s.defaultIdTokenLifetime,
|
|
}
|
|
}
|
|
|
|
switch client.AuthMethodType {
|
|
case domain.OIDCAuthMethodTypeBasic, domain.OIDCAuthMethodTypePost:
|
|
err = s.verifyClientSecret(ctx, client, r.Data.ClientSecret)
|
|
case domain.OIDCAuthMethodTypePrivateKeyJWT:
|
|
err = s.verifyClientAssertion(ctx, client, r.Data.ClientAssertion)
|
|
case domain.OIDCAuthMethodTypeNone:
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ClientFromBusiness(client, s.defaultLoginURL, s.defaultLoginURLV2), nil
|
|
}
|
|
|
|
func (s *Server) verifyClientAssertion(ctx context.Context, client *query.OIDCClient, assertion string) (err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
if assertion == "" {
|
|
return oidc.ErrInvalidClient().WithDescription("empty client assertion")
|
|
}
|
|
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, client.ClockSkew)
|
|
if _, err := op.VerifyJWTAssertion(ctx, assertion, verifier); err != nil {
|
|
return oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("invalid assertion")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) verifyClientSecret(ctx context.Context, client *query.OIDCClient, secret string) (err error) {
|
|
_, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
if secret == "" {
|
|
return oidc.ErrInvalidClient().WithDescription("empty client secret")
|
|
}
|
|
ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify")
|
|
updated, err := s.hasher.Verify(client.HashedSecret, secret)
|
|
spanPasswordComparison.EndWithError(err)
|
|
if err != nil {
|
|
return oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("invalid secret")
|
|
}
|
|
if updated != "" {
|
|
s.command.OIDCUpdateSecret(ctx, client.AppID, client.ProjectID, client.Settings.ResourceOwner, updated)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) checkOrgScopes(ctx context.Context, resourceOwner string, scopes []string) ([]string, error) {
|
|
if slices.ContainsFunc(scopes, func(scope string) bool {
|
|
return strings.HasPrefix(scope, domain.OrgDomainPrimaryScope)
|
|
}) {
|
|
org, err := s.query.OrgByID(ctx, resourceOwner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scopes = slices.DeleteFunc(scopes, func(scope string) bool {
|
|
if domain, ok := strings.CutPrefix(scope, domain.OrgDomainPrimaryScope); ok {
|
|
return domain != org.Domain
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
return slices.DeleteFunc(scopes, func(scope string) bool {
|
|
if orgID, ok := strings.CutPrefix(scope, domain.OrgIDScope); ok {
|
|
return orgID != resourceOwner
|
|
}
|
|
return false
|
|
}), nil
|
|
}
|