Virtual members in Umbraco

If you use an external login provider with Umbraco, the member must exist in 2 systems: Umbraco and the external login provider. This makes it harder to use the external login provider as a single source of truth for all members. If the external login provider has thousands of members you don't want them inside Umbraco as well.

With virtual members, the member no longer needs to exist in Umbraco. It only has to exist in the external login provider. Instead of using the auto link feature, it is now possible to have a member that only exists as long as the login session lasts. I have created a virtual members branch of my Umbraco OpenID Connect example package which demonstrates how this works. It is an experiment though. I don't know if it is wise to use this approach on a live environment.

Override the MemberSignInManager

If you login with an external login provider the ExternalLoginSignInAsync method on the MemberSignInManager class will be executed. This is where the auto link feature tries to create the member in Umbraco. By creating a CustomMemberSignInManager class and overriding the ExternalLoginSignInAsync method we can change this behavior. Instead of creating the member in Umbraco we now create a virtual member. That virtual member is then used to login with.

public class CustomMemberSignInManager : MemberSignInManager
{
    private readonly IMemberManager _memberManager;

    public CustomMemberSignInManager(
        UserManager<MemberIdentityUser> memberManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<MemberIdentityUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<MemberIdentityUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<MemberIdentityUser> confirmation,
        IMemberExternalLoginProviders memberExternalLoginProviders,
        IEventAggregator eventAggregator)
        : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
    {
        _memberManager = (IMemberManager)memberManager;
    }

    [Obsolete("Use ctor with all params")]
    public CustomMemberSignInManager(
        UserManager<MemberIdentityUser> memberManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<MemberIdentityUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<MemberIdentityUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<MemberIdentityUser> confirmation)
        : this(
            memberManager,
            contextAccessor,
            claimsFactory,
            optionsAccessor,
            logger,
            schemes,
            confirmation,
            StaticServiceProvider.Instance.GetRequiredService<IMemberExternalLoginProviders>(),
            StaticServiceProvider.Instance.GetRequiredService<IEventAggregator>())
    {
    }

    public override async Task<SignInResult> ExternalLoginSignInAsync(
        ExternalLoginInfo loginInfo, 
        bool isPersistent, 
        bool bypassTwoFactor = false)
    {
        // In the default implementation, the member is fetched from the database.
        // The default implementation also tries to create the member with the auto link feature if it doesn't exist.
        // The auto link feature has been removed here because the member is from an external login provider.
        // We just build a virtual member from the external login info.
        var claims = loginInfo.Principal.Claims;
        var id = claims.FirstOrDefault(x => x.Type == "sid")?.Value;
        var member = _memberManager.CreateVirtualMember(id, loginInfo.Principal.Claims);

        // For now hard code the role. These could be claims from the external login provider.
        member.Claims.Add(new IdentityUserClaim<string>() { ClaimType = ClaimTypes.Role, ClaimValue = "example-group" });

        return await SignInOrTwoFactorAsync(member, isPersistent, loginInfo.LoginProvider, bypassTwoFactor);
    }
}

Override the MemberManager

Umbraco uses the MemberManager for everything that is related to members. For example if you try to visit a page which is protected Umbraco will use the MemberManager to get the current member and check if it has access to the protected page. By creating a CustomMemberManager class and overriding the GetUserAsync and GetRolesAsync methods we can return the virtual member instead of fetching the member from the Umbraco database.

public class CustomMemberManager : MemberManager
{
    public CustomMemberManager(
        IIpResolver ipResolver, 
        IMemberUserStore store, 
        IOptions<IdentityOptions> optionsAccessor, 
        IPasswordHasher<MemberIdentityUser> passwordHasher,
        IEnumerable<IUserValidator<MemberIdentityUser>> userValidators,
        IEnumerable<IPasswordValidator<MemberIdentityUser>> passwordValidators,
        IdentityErrorDescriber errors,
        IServiceProvider services,
        ILogger<UserManager<MemberIdentityUser>> logger,
        IOptionsSnapshot<MemberPasswordConfigurationSettings> passwordConfiguration,
        IPublicAccessService publicAccessService,
        IHttpContextAccessor httpContextAccessor) : base(
        ipResolver,
        store,
        optionsAccessor,
        passwordHasher,
        userValidators,
        passwordValidators,
        errors,
        services,
        logger,
        passwordConfiguration,
        publicAccessService,
        httpContextAccessor)
    {
    }

    public override Task<MemberIdentityUser?> GetUserAsync(ClaimsPrincipal principal)
    {
        var id = GetUserId(principal);

        // In the default implementation, the member is fetched from the database.
        // Since our member is from an external login provider we just build a virtual member.
        var member = this.CreateVirtualMember(id, principal.Claims);

        return Task.FromResult(member);
    }

    public override Task<IList<string>> GetRolesAsync(MemberIdentityUser user)
    {   
        // Multiple roles could be supported, but we don't need them in this example.
        var role = user.Claims.FirstOrDefault(x => x.ClaimType == ClaimTypes.Role)?.ClaimValue;
        var roles = new List<string>
        {
            role
        };

        return Task.FromResult((IList<string>)roles);
    }
}

CreateVirtualMember extension method

In both the CustomMemberSignInManager and CustomMemberManager a virtual member is created. This is that extension method.

public static class MemberManagerExtensions
{
    public static MemberIdentityUser CreateVirtualMember(this IMemberManager memberManager, string id, IEnumerable<Claim> claims)
    {
        var member = new MemberIdentityUser
        {
            Id = id,
            UserName = claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value,
        };

        foreach(var claim in claims)
        {
            member.Claims.Add(new IdentityUserClaim<string>
            {
                ClaimType = claim.Type,
                ClaimValue = claim.Value
            });
        }
        member.IsApproved = true;

        var idToken = claims.FirstOrDefault(x => x.Type == "id_token")?.Value;
        if (!string.IsNullOrEmpty(idToken))
        {
            var loginIdToken = new IdentityUserToken(
                loginProvider: "UmbracoMembers.OpenIdConnect",
                name: "id_token",
                value: idToken,
                userId: null);
            member.LoginTokens.Add(loginIdToken);
        }

        return member;
    }
}

Register the custom classes

Umbraco already registered the MemberSignInManager and MemberManager classes so we need to override those with our own implementation. You can do that with the following code. There is a SetMemberManager extension method that can be used, but for the CustomMemberSignInManager a custom extension method is used.

public static IUmbracoBuilder AddCustomMemberSignInManager(this IUmbracoBuilder builder)
{
    builder.Services.AddScoped<IMemberSignInManager, CustomMemberSignInManager>();
    return builder;
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddUmbraco(_env, _config)
        .AddBackOffice()
        .AddWebsite()
        .AddComposers()
        .AddOpenIdConnectAuthentication()
        .AddCustomMemberSignInManager()
        .SetMemberManager<CustomMemberManager>()
        .Build();
}

And these are all the changes needed to use virtual members in Umbraco. After this you can use an external login provider and the members no longer need to exist in Umbraco. Like I also mentioned before this is an experiment. You might run into issues if you try to use this on a live environment. You can find a working example here: https://github.com/jbreuer/Umbraco-OpenIdConnect-Example/tree/feature/virtual-members.

Requires Umbraco 11.2 or higher

It was not yet possible to override all methods in the MemberSignInManager and MemberManager classes. For that I made a PR that is part of Umbraco 11.2: https://github.com/umbraco/Umbraco-CMS/pull/13752.