zitadel/internal/api/oidc/client.go
Tim Möhlmann 5fd2061770
feat(oidc): allow returning of parent errors to client (#8376)
# Which Problems Are Solved

Currently the OIDC API of ZITADEL only prints parent errors to the logs.
Where 4xx status are typically warn level and 5xx error level. This
makes it hard to debug certain errors for client in multi-instance
environments like ZITADEL cloud, where there is no direct access to
logs. In case of support requests we often can't correlate past
log-lines to the error that was reported.

This change adds the possibility to return the parent error in the
response to the OIDC client. For the moment this only applies to JSON
body responses, not error redirects to the RP.

# How the Problems Are Solved

- New instance-level feature flag: `debug_oidc_parent_error`
- Use the new `WithReturnParentToClient()` function from the oidc lib
introduced in https://github.com/zitadel/oidc/pull/629 for all cases
where `WithParent` was already used and the request context is
available.

# Additional Changes

none

# Additional Context

- Depends on: https://github.com/zitadel/oidc/pull/629
- Related to: https://github.com/zitadel/zitadel/issues/8362

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
2024-08-20 06:45:24 +00:00

1067 lines
34 KiB
Go

package oidc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/dop251/goja"
"github.com/go-jose/go-jose/v4"
"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"
api_http "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"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"
)
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.GetOIDCClientByID(ctx, id, false)
if err != nil {
return nil, err
}
if client.State != domain.AppStateActive {
return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-sdaGg", "client is not active")
}
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil
}
func (o *OPStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) {
return o.GetKeyByIDAndIssuer(ctx, keyID, userID)
}
func (o *OPStorage) GetKeyByIDAndIssuer(ctx context.Context, keyID, issuer string) (_ *jose.JSONWebKey, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
publicKeyData, err := o.query.GetAuthNKeyPublicKeyByIDAndIdentifier(ctx, keyID, issuer)
if err != nil {
return nil, err
}
publicKey, err := crypto.BytesToPublicKey(publicKeyData)
if err != nil {
return nil, err
}
return &jose.JSONWebKey{
KeyID: keyID,
Use: "sig",
Key: publicKey,
}, nil
}
func (o *OPStorage) ValidateJWTProfileScopes(ctx context.Context, subject string, scopes []string) (_ []string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
user, err := o.query.GetUserByID(ctx, true, subject)
if err != nil {
return nil, err
}
return o.checkOrgScopes(ctx, user, scopes)
}
func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secret string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
ctx = authz.SetCtxData(ctx, authz.CtxData{
UserID: oidcCtx,
OrgID: oidcCtx,
})
app, err := o.query.AppByClientID(ctx, id)
if err != nil {
return err
}
if app.OIDCConfig != nil {
return o.command.VerifyOIDCClientSecret(ctx, app.ProjectID, app.ID, secret)
}
return o.command.VerifyAPIClientSecret(ctx, app.ProjectID, app.ID, secret)
}
func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.UserInfo, tokenID, subject, origin string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
if strings.HasPrefix(tokenID, command.IDPrefixV2) {
token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID)
if err != nil {
return err
}
if err = o.isOriginAllowed(ctx, token.ClientID, origin); err != nil {
return err
}
return o.setUserinfo(ctx, userInfo, token.UserID, token.ClientID, token.Scope, nil)
}
token, err := o.repo.TokenByIDs(ctx, subject, tokenID)
if err != nil {
return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired")
}
if token.ApplicationID != "" {
if err = o.isOriginAllowed(ctx, token.ApplicationID, origin); err != nil {
return err
}
}
return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil)
}
func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
if applicationID != "" {
app, err := o.query.AppByOIDCClientID(ctx, applicationID)
if err != nil {
return err
}
if app.OIDCConfig.AssertIDTokenRole {
scopes, err = o.assertProjectRoleScopes(ctx, applicationID, scopes)
if err != nil {
return zerrors.ThrowPreconditionFailed(err, "OIDC-Dfe2s", "Errors.Internal")
}
}
}
return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes, nil)
}
// SetUserinfoFromRequest extends the SetUserinfoFromScopes during the id_token generation.
// This is required for V2 tokens to be able to set the sessionID (`sid`) claim.
func (o *OPStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.IDTokenRequest, _ []string) error {
switch t := request.(type) {
case *AuthRequestV2:
userinfo.AppendClaims("sid", t.SessionID)
case *RefreshTokenRequestV2:
userinfo.AppendClaims("sid", t.SessionID)
}
return nil
}
func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
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)
if err != nil {
return zerrors.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 zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired")
}
projectID, err := o.query.ProjectIDFromClientID(ctx, clientID)
if err != nil {
return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found")
}
if token.IsPAT {
err = o.assertClientScopesForPAT(ctx, token, clientID, projectID)
if err != nil {
return zerrors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal")
}
}
return o.introspect(ctx, introspection,
token.ID, token.UserID, token.ApplicationID, clientID, projectID,
token.Audience, token.Scopes,
token.CreationDate, token.Expiration)
}
func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (_ op.TokenRequest, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
user, err := o.query.GetUserByLoginName(ctx, false, clientID)
if err != nil {
return nil, err
}
scope, err = o.checkOrgScopes(ctx, user, scope)
if err != nil {
return nil, err
}
audience := domain.AddAudScopeToAudience(ctx, nil, scope)
return &clientCredentialsRequest{
sub: user.ID,
scopes: scope,
audience: audience,
}, nil
}
// ClientCredentials method is kept to keep the storage interface implemented.
// However, it should never be called as the VerifyClient method on the Server is overridden.
func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) {
return nil, zerrors.ThrowInternal(nil, "OIDC-Su8So", "Errors.Internal")
}
// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin
// if no origin is provided, no error will be returned
func (o *OPStorage) isOriginAllowed(ctx context.Context, clientID, origin string) error {
if origin == "" {
return nil
}
app, err := o.query.AppByOIDCClientID(ctx, clientID)
if err != nil {
return err
}
if api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) {
return nil
}
return zerrors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed")
}
func (o *OPStorage) introspect(
ctx context.Context,
introspection *oidc.IntrospectionResponse,
tokenID, subject, tokenClientID, introspectionClientID, introspectionProjectID string,
audience, scope []string,
tokenCreation, tokenExpiration time.Time,
) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
for _, aud := range audience {
if aud == introspectionClientID || aud == introspectionProjectID {
userInfo := new(oidc.UserInfo)
err = o.setUserinfo(ctx, userInfo, subject, introspectionClientID, scope, []string{introspectionProjectID})
if err != nil {
return err
}
introspection.SetUserInfo(userInfo)
introspection.Scope = scope
introspection.ClientID = tokenClientID
introspection.TokenType = oidc.BearerToken
introspection.Expiration = oidc.FromTime(tokenExpiration)
introspection.IssuedAt = oidc.FromTime(tokenCreation)
introspection.NotBefore = oidc.FromTime(tokenCreation)
introspection.Audience = audience
introspection.Issuer = op.IssuerFromContext(ctx)
introspection.JWTID = tokenID
return nil
}
}
return zerrors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client")
}
func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) {
for i := len(scopes) - 1; i >= 0; i-- {
scope := scopes[i]
if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) {
var orgID string
org, err := o.query.OrgByPrimaryDomain(ctx, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope))
if err == nil {
orgID = org.ID
}
if orgID != user.ResourceOwner {
scopes[i] = scopes[len(scopes)-1]
scopes[len(scopes)-1] = ""
scopes = scopes[:len(scopes)-1]
}
}
if strings.HasPrefix(scope, domain.OrgIDScope) {
if strings.TrimPrefix(scope, domain.OrgIDScope) != user.ResourceOwner {
scopes[i] = scopes[len(scopes)-1]
scopes[len(scopes)-1] = ""
scopes = scopes[:len(scopes)-1]
}
}
}
return scopes, nil
}
func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string, roleAudience []string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
user, err := o.query.GetUserByID(ctx, true, userID)
if err != nil {
return err
}
var allRoles bool
roles := make([]string, 0)
for _, scope := range scopes {
switch scope {
case oidc.ScopeOpenID:
userInfo.Subject = user.ID
case oidc.ScopeEmail:
setUserInfoEmail(userInfo, user)
case oidc.ScopeProfile:
o.setUserInfoProfile(ctx, userInfo, user)
case oidc.ScopePhone:
setUserInfoPhone(userInfo, user)
case oidc.ScopeAddress:
//TODO: handle address for human users as soon as implemented
case ScopeUserMetaData:
if err := o.setUserInfoMetadata(ctx, userInfo, userID); err != nil {
return err
}
case ScopeResourceOwner:
if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil {
return err
}
case ScopeProjectsRoles:
allRoles = true
default:
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix))
}
if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) {
userInfo.AppendClaims(domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope))
}
if strings.HasPrefix(scope, domain.OrgIDScope) {
userInfo.AppendClaims(domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope))
if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil {
return err
}
}
}
}
// if all roles are requested take the audience for those from the scopes
if allRoles && len(roleAudience) == 0 {
roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes)
}
userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles, roleAudience)
if err != nil {
return err
}
o.setUserInfoRoleClaims(userInfo, projectRoles)
return o.userinfoFlows(ctx, user, userGrants, userInfo)
}
func (o *OPStorage) setUserInfoProfile(ctx context.Context, userInfo *oidc.UserInfo, user *query.User) {
userInfo.PreferredUsername = user.PreferredLoginName
userInfo.UpdatedAt = oidc.FromTime(user.ChangeDate)
if user.Machine != nil {
userInfo.Name = user.Machine.Name
return
}
userInfo.Name = user.Human.DisplayName
userInfo.FamilyName = user.Human.LastName
userInfo.GivenName = user.Human.FirstName
userInfo.Nickname = user.Human.NickName
userInfo.Gender = getGender(user.Human.Gender)
userInfo.Locale = oidc.NewLocale(user.Human.PreferredLanguage)
userInfo.Picture = domain.AvatarURL(o.assetAPIPrefix(ctx), user.ResourceOwner, user.Human.AvatarKey)
}
func setUserInfoEmail(userInfo *oidc.UserInfo, user *query.User) {
if user.Human == nil {
return
}
userInfo.UserInfoEmail = oidc.UserInfoEmail{
Email: string(user.Human.Email),
EmailVerified: oidc.Bool(user.Human.IsEmailVerified)}
}
func setUserInfoPhone(userInfo *oidc.UserInfo, user *query.User) {
if user.Human == nil {
return
}
userInfo.UserInfoPhone = oidc.UserInfoPhone{
PhoneNumber: string(user.Human.Phone),
PhoneNumberVerified: user.Human.IsPhoneVerified,
}
}
func (o *OPStorage) setUserInfoMetadata(ctx context.Context, userInfo *oidc.UserInfo, userID string) error {
userMetaData, err := o.assertUserMetaData(ctx, userID)
if err != nil {
return err
}
if len(userMetaData) > 0 {
userInfo.AppendClaims(ClaimUserMetaData, userMetaData)
}
return nil
}
func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc.UserInfo, userID string) error {
resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID)
if err != nil {
return err
}
for claim, value := range resourceOwnerClaims {
userInfo.AppendClaims(claim, value)
}
return nil
}
func (o *OPStorage) 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 (o *OPStorage) userinfoFlows(ctx context.Context, user *query.User, userGrants *query.UserGrants, userInfo *oidc.UserInfo) error {
queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, 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, user)
}
}),
actions.SetFields("user",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner)
if err != nil {
logging.WithError(err).Debug("unable to create search query")
panic(err)
}
metadata, err := o.query.SearchUserMetadata(
ctx,
true,
userInfo.Subject,
&query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get md in action")
panic(err)
}
return object.UserMetadataListFromQuery(c, metadata)
}
}),
actions.SetFields("grants",
func(c *actions.FieldConfig) interface{} {
return object.UserGrantsFromQuery(ctx, o.query, c, userGrants)
},
),
),
actions.SetFields("org",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
return object.GetOrganizationMetadata(ctx, o.query, c, 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 = o.command.SetUserMetadata(ctx, metadata, userInfo.Subject, 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
}
func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(err)
span.EndWithError(err)
}()
roles := make([]string, 0)
var allRoles bool
for _, scope := range scopes {
switch scope {
case ScopeUserMetaData:
userMetaData, err := o.assertUserMetaData(ctx, userID)
if err != nil {
return nil, err
}
if len(userMetaData) > 0 {
claims = appendClaim(claims, ClaimUserMetaData, userMetaData)
}
case ScopeResourceOwner:
resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID)
if err != nil {
return nil, err
}
for claim, value := range resourceOwnerClaims {
claims = appendClaim(claims, claim, value)
}
case ScopeProjectsRoles:
allRoles = true
}
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix))
}
if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) {
claims = appendClaim(claims, domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope))
}
if strings.HasPrefix(scope, domain.OrgIDScope) {
claims = appendClaim(claims, domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope))
resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID)
if err != nil {
return nil, err
}
for claim, value := range resourceOwnerClaims {
claims = appendClaim(claims, claim, value)
}
}
}
// If requested, use the audience as context for the roles,
// otherwise the project itself will be used
var roleAudience []string
if allRoles {
roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes)
}
userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles, roleAudience)
if err != nil {
return nil, err
}
if projectRoles != nil && len(projectRoles.projects) > 0 {
if roles, ok := projectRoles.projects[projectRoles.requestProjectID]; ok {
claims = appendClaim(claims, ClaimProjectRoles, roles)
}
for projectID, roles := range projectRoles.projects {
claims = appendClaim(claims, fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles)
}
}
return o.privateClaimsFlows(ctx, userID, userGrants, claims)
}
func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userGrants *query.UserGrants, claims map[string]interface{}) (map[string]interface{}, error) {
user, err := o.query.GetUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreAccessTokenCreation, user.ResourceOwner)
if err != nil {
return nil, err
}
ctxFields := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("claims", func(c *actions.FieldConfig) interface{} {
return c.Runtime.ToValue(claims)
}),
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
return func(call goja.FunctionCall) goja.Value {
return object.UserFromQuery(c, user)
}
}),
actions.SetFields("user",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner)
if err != nil {
logging.WithError(err).Debug("unable to create search query")
panic(err)
}
metadata, err := o.query.SearchUserMetadata(
ctx,
true,
userID,
&query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get md in action")
panic(err)
}
return object.UserMetadataListFromQuery(c, metadata)
}
}),
actions.SetFields("grants", func(c *actions.FieldConfig) interface{} {
return object.UserGrantsFromQuery(ctx, o.query, c, userGrants)
}),
),
actions.SetFields("org",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner)
}
}),
),
),
)
for _, action := range queriedActions {
claimLogs := []string{}
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
apiFields := actions.WithAPIFields(
actions.SetFields("v1",
actions.SetFields("claims",
actions.SetFields("setClaim", func(key string, value interface{}) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if _, ok := claims[key]; !ok {
claims = appendClaim(claims, 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 = o.command.SetUserMetadata(ctx, metadata, userID, 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 nil, err
}
if len(claimLogs) > 0 {
claims = appendClaim(claims, fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
claimLogs = nil
}
}
return claims, nil
}
func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) {
if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 {
return nil, nil, nil
}
projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID)
// applicationID might contain a username (e.g. client credentials) -> ignore the not found
if err != nil && !zerrors.IsNotFound(err) {
return nil, nil, err
}
// ensure the projectID of the requesting is part of the roleAudience
if projectID != "" {
roleAudience = append(roleAudience, projectID)
}
queries := make([]query.SearchQuery, 0, 2)
projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience)
if err != nil {
return nil, nil, err
}
queries = append(queries, projectQuery)
userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
if err != nil {
return nil, nil, err
}
queries = append(queries, userIDQuery)
grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{
Queries: queries,
}, true)
if err != nil {
return nil, nil, err
}
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.UserGrants {
checkGrantedRoles(roles, *grant, requestedRole, grant.ProjectID == projectID)
}
}
return grants, roles, nil
}
// no specific roles were requested, so convert any grants into roles
for _, grant := range grants.UserGrants {
for _, role := range grant.Roles {
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID)
}
}
return grants, roles, nil
}
func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) {
metaData, err := o.query.SearchUserMetadata(ctx, true, userID, &query.UserMetadataSearchQueries{}, false)
if err != nil {
return nil, err
}
userMetaData := make(map[string]string)
for _, md := range metaData.Metadata {
userMetaData[md.Key] = base64.RawURLEncoding.EncodeToString(md.Value)
}
return userMetaData, nil
}
func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) (map[string]string, error) {
user, err := o.query.GetUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
resourceOwner, err := o.query.OrgByID(ctx, true, user.ResourceOwner)
if err != nil {
return nil, err
}
return map[string]string{
ClaimResourceOwnerID: resourceOwner.ID,
ClaimResourceOwnerName: resourceOwner.Name,
ClaimResourceOwnerPrimaryDomain: resourceOwner.Domain,
}, nil
}
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 appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} {
if claims == nil {
claims = make(map[string]interface{})
}
claims[claim] = value
return claims
}
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.GetOIDCClientByID(ctx, clientID, assertion)
if zerrors.IsNotFound(err) {
return nil, oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("client not found")
}
if err != nil {
return nil, err // defaults to server error
}
if client.State != domain.AppStateActive {
return nil, oidc.ErrInvalidClient().WithDescription("client is not active")
}
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 {
s.command.OIDCSecretCheckFailed(ctx, client.AppID, client.ProjectID, client.Settings.ResourceOwner)
return oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("invalid secret")
}
s.command.OIDCSecretCheckSucceeded(ctx, client.AppID, client.ProjectID, client.Settings.ResourceOwner, updated)
return nil
}
func (s *Server) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) {
if slices.ContainsFunc(scopes, func(scope string) bool {
return strings.HasPrefix(scope, domain.OrgDomainPrimaryScope)
}) {
org, err := s.query.OrgByID(ctx, false, user.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 != user.ResourceOwner
}
return false
}), nil
}