userinfo from events for v2 tokens

This commit is contained in:
Tim Möhlmann
2023-11-02 17:27:30 +02:00
parent 85e22c1521
commit 9f7f715259
7 changed files with 485 additions and 79 deletions

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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) }()

View File

@@ -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)
}

View File

@@ -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"
)

View File

@@ -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

254
internal/query/userinfo.go Normal file
View File

@@ -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()
}