mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-14 03:54:21 +00:00
3b140a67c8
# Which Problems Are Solved When the `openid` scope was not requested, as is possible in machine authentication, we didn't set the `sub` (subject) claim to tokens and possibly also userInfo and introspection. This fix always sets the `sub` claim for all cases. # How the Problems Are Solved Set the `Subject` field to regardless of passed scopes. # Additional Changes - none # Additional Context According to standards: - [RFC9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068#name-data-structure) this claim is **required**. - [RFC7667 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2) the claim is optional, however there is no correlation to the `openid` or OpenID Connect. Therefore it doesn't harm to always return this claim. - [OpenID connect, User Info Response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse): "The sub (subject) Claim **MUST** always be returned in the UserInfo Response." Closes https://github.com/zitadel/zitadel/issues/8591
412 lines
14 KiB
Go
412 lines
14 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/zitadel/logging"
|
|
"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/actions/object"
|
|
"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"
|
|
)
|
|
|
|
func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoRequest]) (_ *op.Response, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() {
|
|
err = oidcError(err)
|
|
span.EndWithError(err)
|
|
}()
|
|
|
|
features := authz.GetFeatures(ctx)
|
|
if features.LegacyIntrospection {
|
|
return s.LegacyServer.UserInfo(ctx, r)
|
|
}
|
|
if features.TriggerIntrospectionProjections {
|
|
query.TriggerOIDCUserInfoProjections(ctx)
|
|
}
|
|
|
|
token, err := s.verifyAccessToken(ctx, r.Data.AccessToken)
|
|
if err != nil {
|
|
return nil, op.NewStatusError(oidc.ErrAccessDenied().WithDescription("access token invalid").WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError), http.StatusUnauthorized)
|
|
}
|
|
|
|
var (
|
|
projectID string
|
|
assertion bool
|
|
)
|
|
if token.clientID != "" {
|
|
projectID, assertion, err = s.query.GetOIDCUserinfoClientByID(ctx, token.clientID)
|
|
// token.clientID might contain a username (e.g. client credentials) -> ignore the not found
|
|
if err != nil && !zerrors.IsNotFound(err) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
userInfo, err := s.userInfo(
|
|
token.userID,
|
|
token.scope,
|
|
projectID,
|
|
assertion,
|
|
true,
|
|
false,
|
|
)(ctx, true, domain.TriggerTypePreUserinfoCreation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return op.NewResponse(userInfo), nil
|
|
}
|
|
|
|
// userInfo gets the user's data based on the scope.
|
|
// The returned UserInfo contains standard and reserved claims, documented
|
|
// here: https://zitadel.com/docs/apis/openidoauth/claims.
|
|
//
|
|
// User information is only retrieved once from the database.
|
|
// However, each time, role claims are asserted and also action flows will trigger.
|
|
//
|
|
// projectID is an optional parameter which defines the default audience when there are any (or all) role claims requested.
|
|
// projectRoleAssertion sets the default of returning all project roles, only if no specific roles were requested in the scope.
|
|
// roleAssertion decides whether the roles will be returned (in the token or response)
|
|
// userInfoAssertion decides whether the user information (profile data like name, email, ...) are returned
|
|
//
|
|
// currentProjectOnly can be set to use the current project ID only and ignore the audience from the scope.
|
|
// It should be set in cases where the client doesn't need to know roles outside its own project,
|
|
// for example an introspection client.
|
|
func (s *Server) userInfo(
|
|
userID string,
|
|
scope []string,
|
|
projectID string,
|
|
projectRoleAssertion, userInfoAssertion, currentProjectOnly bool,
|
|
) func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
|
|
var (
|
|
once sync.Once
|
|
rawUserInfo *oidc.UserInfo
|
|
qu *query.OIDCUserInfo
|
|
roleAudience, requestedRoles []string
|
|
)
|
|
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
|
|
once.Do(func() {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
roleAudience, requestedRoles = prepareRoles(ctx, scope, projectID, projectRoleAssertion, currentProjectOnly)
|
|
roleOrgIDs := domain.RoleOrgIDsFromScope(scope)
|
|
qu, err = s.query.GetOIDCUserInfo(ctx, userID, roleAudience, roleOrgIDs...)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rawUserInfo = userInfoToOIDC(qu, userInfoAssertion, scope, s.assetAPIPrefix(ctx))
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// copy the userinfo to make sure the assert roles and actions use their own copy (e.g. map)
|
|
userInfo := &oidc.UserInfo{
|
|
Subject: rawUserInfo.Subject,
|
|
UserInfoProfile: rawUserInfo.UserInfoProfile,
|
|
UserInfoEmail: rawUserInfo.UserInfoEmail,
|
|
UserInfoPhone: rawUserInfo.UserInfoPhone,
|
|
Address: rawUserInfo.Address,
|
|
Claims: maps.Clone(rawUserInfo.Claims),
|
|
}
|
|
assertRoles(projectID, qu, roleAudience, requestedRoles, roleAssertion, userInfo)
|
|
return userInfo, s.userinfoFlows(ctx, qu, userInfo, triggerType)
|
|
}
|
|
}
|
|
|
|
// prepareRoles scans the requested scopes and builds the requested roles
|
|
// and the audience for which roles need to be asserted.
|
|
//
|
|
// Scopes with [ScopeProjectRolePrefix] are added to requestedRoles.
|
|
// When [ScopeProjectsRoles] is present project IDs with the [domain.ProjectIDScope]
|
|
// prefix are added to the returned audience.
|
|
//
|
|
// If projectRoleAssertion is true and there were no specific roles requested,
|
|
// the current projectID will always be parts of the returned audience.
|
|
func prepareRoles(ctx context.Context, scope []string, projectID string, projectRoleAssertion, currentProjectOnly bool) (roleAudience, requestedRoles []string) {
|
|
for _, s := range scope {
|
|
if role, ok := strings.CutPrefix(s, ScopeProjectRolePrefix); ok {
|
|
requestedRoles = append(requestedRoles, role)
|
|
}
|
|
}
|
|
|
|
// If roles are requested take the audience for those from the scopes,
|
|
// when currentProjectOnly is not set.
|
|
if !currentProjectOnly && (len(requestedRoles) > 0 || slices.Contains(scope, ScopeProjectsRoles)) {
|
|
roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scope)
|
|
}
|
|
|
|
// When either:
|
|
// - Project role assertion is set;
|
|
// - Roles for the current project (only) are requested;
|
|
// - There is already a roleAudience requested through scope;
|
|
// - There are requested roles through the scope;
|
|
// and the projectID is not empty, projectID must be part of the roleAudience.
|
|
if (projectRoleAssertion || currentProjectOnly || len(roleAudience) > 0 || len(requestedRoles) > 0) && projectID != "" && !slices.Contains(roleAudience, projectID) {
|
|
roleAudience = append(roleAudience, projectID)
|
|
}
|
|
return roleAudience, requestedRoles
|
|
}
|
|
|
|
func userInfoToOIDC(user *query.OIDCUserInfo, userInfoAssertion bool, scope []string, assetPrefix string) *oidc.UserInfo {
|
|
out := &oidc.UserInfo{
|
|
Subject: user.User.ID,
|
|
}
|
|
for _, s := range scope {
|
|
switch s {
|
|
case oidc.ScopeEmail:
|
|
if !userInfoAssertion {
|
|
continue
|
|
}
|
|
out.UserInfoEmail = userInfoEmailToOIDC(user.User)
|
|
case oidc.ScopeProfile:
|
|
if !userInfoAssertion {
|
|
continue
|
|
}
|
|
out.UserInfoProfile = userInfoProfileToOidc(user.User, assetPrefix)
|
|
case oidc.ScopePhone:
|
|
if !userInfoAssertion {
|
|
continue
|
|
}
|
|
out.UserInfoPhone = userInfoPhoneToOIDC(user.User)
|
|
case oidc.ScopeAddress:
|
|
if !userInfoAssertion {
|
|
continue
|
|
}
|
|
// TODO: handle address for human users as soon as implemented
|
|
case ScopeUserMetaData:
|
|
setUserInfoMetadata(user.Metadata, out)
|
|
case ScopeResourceOwner:
|
|
setUserInfoOrgClaims(user, out)
|
|
default:
|
|
if claim, ok := strings.CutPrefix(s, domain.OrgDomainPrimaryScope); ok {
|
|
out.AppendClaims(domain.OrgDomainPrimaryClaim, claim)
|
|
}
|
|
if claim, ok := strings.CutPrefix(s, domain.OrgIDScope); ok {
|
|
out.AppendClaims(domain.OrgIDClaim, claim)
|
|
setUserInfoOrgClaims(user, out)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertRoles(projectID string, user *query.OIDCUserInfo, roleAudience, requestedRoles []string, assertion bool, info *oidc.UserInfo) {
|
|
if !assertion {
|
|
return
|
|
}
|
|
// prevent returning obtained grants if none where requested
|
|
if (projectID != "" && len(requestedRoles) > 0) || len(roleAudience) > 0 {
|
|
setUserInfoRoleClaims(info, newProjectRoles(projectID, user.UserGrants, requestedRoles))
|
|
}
|
|
}
|
|
|
|
func userInfoEmailToOIDC(user *query.User) oidc.UserInfoEmail {
|
|
if human := user.Human; human != nil {
|
|
return oidc.UserInfoEmail{
|
|
Email: string(human.Email),
|
|
EmailVerified: oidc.Bool(human.IsEmailVerified),
|
|
}
|
|
}
|
|
return oidc.UserInfoEmail{}
|
|
}
|
|
|
|
func userInfoProfileToOidc(user *query.User, assetPrefix string) oidc.UserInfoProfile {
|
|
if human := user.Human; human != nil {
|
|
return oidc.UserInfoProfile{
|
|
Name: human.DisplayName,
|
|
GivenName: human.FirstName,
|
|
FamilyName: human.LastName,
|
|
Nickname: human.NickName,
|
|
Picture: domain.AvatarURL(assetPrefix, user.ResourceOwner, user.Human.AvatarKey),
|
|
Gender: getGender(human.Gender),
|
|
Locale: oidc.NewLocale(human.PreferredLanguage),
|
|
UpdatedAt: oidc.FromTime(user.ChangeDate),
|
|
PreferredUsername: user.PreferredLoginName,
|
|
}
|
|
}
|
|
if machine := user.Machine; machine != nil {
|
|
return oidc.UserInfoProfile{
|
|
Name: machine.Name,
|
|
UpdatedAt: oidc.FromTime(user.ChangeDate),
|
|
PreferredUsername: user.PreferredLoginName,
|
|
}
|
|
}
|
|
return oidc.UserInfoProfile{}
|
|
}
|
|
|
|
func userInfoPhoneToOIDC(user *query.User) oidc.UserInfoPhone {
|
|
if human := user.Human; human != nil {
|
|
return oidc.UserInfoPhone{
|
|
PhoneNumber: string(human.Phone),
|
|
PhoneNumberVerified: human.IsPhoneVerified,
|
|
}
|
|
}
|
|
return oidc.UserInfoPhone{}
|
|
}
|
|
|
|
func setUserInfoMetadata(metadata []query.UserMetadata, out *oidc.UserInfo) {
|
|
if len(metadata) == 0 {
|
|
return
|
|
}
|
|
mdmap := make(map[string]string, len(metadata))
|
|
for _, md := range metadata {
|
|
mdmap[md.Key] = base64.RawURLEncoding.EncodeToString(md.Value)
|
|
}
|
|
out.AppendClaims(ClaimUserMetaData, mdmap)
|
|
}
|
|
|
|
func setUserInfoOrgClaims(user *query.OIDCUserInfo, out *oidc.UserInfo) {
|
|
if org := user.Org; org != nil {
|
|
out.AppendClaims(ClaimResourceOwnerID, org.ID)
|
|
out.AppendClaims(ClaimResourceOwnerName, org.Name)
|
|
out.AppendClaims(ClaimResourceOwnerPrimaryDomain, org.PrimaryDomain)
|
|
}
|
|
}
|
|
|
|
func setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) {
|
|
if roles != nil && len(roles.projects) > 0 {
|
|
if roles, ok := roles.projects[roles.requestProjectID]; ok {
|
|
userInfo.AppendClaims(ClaimProjectRoles, roles)
|
|
}
|
|
for projectID, roles := range roles.projects {
|
|
userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType) (err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
queriedActions, err := s.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, triggerType, qu.User.ResourceOwner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctxFields := actions.SetContextFields(
|
|
actions.SetFields("v1",
|
|
actions.SetFields("claims", userinfoClaims(userInfo)),
|
|
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
|
|
return func(call goja.FunctionCall) goja.Value {
|
|
return object.UserFromQuery(c, qu.User)
|
|
}
|
|
}),
|
|
actions.SetFields("user",
|
|
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
|
return func(goja.FunctionCall) goja.Value {
|
|
return object.UserMetadataListFromSlice(c, qu.Metadata)
|
|
}
|
|
}),
|
|
actions.SetFields("grants", func(c *actions.FieldConfig) interface{} {
|
|
return object.UserGrantsFromSlice(ctx, s.query, c, qu.UserGrants)
|
|
}),
|
|
),
|
|
actions.SetFields("org",
|
|
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
|
return func(goja.FunctionCall) goja.Value {
|
|
return object.GetOrganizationMetadata(ctx, s.query, c, qu.User.ResourceOwner)
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
for _, action := range queriedActions {
|
|
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
|
|
claimLogs := []string{}
|
|
|
|
apiFields := actions.WithAPIFields(
|
|
actions.SetFields("v1",
|
|
actions.SetFields("userinfo",
|
|
actions.SetFields("setClaim", func(key string, value interface{}) {
|
|
if strings.HasPrefix(key, ClaimPrefix) {
|
|
return
|
|
}
|
|
if userInfo.Claims[key] == nil {
|
|
userInfo.AppendClaims(key, value)
|
|
return
|
|
}
|
|
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
|
|
}),
|
|
actions.SetFields("appendLogIntoClaims", func(entry string) {
|
|
claimLogs = append(claimLogs, entry)
|
|
}),
|
|
),
|
|
actions.SetFields("claims",
|
|
actions.SetFields("setClaim", func(key string, value interface{}) {
|
|
if strings.HasPrefix(key, ClaimPrefix) {
|
|
return
|
|
}
|
|
if userInfo.Claims[key] == nil {
|
|
userInfo.AppendClaims(key, value)
|
|
return
|
|
}
|
|
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
|
|
}),
|
|
actions.SetFields("appendLogIntoClaims", func(entry string) {
|
|
claimLogs = append(claimLogs, entry)
|
|
}),
|
|
),
|
|
actions.SetFields("user",
|
|
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) != 2 {
|
|
panic("exactly 2 (key, value) arguments expected")
|
|
}
|
|
key := call.Arguments[0].Export().(string)
|
|
val := call.Arguments[1].Export()
|
|
|
|
value, err := json.Marshal(val)
|
|
if err != nil {
|
|
logging.WithError(err).Debug("unable to marshal")
|
|
panic(err)
|
|
}
|
|
|
|
metadata := &domain.Metadata{
|
|
Key: key,
|
|
Value: value,
|
|
}
|
|
if _, err = s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner); err != nil {
|
|
logging.WithError(err).Info("unable to set md in action")
|
|
panic(err)
|
|
}
|
|
return nil
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
err = actions.Run(
|
|
actionCtx,
|
|
ctxFields,
|
|
apiFields,
|
|
action.Script,
|
|
action.Name,
|
|
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))...,
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(claimLogs) > 0 {
|
|
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|