diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 7c562881710..38b48bb4ba2 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -27,6 +27,7 @@ import ( ) const ( + // TODO: remove declarations: (moved to domain package) ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:" ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles" ClaimProjectRoles = "urn:zitadel:iam:org:project:roles" @@ -179,21 +180,6 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID, false) - if err != nil { - return errors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - return o.introspect(ctx, introspection, - tokenID, token.UserID, token.ClientID, clientID, projectID, - token.Audience, token.Scope, - token.AccessTokenCreation, token.AccessTokenExpiration) - } - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) if err != nil { return errors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go new file mode 100644 index 00000000000..b98980358d9 --- /dev/null +++ b/internal/api/oidc/introspect.go @@ -0,0 +1,110 @@ +package oidc + +import ( + "context" + "errors" + "slices" + "strings" + "time" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" + "github.com/zitadel/zitadel/internal/command" + errz "github.com/zitadel/zitadel/internal/errors" +) + +func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionRequest]) (_ *op.Response, err error) { + clientID, err := s.authenticateResourceClient(ctx, r.Data.ClientCredentials) + if err != nil { + return nil, err + } + response := new(oidc.IntrospectionResponse) + tokenID, subject, err := s.getTokenIDAndSubject(ctx, r.Data.Token) + if err != nil { + // TODO: log error + return op.NewResponse(response), nil + } + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + err = s.introspect(ctx, response, tokenID, subject, clientID) + return op.NewResponse(response), nil + } + + err = s.storage.SetIntrospectionFromToken(ctx, response, tokenID, subject, clientID) + if err != nil { + return op.NewResponse(response), nil + } + response.Active = true + return op.NewResponse(response), nil +} + +func (s *Server) authenticateResourceClient(ctx context.Context, cc *op.ClientCredentials) (clientID string, err error) { + if cc.ClientAssertion != "" { + verifier := op.NewJWTProfileVerifier(s.storage, op.IssuerFromContext(ctx), 1*time.Hour, time.Second) + profile, err := op.VerifyJWTAssertion(ctx, cc.ClientAssertion, verifier) + if err != nil { + return "", err + } + return profile.Issuer, nil + } + + if err = s.storage.AuthorizeClientIDSecret(ctx, cc.ClientID, cc.ClientSecret); err != nil { + if err != nil { + return "", err + } + } + return cc.ClientID, nil +} + +func (s *Server) getTokenIDAndSubject(ctx context.Context, accessToken string) (idToken, subject string, err error) { + provider := s.Provider() + tokenIDSubject, err := provider.Crypto().Decrypt(accessToken) + if err == nil { + splitToken := strings.Split(tokenIDSubject, ":") + if len(splitToken) != 2 { + return "", "", errors.New("invalid token format") + } + return splitToken[0], splitToken[1], nil + } + + verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.storage.keySet) + accessTokenClaims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, verifier) + if err != nil { + return "", "", err + } + return accessTokenClaims.JWTID, accessTokenClaims.Subject, nil +} + +func (s *Server) introspect(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { + // TODO: give clients their own aggregate, so we can skip this query + projectID, err := s.storage.query.ProjectIDFromClientID(ctx, clientID, false) + if err != nil { + return errz.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") + } + + token, err := s.storage.query.ActiveAccessTokenByToken(ctx, tokenID) + if err != nil { + return err + } + if !slices.ContainsFunc(token.Audience, func(aud string) bool { + return aud == token.ClientID || aud == projectID + }) { + return errz.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") + } + + userInfo, err := s.storage.query.GetOIDCUserinfo(ctx, token.UserID, token.Scope, []string{projectID}) + if err != nil { + return err + } + introspection.SetUserInfo(userinfoToOIDC(userInfo, token.Scope)) + introspection.Scope = token.Scope + introspection.ClientID = token.ClientID + introspection.TokenType = oidc.BearerToken + introspection.Expiration = oidc.FromTime(token.AccessTokenExpiration) + introspection.IssuedAt = oidc.FromTime(token.AccessTokenCreation) + introspection.NotBefore = oidc.FromTime(token.AccessTokenCreation) + introspection.Audience = token.Audience + introspection.Issuer = op.IssuerFromContext(ctx) + introspection.JWTID = tokenID + + return nil +} diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 18b2638bcdb..cd05e24e533 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -2,10 +2,7 @@ package oidc import ( "context" - "errors" "net/http" - "strings" - "time" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" @@ -163,62 +160,6 @@ func (s *Server) DeviceToken(ctx context.Context, r *op.ClientRequest[oidc.Devic return s.LegacyServer.DeviceToken(ctx, r) } -func (s *Server) authenticateResourceClient(ctx context.Context, cc *op.ClientCredentials) (clientID string, err error) { - if cc.ClientAssertion != "" { - verifier := op.NewJWTProfileVerifier(s.storage, op.IssuerFromContext(ctx), 1*time.Hour, time.Second) - profile, err := op.VerifyJWTAssertion(ctx, cc.ClientAssertion, verifier) - if err != nil { - return "", err - } - return profile.Issuer, nil - } - - if err = s.storage.AuthorizeClientIDSecret(ctx, cc.ClientID, cc.ClientSecret); err != nil { - if err != nil { - return "", err - } - } - return cc.ClientID, nil -} - -func (s *Server) getTokenIDAndSubject(ctx context.Context, accessToken string) (idToken, subject string, err error) { - provider := s.Provider() - tokenIDSubject, err := provider.Crypto().Decrypt(accessToken) - if err == nil { - splitToken := strings.Split(tokenIDSubject, ":") - if len(splitToken) != 2 { - return "", "", errors.New("invalid token format") - } - return splitToken[0], splitToken[1], nil - } - - verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.storage.keySet) - accessTokenClaims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, verifier) - if err != nil { - return "", "", err - } - return accessTokenClaims.JWTID, accessTokenClaims.Subject, nil -} - -func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionRequest]) (_ *op.Response, err error) { - clientID, err := s.authenticateResourceClient(ctx, r.Data.ClientCredentials) - if err != nil { - return nil, err - } - response := new(oidc.IntrospectionResponse) - tokenID, subject, err := s.getTokenIDAndSubject(ctx, r.Data.Token) - if err != nil { - // TODO: log error - return op.NewResponse(response), nil - } - err = s.storage.SetIntrospectionFromToken(ctx, response, tokenID, subject, clientID) - if err != nil { - return op.NewResponse(response), nil - } - response.Active = true - return op.NewResponse(response), nil -} - func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoRequest]) (_ *op.Response, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go new file mode 100644 index 00000000000..100ddbbf99d --- /dev/null +++ b/internal/api/oidc/userinfo.go @@ -0,0 +1,88 @@ +package oidc + +import ( + "strings" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func userinfoToOIDC(user *query.OIDCUserinfo, scopes []string) *oidc.UserInfo { + out := new(oidc.UserInfo) + for _, scope := range scopes { + switch scope { + case oidc.ScopeOpenID: + out.Subject = user.ID + case oidc.ScopeEmail: + out.UserInfoEmail = userInfoEmailToOIDC(user) + case oidc.ScopeProfile: + out.UserInfoProfile = userInfoProfileToOidc(user) + case oidc.ScopePhone: + out.UserInfoPhone = userInfoPhoneToOIDC(user) + case oidc.ScopeAddress: + out.Address = userInfoAddressToOIDC(user) + case ScopeUserMetaData: + if len(user.Metadata) > 0 { + out.AppendClaims(ClaimUserMetaData, user.Metadata) + } + case ScopeResourceOwner: + setUserInfoOrgClaims(user, out) + default: + if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { + out.AppendClaims(domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) + } + if strings.HasPrefix(scope, domain.OrgIDScope) { + out.AppendClaims(domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) + setUserInfoOrgClaims(user, out) + } + } + } + + return out +} + +func userInfoEmailToOIDC(user *query.OIDCUserinfo) oidc.UserInfoEmail { + return oidc.UserInfoEmail{ + Email: string(user.Email), + EmailVerified: oidc.Bool(user.IsEmailVerified), + } +} + +func userInfoProfileToOidc(user *query.OIDCUserinfo) oidc.UserInfoProfile { + return oidc.UserInfoProfile{ + Name: user.Name, + GivenName: user.FirstName, + FamilyName: user.LastName, + Nickname: user.NickName, + // Picture: domain.AvatarURL(o.assetAPIPrefix(ctx), user.ResourceOwner, user.Human.AvatarKey), + Gender: getGender(user.Gender), + Locale: oidc.NewLocale(user.PreferredLanguage), + UpdatedAt: oidc.FromTime(user.UpdatedAt), + // PreferredUsername: user.PreferredLoginName, + } +} + +func userInfoPhoneToOIDC(user *query.OIDCUserinfo) oidc.UserInfoPhone { + return oidc.UserInfoPhone{ + PhoneNumber: string(user.Phone), + PhoneNumberVerified: user.IsPhoneVerified, + } +} + +func userInfoAddressToOIDC(user *query.OIDCUserinfo) *oidc.UserInfoAddress { + return &oidc.UserInfoAddress{ + // Formatted: ??, + StreetAddress: user.StreetAddress, + Locality: user.Locality, + Region: user.Region, + PostalCode: user.PostalCode, + Country: user.Country, + } +} + +func setUserInfoOrgClaims(user *query.OIDCUserinfo, out *oidc.UserInfo) { + out.AppendClaims(ClaimResourceOwner+"id", user.OrgID) + out.AppendClaims(ClaimResourceOwner+"name", user.OrgName) + out.AppendClaims(ClaimResourceOwner+"primary_domain", user.OrgPrimaryDomain) +} diff --git a/internal/domain/oidc_scopes.go b/internal/domain/oidc_scopes.go new file mode 100644 index 00000000000..5f963ac26f1 --- /dev/null +++ b/internal/domain/oidc_scopes.go @@ -0,0 +1,22 @@ +package domain + +import "github.com/zitadel/oidc/v3/pkg/oidc" + +const ( + ScopeOpenID = oidc.ScopeOpenID + ScopeProfile = oidc.ScopeProfile + ScopeEmail = oidc.ScopeEmail + ScopeAddress = oidc.ScopeAddress + ScopePhone = oidc.ScopePhone + ScopeOfflineAccess = oidc.ScopeOfflineAccess + + 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" + ClaimResourceOwner = ScopeResourceOwner + ":" + ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log" +) diff --git a/internal/query/access_token.go b/internal/query/access_token.go index 664796d2d29..f2db4387373 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -52,11 +52,8 @@ func (wm *OIDCSessionAccessTokenReadModel) Reduce() error { return wm.WriteModel.Reduce() } -func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - AwaitOpenTransactions(). - AllowTimeTravel(). - AddQuery(). +func (wm *OIDCSessionAccessTokenReadModel) addQuery(b *eventstore.SearchQueryBuilder) *eventstore.SearchQueryBuilder { + return b.AddQuery(). AggregateTypes(oidcsession.AggregateType). AggregateIDs(wm.AggregateID). EventTypes( @@ -68,6 +65,14 @@ func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilde Builder() } +func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilder { + return wm.addQuery( + eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AllowTimeTravel(), + ) +} + func (wm *OIDCSessionAccessTokenReadModel) reduceAdded(e *oidcsession.AddedEvent) { wm.UserID = e.UserID wm.SessionID = e.SessionID diff --git a/internal/query/userinfo.go b/internal/query/userinfo.go new file mode 100644 index 00000000000..49d272d9109 --- /dev/null +++ b/internal/query/userinfo.go @@ -0,0 +1,254 @@ +package query + +import ( + "context" + "encoding/base64" + "slices" + "strings" + "time" + + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "golang.org/x/text/language" +) + +func (q *Queries) GetOIDCUserinfo(ctx context.Context, userID string, scope, roleAudience []string) (_ *OIDCUserinfo, err error) { + if slices.Contains(scope, domain.ScopeProjectsRoles) { + roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scope) + // TODO: we need to get the project roles and user roles. + } + + user := newOidcUserinfoReadModel(userID, scope) + if err = q.eventstore.FilterToQueryReducer(ctx, user); err != nil { + return nil, err + } + + if hasOrgScope(scope) { + org := newoidcUserinfoOrganizationReadModel(user.ResourceOwner) + if err = q.eventstore.FilterToQueryReducer(ctx, org); err != nil { + return nil, err + } + + user.OrgID = org.AggregateID + user.OrgName = org.Name + user.OrgPrimaryDomain = org.PrimaryDomain + } + + return &user.OIDCUserinfo, nil +} + +func hasOrgScope(scope []string) bool { + return slices.ContainsFunc(scope, func(s string) bool { + return s == domain.ScopeResourceOwner || strings.HasPrefix(s, domain.OrgIDScope) + }) +} + +type OIDCUserinfo struct { + ID string + UserName string + Name string + FirstName string + LastName string + NickName string + PreferredLanguage language.Tag + Gender domain.Gender + Avatar string + UpdatedAt time.Time + + Email domain.EmailAddress + IsEmailVerified bool + + Phone domain.PhoneNumber + IsPhoneVerified bool + + Country string + Locality string + PostalCode string + Region string + StreetAddress string + + UserState domain.UserState + UserType domain.UserType + + OrgID string + OrgName string + OrgPrimaryDomain string + + Metadata map[string]string +} + +type oidcUserinfoReadmodel struct { + eventstore.ReadModel + scope []string // Scope is used to determine events + OIDCUserinfo +} + +func newOidcUserinfoReadModel(userID string, scope []string) *oidcUserinfoReadmodel { + return &oidcUserinfoReadmodel{ + ReadModel: eventstore.ReadModel{ + AggregateID: userID, + }, + scope: scope, + OIDCUserinfo: OIDCUserinfo{ + ID: userID, + }, + } +} + +func (rm *oidcUserinfoReadmodel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AllowTimeTravel(). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(rm.AggregateID). + EventTypes(rm.scopeToEventTypes()...). + Builder() +} + +// scopeToEventTypes sets required user events to obtain get the correct userinfo. +// Events such as UserLocked, UserDeactivated and UserRemoved are not checked, +// as access tokens should already be revoked. +func (rm *oidcUserinfoReadmodel) scopeToEventTypes() []eventstore.EventType { + types := make([]eventstore.EventType, 0, len(rm.scope)) + types = append(types, user.HumanAddedType, user.MachineAddedEventType) + + for _, scope := range rm.scope { + switch scope { + case domain.ScopeEmail: + types = append(types, user.HumanEmailChangedType, user.HumanEmailVerifiedType) + case domain.ScopeProfile: + types = append(types, user.HumanProfileChangedType, user.HumanAvatarAddedType, user.HumanAvatarRemovedType) + case domain.ScopePhone: + types = append(types, user.HumanPhoneChangedType, user.HumanPhoneVerifiedType, user.HumanPhoneRemovedType) + case domain.ScopeAddress: + types = append(types, user.HumanAddressChangedType) + case domain.ScopeUserMetaData: + types = append(types, user.MetadataSetType, user.MetadataRemovedType, user.MetadataRemovedAllType) + } + } + return slices.Compact(types) +} + +func (rm *oidcUserinfoReadmodel) Reduce() error { + for _, event := range rm.Events { + switch e := event.(type) { + case *user.HumanAddedEvent: + rm.UserName = e.UserName + rm.FirstName = e.FirstName + rm.LastName = e.LastName + rm.NickName = e.NickName + rm.Name = e.DisplayName + rm.PreferredLanguage = e.PreferredLanguage + rm.Gender = e.Gender + rm.Email = e.EmailAddress + rm.Phone = e.PhoneNumber + rm.Country = e.Country + rm.Locality = e.Locality + rm.PostalCode = e.PostalCode + rm.Region = e.Region + rm.StreetAddress = e.StreetAddress + rm.UpdatedAt = e.Creation + case *user.MachineAddedEvent: + rm.UserName = e.UserName + rm.Name = e.Name + rm.UpdatedAt = e.Creation + case *user.HumanEmailChangedEvent: + rm.Email = e.EmailAddress + rm.IsEmailVerified = false + rm.UpdatedAt = e.Creation + case *user.HumanEmailVerifiedEvent: + rm.IsEmailVerified = e.IsEmailVerified + rm.UpdatedAt = e.Creation + case *user.HumanProfileChangedEvent: + rm.FirstName = e.FirstName + rm.LastName = e.LastName + rm.NickName = gu.Value(e.NickName) + rm.Name = gu.Value(e.DisplayName) + rm.PreferredLanguage = gu.Value(e.PreferredLanguage) + rm.Gender = gu.Value(e.Gender) + rm.UpdatedAt = e.Creation + case *user.HumanAvatarAddedEvent: + rm.Avatar = e.StoreKey + rm.UpdatedAt = e.Creation + case *user.HumanAvatarRemovedEvent: + rm.Avatar = "" + rm.UpdatedAt = e.Creation + case *user.HumanPhoneChangedEvent: + rm.Phone = e.PhoneNumber + rm.IsPhoneVerified = false + rm.UpdatedAt = e.Creation + case *user.HumanPhoneVerifiedEvent: + rm.IsEmailVerified = e.IsPhoneVerified + rm.UpdatedAt = e.Creation + case *user.HumanPhoneRemovedEvent: + rm.Phone = "" + rm.IsPhoneVerified = false + rm.UpdatedAt = e.Creation + case *user.HumanAddressChangedEvent: + rm.Country = gu.Value(e.Country) + rm.Locality = gu.Value(e.Locality) + rm.PostalCode = gu.Value(e.PostalCode) + rm.Region = gu.Value(e.Region) + rm.StreetAddress = gu.Value(e.StreetAddress) + rm.UpdatedAt = e.Creation + case *user.MetadataSetEvent: + rm.Metadata[e.Key] = base64.RawURLEncoding.EncodeToString(e.Value) + rm.UpdatedAt = e.Creation + case *user.MetadataRemovedEvent: + delete(rm.Metadata, e.Key) + rm.UpdatedAt = e.Creation + case *user.MetadataRemovedAllEvent: + for key := range rm.Metadata { + delete(rm.Metadata, key) + } + rm.UpdatedAt = e.Creation + } + } + + return rm.ReadModel.Reduce() +} + +type oidcUserinfoOrganizationReadModel struct { + eventstore.ReadModel + + Name string + PrimaryDomain string +} + +func newoidcUserinfoOrganizationReadModel(orgID string) *oidcUserinfoOrganizationReadModel { + return &oidcUserinfoOrganizationReadModel{ + ReadModel: eventstore.ReadModel{ + AggregateID: orgID, + }, + } +} + +func (rm *oidcUserinfoOrganizationReadModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AwaitOpenTransactions(). + AllowTimeTravel(). + AddQuery(). + AggregateTypes(org.AggregateType). + AggregateIDs(rm.AggregateID). + EventTypes(org.OrgAddedEventType, org.OrgChangedEventType, org.OrgDomainPrimarySetEventType). + Builder() +} + +func (rm *oidcUserinfoOrganizationReadModel) Reduce() error { + for _, event := range rm.Events { + switch e := event.(type) { + case *org.OrgAddedEvent: + rm.Name = e.Name + case *org.OrgChangedEvent: + rm.Name = e.Name + case *org.DomainPrimarySetEvent: + rm.PrimaryDomain = e.Domain + } + } + + return rm.ReadModel.Reduce() +}