mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-14 11:58:02 +00:00
8e0c8393e9
* implement code exchange * port tokenexchange to v2 tokens * implement refresh token * implement client credentials * implement jwt profile * implement device token * cleanup unused code * fix current unit tests * add user agent unit test * unit test domain package * need refresh token as argument * test commands create oidc session * test commands device auth * fix device auth build error * implicit for oidc session API * implement authorize callback handler for legacy implicit mode * upgrade oidc module to working draft * add missing auth methods and time * handle all errors in defer * do not fail auth request on error the oauth2 Go client automagically retries on any error. If we fail the auth request on the first error, the next attempt will always fail with the Errors.AuthRequest.NoCode, because the auth request state is already set to failed. The original error is then already lost and the oauth2 library does not return the original error. Therefore we should not fail the auth request. Might be worth discussing and perhaps send a bug report to Oauth2? * fix code flow tests by explicitly setting code exchanged * fix unit tests in command package * return allowed scope from client credential client * add device auth done reducer * carry nonce thru session into ID token * fix token exchange integration tests * allow project role scope prefix in client credentials client * gci formatting * do not return refresh token in client credentials and jwt profile * check org scope * solve linting issue on authorize callback error * end session based on v2 session ID * use preferred language and user agent ID for v2 access tokens * pin oidc v3.23.2 * add integration test for jwt profile and client credentials with org scopes * refresh token v1 to v2 * add user token v2 audit event * add activity trigger * cleanup and set panics for unused methods * use the encrypted code for v1 auth request get by code * add missing event translation * fix pipeline errors (hopefully) * fix another test * revert pointer usage of preferred language * solve browser info panic in device auth * remove duplicate entries in AMRToAuthMethodTypes to prevent future `mfa` claim * revoke v1 refresh token to prevent reuse * fix terminate oidc session * always return a new refresh toke in refresh token grant --------- Co-authored-by: Livio Spring <livio.a@gmail.com>
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.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(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).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).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).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
|
|
}
|