ASP.NET Identity Auth in ServiceStack (Legacy)

INFO

For new projects we recommend starting with the new ASP.NET Core Identity Auth templates.


mvcidentity is a .NET 6.0 MVC Website integrated with ServiceStack using ASP.NET Identity Auth:

Create new mvcidentity project with:

x new mvcidentity ProjectName

mvcidentity is essentially the same App with the same functionality as mvcauth but rewritten to use ASP.NET Identity Auth instead of ServiceStack Auth, including the registration options which are handled implemented using MVC Controllers instead of ServiceStack's built-in Services:

mvcidentity defaults to using EF and SQL Server which we expect to be the most popular configuration:

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

services.AddIdentity<ApplicationUser, IdentityRole>(options => {
        options.User.AllowedUserNameCharacters = null;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

The rest of Startup.cs contains the standard setup for configuring ASP.NET Identity Auth with the same Twitter, Facebook, Google and Microsoft OAuth Providers.

A custom ApplicationUser EF DataModel is used to better prepare for real world usage to show how to propagate custom User metadata
down into Authenticated UserSessions. mvcidentity starts with an extended ApplicationUser that captures basic info about the user and capture external references to any 3rd Party OAuth providers that Users have signed in with:

public class ApplicationUser : IdentityUser
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string DisplayName { get; set; }

    public string TwitterUserId { get; set; }
    public string TwitterScreenName { get; set; }

    public string FacebookUserId { get; set; }

    public string GoogleUserId { get; set; }

    public string GoogleProfilePageUrl { get; set; }

    public string MicrosoftUserId { get; set; }
    
    public string ProfileUrl { get; set; }
}

Mapping Customizations

By default the NetCoreIdentityAuthProvider.cs uses the MapClaimsToSession dictionary to map well known ClaimTypes Properties to their natural AuthUserSession property:

public Dictionary<string, string> MapClaimsToSession { get; set; } = new Dictionary<string, string> {
    [ClaimTypes.Email] = nameof(AuthUserSession.Email),
    [ClaimTypes.Name] = nameof(AuthUserSession.UserAuthName),
    [ClaimTypes.GivenName] = nameof(AuthUserSession.FirstName),
    [ClaimTypes.Surname] = nameof(AuthUserSession.LastName),
    [ClaimTypes.StreetAddress] = nameof(AuthUserSession.Address),
    [ClaimTypes.Locality] = nameof(AuthUserSession.City),
    [ClaimTypes.StateOrProvince] = nameof(AuthUserSession.State),
    [ClaimTypes.PostalCode] = nameof(AuthUserSession.PostalCode),
    [ClaimTypes.Country] = nameof(AuthUserSession.Country),
    [ClaimTypes.OtherPhone] = nameof(AuthUserSession.PhoneNumber),
    [ClaimTypes.DateOfBirth] = nameof(AuthUserSession.BirthDateRaw),
    [ClaimTypes.Gender] = nameof(AuthUserSession.Gender),
    [ClaimTypes.Dns] = nameof(AuthUserSession.Dns),
    [ClaimTypes.Rsa] = nameof(AuthUserSession.Rsa),
    [ClaimTypes.Sid] = nameof(AuthUserSession.Sid),
    [ClaimTypes.Hash] = nameof(AuthUserSession.Hash),
    [ClaimTypes.HomePhone] = nameof(AuthUserSession.HomePhone),
    [ClaimTypes.MobilePhone] = nameof(AuthUserSession.MobilePhone),
    [ClaimTypes.Webpage] = nameof(AuthUserSession.Webpage),
};

Which you can also extend or modify to handle any additional straightforward 1:1 mappings.

Custom Mappings

Alternatively you can use PopulateSessionFilter to apply additional logic when creating a UserSession from a ClaimsPrincipal which is what's needed to copy over EF Identity Roles when using EF Identity Auth with ServiceStack.

As mvcidentity doesn't have a dependency on OrmLite you could choose to populate roles using EF's APIs directly:

new NetCoreIdentityAuthProvider(AppSettings) 
{
    PopulateSessionFilter = (session, principal, req) => 
    {
        var userManager = req.TryResolve<UserManager<ApplicationUser>>();
        var user = userManager.FindByIdAsync(session.Id).Result;
        var roles = userManager.GetRolesAsync(user).Result;        
    }
},

Whilst this works it uses "sync over async" which is discouraged and problematic in many use-cases, less efficient than just sync and UserManager's limited API available forces multiple DB calls and more data over the wire than just the role names needed.

Built-in Identity DB APIs

Instead we recommend instead using the more optimal IDbConnection.GetIdentityUserRolesById() API which returns just the role names in a single indexed DB query.

If you're not using OrmLite you can utilize EF's configured DB Connection by adding this extension method to your host project:

public static class AppExtensions
{
    public static T DbExec<T>(this IServiceProvider services, Func<IDbConnection, T> fn) => services
        .DbContextExec<ApplicationDbContext,T>(x => { 
            x.Database.OpenConnection(); return x.Database.GetDbConnection(); }, fn);
}

ASP.NET Core Identity Auth Adapter

Where you'll able to use it to perform adhoc DB queries, in this case calling GetIdentityUserRolesById() to populate the Users roles:

new NetCoreIdentityAuthProvider(AppSettings) 
{
    PopulateSessionFilter = (session, principal, req) => {
        session.Roles = ApplicationServices.DbExec(db => db.GetIdentityUserRolesById(session.Id));
    }
}

This gets called whenever ServiceStack receives an Authenticated Request which you can intercept and customize how ClaimsPrincipal are mapped to ServiceStack User Sessions.

To improve performance and save the DB hit, we recommend caching the User Roles into an In Memory Cache:

new NetCoreIdentityAuthProvider(AppSettings) 
{
    PopulateSessionFilter = (session, principal, req) => {
        session.Roles = req.GetMemoryCacheClient().GetOrCreate(
            IdUtils.CreateUrn(nameof(session.Roles), session.Id),
            TimeSpan.FromMinutes(5),
            () => ApplicationServices.DbExec(db => db.GetIdentityUserRolesById(session.Id)));
    }
},

INFO

Alternatively use req.GetCacheClient() if you want to use your registered ICacheClient provider instead

Propagating Extended User Info

In addition to populating the Users Roles we also want to populate our custom User metadata on our ApplicationUser EF model, for this we can use the new GetIdentityUserById<T> API which we'll also want to cache.

This brings us to the end result in mvcidentity project template:

Plugins.Add(new AuthFeature(() => new CustomUserSession(), 
    new IAuthProvider[] {
        new NetCoreIdentityAuthProvider(AppSettings) // Adapter to enable ASP.NET Identity Auth in ServiceStack
        {
            AdminRoles = { "Manager" }, // Automatically Assign additional roles to Admin Users
            PopulateSessionFilter = (session, principal, req) => {
                //Example of populating ServiceStack Session Roles + Custom Info from EF Identity DB
                var user = req.GetMemoryCacheClient().GetOrCreate(
                    IdUtils.CreateUrn(nameof(ApplicationUser), session.Id),
                    TimeSpan.FromMinutes(5), //return cached results before refreshing cache from db /5mins
                    () => ApplicationServices.DbExec(db=>db.GetIdentityUserById<ApplicationUser>(session.Id)));

                session.Email = session.Email ?? user.Email;
                session.FirstName = session.FirstName ?? user.FirstName;
                session.LastName = session.LastName ?? user.LastName;
                session.DisplayName = session.DisplayName ?? user.DisplayName;
                session.ProfileUrl = user.ProfileUrl ?? AuthMetadataProvider.DefaultNoProfileImgUrl;

                session.Roles = req.GetMemoryCacheClient().GetOrCreate(
                    IdUtils.CreateUrn(nameof(session.Roles), session.Id),
                    TimeSpan.FromMinutes(5), //return cached results before refreshing cache from db /5mins
                    () => ApplicationServices.DbExec(db => db.GetIdentityUserRolesById(session.Id)));
            }
        }, 
    }));