Try our conversational search powered by Generative AI!

Scott Reed
Nov 22, 2018
  3354
(8 votes)

Working Around IQueryableNotificationUsers when using external claims based CMS users

We have implemented as per the guide here https://world.episerver.com/documentation/developer-guides/CMS/security/integrate-azure-ad-using-openid-connect/ claims based authentication hooking up with an AzureAD instance.

As part of this the standard user flow is that when a user logs in the following code is called

ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(ctx.AuthenticationTicket.Identity);

This creates a record in the [tblSynchedUser] table which allows parts of the CMS to select these users, however as myself and another developer has found the user selection was not working with parts of episerver such as Project Comments and Workflow when trying to selected a user.

https://world.episerver.com/forum/developer-forum/-Episerver-75-CMS/Thread-Container/2018/11/content-approvals-with-azure-ad/ 

https://world.episerver.com/forum/developer-forum/-Episerver-75-CMS/Thread-Container/2018/11/tag-user-in-project-comment-not-working-when-using-external-authentication-provider/

Issues

After digging around using DotPeek it turns out that there's an IQueryableNotificationUsers interface which is used by some of the rest stores when trying to query users, this interface is implemtned by 2 classes.

  • DefaultSynchronizedUsersRepository
  • AspNetIdentitySecurityEntityProvider

And after running my solution and getting an instance of IQueryableNotificationUsers it was returning back the AspNetIdentitySecurityEntityProvider which seems like a bug.

I could not find any documentation on how to set this from a config source and sadly DefaultSynchronizedUsersRepository happens to be an internal interface so this left me with one solution which has worked.

I have copied the code for this in to a class of my own

 /// <summary>
    /// Copy of internal DefaultSynchronizedUsersRepository to get around user selection issues
    /// </summary>
    /// <seealso cref="IQueryableNotificationUsers" />
    /// <seealso cref="IQueryablePreference" />
    /// <seealso cref="ISynchronizedUsersRepository" />
    public class CustomSynchronizedUsersRepository: IQueryableNotificationUsers, IQueryablePreference, ISynchronizedUsersRepository
    {
        private static ILogger _log = LogManager.GetLogger();
        private const string CacheKey = "GetRolesForUser_";
        private readonly ServiceAccessor<SynchronizeUsersDB> _syncronizeUsersDB;
        private readonly TaskExecutor _taskExecutor;
        private readonly IDatabaseMode _databaseModeService;
        private readonly ISynchronizedObjectInstanceCache _cache;
        private readonly ClaimTypeOptions _claimTypeOptions;
        private static IList<string> _synchronizedClaims;

        public CustomSynchronizedUsersRepository(ServiceAccessor<SynchronizeUsersDB> windowsProviderDb, TaskExecutor taskExecutor, IDatabaseMode databaseModeService, ISynchronizedObjectInstanceCache cache, ClaimTypeOptions claimTypeOptions)
        {
            this._syncronizeUsersDB = windowsProviderDb;
            this._taskExecutor = taskExecutor;
            this._databaseModeService = databaseModeService;
            this._cache = cache;
            this._claimTypeOptions = claimTypeOptions ?? new ClaimTypeOptions();
            CustomSynchronizedUsersRepository._synchronizedClaims = (IList<string>)new string[3]
            {
        this._claimTypeOptions.Email,
        this._claimTypeOptions.GivenName,
        this._claimTypeOptions.Surname
            };
        }

        public virtual Task SynchronizeAsync(string username, string roleClaimType, IEnumerable<Claim> claimsToSync)
        {
            return this._taskExecutor.Start((Action)(() => this.SynchronizeUserAndClaims(username, roleClaimType, claimsToSync)));
        }

        public virtual void Synchronize(string userName, string roleClaimType, IEnumerable<Claim> claimsToSync)
        {
            this.SynchronizeUserAndClaims(userName, roleClaimType, claimsToSync);
        }

        public virtual void ClearRoles(string userName)
        {
            this._syncronizeUsersDB().SynchronizeRoles(userName, Enumerable.Empty<string>());
            this.ClearUserCache(userName);
        }

        public virtual IEnumerable<string> GetRolesForUser(string userName)
        {
            EPiServer.Framework.Validator.ThrowIfNull(nameof(userName), (object)userName);
            string key = "GetRolesForUser_" + userName;
            string[] array = this._cache.Get(key) as string[];
            if (array == null)
            {
                array = this._syncronizeUsersDB().ListRolesForUser(userName).ToArray<string>();
                CacheEvictionPolicy evictionPolicy = new CacheEvictionPolicy(TimeSpan.FromMinutes(1.0), CacheTimeoutType.Absolute);
                this._cache.Insert(key, (object)array, evictionPolicy);
            }
            return (IEnumerable<string>)array;
        }

        public virtual IEnumerable<string> FindUsersInRole(string roleName, string partOfUsername)
        {
            return this._syncronizeUsersDB().FindUsersInRole(roleName, partOfUsername);
        }

        public virtual IEnumerable<string> FindUsers(string partOfName)
        {
            return this._syncronizeUsersDB().FindUsersByName(partOfName);
        }

        public virtual IEnumerable<string> FindRoles(string partOfName)
        {
            return this._syncronizeUsersDB().FindMatchingRoles(partOfName);
        }

        public virtual IEnumerable<SynchronizedRoleStatus> ListRoleStatus()
        {
            return this._syncronizeUsersDB().ListRoleStatus();
        }

        public virtual void HideRole(string role)
        {
            EPiServer.Framework.Validator.ThrowIfNullOrEmpty(nameof(role), role);
            this._syncronizeUsersDB().SetVisibilityForRole(false, role);
        }

        public virtual void ShowRole(string role)
        {
            EPiServer.Framework.Validator.ThrowIfNullOrEmpty(nameof(role), role);
            this._syncronizeUsersDB().SetVisibilityForRole(true, role);
        }

        public IEnumerable<string> DefaultSynchronizedClaims
        {
            get
            {
                return (IEnumerable<string>)CustomSynchronizedUsersRepository._synchronizedClaims;
            }
        }

        private void ClearUserCache(string userName)
        {
            this._cache.Remove("GetRolesForUser_" + userName);
        }

        private List<string> GetRolesFromClaims(string roleClaimType, IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(roleClaimType, StringComparison.OrdinalIgnoreCase))).Where<Claim>((Func<Claim, bool>)(c => !string.IsNullOrEmpty(c.Value))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).Distinct<string>((IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase).ToList<string>();
        }

        private static NameValueCollection GetAdditionalClaims(IEnumerable<Claim> claims)
        {
            NameValueCollection nameValueCollection = new NameValueCollection();
            foreach (Claim claim in claims.Where<Claim>((Func<Claim, bool>)(c =>
            {
                if (!CustomSynchronizedUsersRepository._synchronizedClaims.Contains<string>(c.Type, (IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase))
                    return !string.IsNullOrEmpty(c.Value);
                return false;
            })))
                nameValueCollection.Add(claim.Type, claim.Value);
            return nameValueCollection;
        }

        internal string GetEmailFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.Email, StringComparison.Ordinal))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        internal string GetGivenNameFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.GivenName, StringComparison.OrdinalIgnoreCase))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        internal string GetSurnameFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.Surname, StringComparison.OrdinalIgnoreCase))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        Task<PagedNotificationUserResult> IQueryableNotificationUsers.FindAsync(string partOfUser, int pageIndex, int pageSize)
        {
            return this._syncronizeUsersDB().FindUsersAsync(partOfUser, pageIndex, pageSize);
        }

        internal virtual void SynchronizeUserAndClaims(string userName, string roleClaimType, IEnumerable<Claim> claims)
        {
            EPiServer.Framework.Validator.ThrowIfNull(nameof(userName), (object)userName);
            this.RequiredReadWriteDatabaseMode((Action)(() =>
            {
                string emailFromClaims = this.GetEmailFromClaims(claims);
                string givenNameFromClaims = this.GetGivenNameFromClaims(claims);
                string surnameFromClaims = this.GetSurnameFromClaims(claims);
                NameValueCollection additionalClaims = CustomSynchronizedUsersRepository.GetAdditionalClaims(claims);
                if (!string.IsNullOrEmpty(givenNameFromClaims) || !string.IsNullOrEmpty(surnameFromClaims) || (!string.IsNullOrEmpty(emailFromClaims) || additionalClaims.Count > 0))
                    this._syncronizeUsersDB().SynchronizeUser(userName, givenNameFromClaims, surnameFromClaims, emailFromClaims, additionalClaims);
                else if (!this._syncronizeUsersDB().FindUsersByName(userName).Any<string>((Func<string, bool>)(u => string.Equals(userName, u, StringComparison.OrdinalIgnoreCase))))
                    this._syncronizeUsersDB().SynchronizeUser(userName, (string)null, (string)null, (string)null, (NameValueCollection)null);
                this._syncronizeUsersDB().SynchronizeRoles(userName, (IEnumerable<string>)this.GetRolesFromClaims(roleClaimType, claims));
            }));
            this.ClearUserCache(userName);
        }

        private void RequiredReadWriteDatabaseMode(Action action)
        {
            if (this._databaseModeService.DatabaseMode == DatabaseMode.ReadWrite)
            {
                action();
            }
            else
            {
                ILogger log = CustomSynchronizedUsersRepository._log;
                string messageFormat = "The action '{0}' has not been called becuase the database is in the ReadOnly mode.";
                object[] objArray = new object[1];
                int index = 0;
                string str;
                if (action == null)
                {
                    str = (string)null;
                }
                else
                {
                    MethodInfo method = action.Method;
                    str = (object)method != null ? method.Name : (string)null;
                }
                objArray[index] = (object)str;
                log.Debug(messageFormat, objArray);
            }
        }

        int IQueryablePreference.SortOrder
        {
            get
            {
                return 20;
            }
        }

        string IQueryablePreference.GetPreference(string userName, string preferenceName)
        {
            string str = (string)null;
            NameValueCollection nameValueCollection = this._syncronizeUsersDB().LoadMetadata(userName);
            if (nameValueCollection != null)
                str = nameValueCollection.GetValues(preferenceName)?[0];
            return str;
        }
    }

And have registered this class using the standard dependencie framework

            container.Services.AddTransient<IQueryableNotificationUsers, CustomSynchronizedUsersRepository>();
            container.Services.AddTransient<IQueryablePreference, CustomSynchronizedUsersRepository>();
            container.Services.AddTransient<ISynchronizedUsersRepository, CustomSynchronizedUsersRepository>();

Until this issue is fixed or I find a way to set this in configuration I will continue to use this, but I'll pop it as a bug and ideally hopefully they will make this class public so be can control this in our DI code.

Thanks.

Nov 22, 2018

Comments

Matthew Boniface
Matthew Boniface May 10, 2021 10:46 PM

I'm hitting a similar issue where notifications aren't been emailed. The above sounds like it lines up with the same problem as we're also using AAD B2C (Azure Active Directory). However, when I tried this code I got this exception on start up:

Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.

Exception Details: StructureMap.Building.StructureMapBuildException: Bi-directional dependency relationship detected!
Check the StructureMap stacktrace below:
1.) Instance of EPiServer.Notification.IQueryableNotificationUsers (Epik.Web.Infrastructure.CustomSynchronizedUsersRepository)
2.) new NotificationUserRepositoryImpl(*Default of IQueryableNotificationUsers*, *Default of IEnumerable<INotificationProvider>*, *Default of IEnumerable<IQueryablePreference>*)
3.) EPiServer.Notification.Internal.NotificationUserRepositoryImpl
4.) Instance of EPiServer.Notification.Internal.INotificationUserRepository (EPiServer.Notification.Internal.NotificationUserRepositoryImpl)
5.) new DefaultNotifier(*Default of INotificationUserRepository*, *Default of INotificationRepository*, *Default of IEnumerable<INotificationFormatter>*, *Default of IEnumerable<IUserNotificationFormatter>*, *Default of QueryableNotificationUserService*, *Default of INotificationChannelOptionsRegistry*, *Default of INotificationDispatcher*)
6.) EPiServer.Notification.Internal.DefaultNotifier
7.) Instance of EPiServer.Notification.INotifier (EPiServer.Notification.Internal.DefaultNotifier)
8.) new NotificationService(*Default of INotifier*)
9.) Epik.Web.Logging.Services.NotificationService ('Epik.Web.Logging.Services.NotificationService, Epik.Web, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null')
10.) Instance of Epik.Web.Logging.Services.INotificationService ('Epik.Web.Logging.Services.NotificationService, Epik.Web, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null')
11.) new Logger(*Default of TelemetryClient*, *Default of INotificationService*)
12.) Epik.Web.Logging.Logger
13.) Instance of Epik.Web.Logging.ILogProvider (Epik.Web.Logging.Logger)
14.) new CustomSynchronizedUsersRepository(*Default of ServiceAccessor<SynchronizeUsersDB>*, *Default of TaskExecutor*, *Default of IDatabaseMode*, *Default of ISynchronizedObjectInstanceCache*, *Default of ClaimTypeOptions*, *Default of ILogProvider*)
15.) Epik.Web.Infrastructure.CustomSynchronizedUsersRepository
16.) Instance of EPiServer.Notification.IQueryableNotificationUsers (Epik.Web.Infrastructure.CustomSynchronizedUsersRepository)
17.) new QueryableNotificationUsersProfileImpl(*Default of IQueryableProfile*, *Default of IQueryableNotificationUsers*, *Default of IQueryableNotificationUser*)
18.) EPiServer.Notification.Internal.QueryableNotificationUsersProfileImpl
19.) Instance of EPiServer.Notification.QueryableNotificationUserService (EPiServer.Notification.Internal.QueryableNotificationUsersProfileImpl)
20.) new ApprovalService(*Default of IApprovalDefinitionRepository*, *Default of IApprovalDefinitionVersionRepository*, *Default of QueryableNotificationUserService*, *Default of ContentLoaderService*, *Default of IApprovalRepository*, *Default of IApprovalEngine*, *Default of ServiceAccessor<SiteDefinition>*, *Default of SecurityEntityProvider*)
21.) EPiServer.Cms.Shell.UI.Rest.Approvals.ApprovalService
22.) Instance of EPiServer.Cms.Shell.UI.Rest.Approvals.ApprovalService
23.) new ContentService(*Default of IContentRepository*, *Default of IContentVersionRepository*, *Default of ILanguageBranchRepository*, *Default of IContentProviderManager*, *Default of AncestorReferencesLoader*, *Default of LanguageSelectorFactory*, *Default of ISiteDefinitionRepository*, *Default of ProjectLoaderService*, *Default of ContentEvents*, *Default of Settings*, *Default of ISiteConfigurationRepository*, *Default of IStatusTransitionEvaluator*, *Default of ApprovalService*, *Default of SaveActionRuleEngine*)
24.) EPiServer.Cms.Shell.Service.Internal.ContentService
25.) Instance of EPiServer.Cms.Shell.Service.Internal.ContentService
26.) new ProjectService(*Default of ProjectRepository*, *Default of ProjectPublisher*, *Default of ContentService*, *Default of IContentChangeManager*, *Default of LanguageSelectorFactory*, *Default of CurrentProject*, *Default of ISiteConfigurationRepository*, *Default of IConfigurationSource*, *Default of ApprovalService*, *Default of LocalizationService*)
27.) EPiServer.Cms.Shell.UI.Rest.Projects.Internal.ProjectService
28.) Instance of EPiServer.Cms.Shell.UI.Rest.Projects.IProjectService (EPiServer.Cms.Shell.UI.Rest.Projects.Internal.ProjectService)
29.) Container.GetInstance(EPiServer.Cms.Shell.UI.Rest.Projects.IProjectService)

I know it's been a few years since this post - is there something that I need to change to make this code work? Keen to see if it resolves our issue.

Matthew Boniface
Matthew Boniface May 11, 2021 12:17 AM

Okay, ignore my other comment (I tried to delete it) as it was just me making a mistake with the code I copied - I caused a circular dependency by putting a new dependency in that used the notification services :)

Thanks for this post Scott - this seems to have resolved it for us also on a project using AAD B2C. I hope others find this if they have this - and I hope Episerver resolve this bug as it seems to happen for ours on Episerver CMS 11.20.6

Matthew Boniface
Matthew Boniface May 12, 2021 01:35 AM

Alright, so from discussions with Epi support on this the fix that avoids this work around is to simply remove the package "EPiServer.CMS.UI.AspNetIdentity". After doing so the problem has gone away completely for me. So if you don't need this package I recommend removing it.

Shella Cabatbat
Shella Cabatbat Feb 9, 2022 06:33 AM

For cases that you need mixed OpenId/ADFS and the built-in ASP.NET Identity and can't get rid of "EPiServer.CMS.UI.AspNetIdentity".

You might want to try adding these overrides after ConfigurationComplete.

context.Services.AddTransient<IQueryableNotificationUser, QueryableNotificationUser>(); //This is most likely the most important line!
context.Services.AddTransient<QueryableNotificationUserService, QueryableNotificationUsersImpl>();
context.Services.AddTransient<SecurityEntityProvider, SynchronizingRolesSecurityEntityProvider>();

The EPiServer.CMS.UI.AspNetIdentity, as I found out, has initialization code that forces the use of AspnetSecurityIdentityProvider :-|

[InitializableModule]
public class ApplicationSecurityEntityInitialization : IConfigurableModule, IInitializableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
      if (context.HostType != HostType.WebApplication)
        return;
      Func<ApplicationDbContext<IdentityUser>> dbContext = (Func<ApplicationDbContext<IdentityUser>>) (() => new ApplicationDbContext<IdentityUser>(ConnectionStringNameResolver.Resolve()));
      Func<AspNetIdentitySecurityEntityProvider> aspnetIdentitySecurityEntityProviderFactory = (Func<AspNetIdentitySecurityEntityProvider>) (() => new AspNetIdentitySecurityEntityProvider((ServiceAccessor<UserManager<IdentityUser>>) (() => new UserManager<IdentityUser>((IUserStore<IdentityUser>) new UserStore<IdentityUser>((DbContext) dbContext()))), (ServiceAccessor<RoleManager<IdentityRole>>) (() => new RoleManager<IdentityRole>((IRoleStore<IdentityRole, string>) new RoleStore<IdentityRole>((DbContext) dbContext()))), (ServiceAccessor<ApplicationDbContext<IdentityUser>>) (() => dbContext())));
      context.Services.AddTransient<SecurityEntityProvider>((Func<IServiceLocator, SecurityEntityProvider>) (s => (SecurityEntityProvider) aspnetIdentitySecurityEntityProviderFactory())).AddTransient<IQueryableNotificationUsers>((Func<IServiceLocator, IQueryableNotificationUsers>) (s => (IQueryableNotificationUsers) aspnetIdentitySecurityEntityProviderFactory())).AddTransient<IQueryableNotificationUser>((Func<IServiceLocator, IQueryableNotificationUser>) (s => (IQueryableNotificationUser) aspnetIdentitySecurityEntityProviderFactory()));
    }

    public void Initialize(InitializationEngine context)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}

Please login to comment.
Latest blogs
Blazor in Optimizely CMS 12 with .NET 8

How to enable support for Blazor components in Optimizely CMS 12 after upgrading to .NET 8.

Ted | May 30, 2024 | Syndicated blog

Build a headless blog with Astro and Optimizely SaaS CMS

I’m a big fan of using the right tool for the right job. I’m also a big fan of Astro , for the right use case. Let's explore Astro to see what it's...

Jacob Pretorius | May 28, 2024

Microsoft announces Natural language to SQL

Finally, Microsoft launches "Natural language to SQL," after it has been available for several months in Optimizely CMS!

Tomas Hensrud Gulla | May 23, 2024 | Syndicated blog

Five easy ways to start personalizing your content right now

If you clicked on this article, you already know that getting the right message to the right person at the right time helps drive conversions and...

Kara Andersen | May 23, 2024

ExtendedCms.TinyMceEnhancements – serwer side webp support

Today I will introduce another small feature of TinyMceEnhancements plugin. The functionality is used to automatically detect whether a browser...

Grzegorz Wiecheć | May 22, 2024 | Syndicated blog

Azure AI Language– Detect Healthcare Content in Optimizely CMS

In this blog post, I showcase how the Azure AI Language service's Text Analytics for health feature can be used to detect healthcare content within...

Anil Patel | May 22, 2024 | Syndicated blog