mirror of
https://github.com/zitadel/zitadel.git
synced 2025-11-02 03:38:46 +00:00
# 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)
292 lines
9.7 KiB
Go
292 lines
9.7 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"
|
|
ClaimProjectsRoles = "urn:zitadel:iam:org:projects: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, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// projectsRoles contains all projects with all their roles for a user
|
|
type projectsRoles struct {
|
|
// key is projectID
|
|
projects map[string]projectRoles
|
|
|
|
requestProjectID string
|
|
|
|
requestAudIDs map[string]struct{}
|
|
}
|
|
|
|
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 {
|
|
for _, requestedRole := range requestedRoles {
|
|
for _, grant := range grants {
|
|
checkGrantedRoles(roles, grant, requestedRole, grant.ProjectID == projectID)
|
|
}
|
|
}
|
|
}
|
|
// no specific roles were requested, so convert any grants into roles
|
|
for _, grant := range grants {
|
|
for _, role := range grant.Roles {
|
|
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, isAudienceReq 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
|
|
}
|
|
if p.requestAudIDs == nil {
|
|
p.requestAudIDs = make(map[string]struct{}, 1)
|
|
}
|
|
if isAudienceReq {
|
|
p.requestAudIDs[projectID] = struct{}{}
|
|
}
|
|
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
|
|
}
|