mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-16 12:58:00 +00:00
ca1914e235
# Which Problems Are Solved ZITADEL's user grants deactivation mechanism did not work correctly. Deactivated user grants were still provided in token, which could lead to unauthorized access to applications and resources. Additionally, the management and auth API always returned the state as active or did not provide any information about the state. # How the Problems Are Solved - Correctly check the user grant state on active for tokens and user information (userinfo, introspection, saml attributes) - Map state in API and display in Console
1067 lines
34 KiB
Go
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.ActiveOIDCClientByID(ctx, id, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|
|
projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
activeQuery, err := query.NewUserGrantStateQuery(domain.UserGrantStateActive)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{
|
|
Queries: []query.SearchQuery{
|
|
projectQuery,
|
|
userIDQuery,
|
|
activeQuery,
|
|
},
|
|
}, 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.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, false, 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
|
|
}
|