Scuzzlebutt
Scuzzlebutt

Reputation: 505

How does Asp.Net Identity 2 User Info get mapped to IdentityServer3 profile claims

I've got Asp.Net Identity 2 all set up and running with a custom user store backed by SQL Server via Dapper. At this point in my dev/testing I'm only concerned with local accounts (but will be adding in external login providers). I have a custom user that includes the standard properties that Asp.Net Identity wants, and added a few of my own (FirstName, LastName):

public class AppUser : IUser<Guid>
{
    public Guid Id { get; set; }
    public string UserName { get; set; }
    public string PasswordHash { get; set; }
    public string SecurityStamp { get; set; }
    public string Email { get; set; }
    public bool EmailConfirmed { get; set; }
    public bool LockoutEnabled { get; set; }
    public DateTimeOffset LockoutEndDate { get; set; }
    public int AccessFailedCount { get; set; }

    // Custom User Properties
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

In my MVC web app, I configure the OIDC like so:

       app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
        {
            Authority = ConfigurationManager.AppSettings["OpenIdConnectAuthenticationOptions.Authority"],
            ClientId = "MVC.Web",
            Scope = "openid profile email",
            RedirectUri = ConfigurationManager.AppSettings["OpenIdConnectAuthenticationOptions.RedirectUri"],
            ResponseType = "id_token",
            SignInAsAuthenticationType = "Cookies"
        });

Since I included profile as a requested scope, I get:

preferred_username: testuser

And since I included email as a requested scope, I get:

email:          [email protected]
email_verified: true

I'm not explicitly telling my AspNetIdentityUserService how to map the UserName property in my AppUser to the preferred_username claim and I'm not sure how that happens. Therefore, I don't understand how to get the FirstName property mapped to the given_name claim so that it will be returned with the id_token.

What I've researched:

So if you look at the IdentityServer3 AspNetIdentity sample here I found this ClaimsIdentityFactory which looked like it should do the trick:

    public override async Task<ClaimsIdentity> CreateAsync(UserManager<User, string> manager, User user, string authenticationType)
    {
        var ci = await base.CreateAsync(manager, user, authenticationType);
        if (!String.IsNullOrWhiteSpace(user.FirstName))
        {
            ci.AddClaim(new Claim("given_name", user.FirstName));
        }
        if (!String.IsNullOrWhiteSpace(user.LastName))
        {
            ci.AddClaim(new Claim("family_name", user.LastName));
        }
        return ci;
    }

So I added this in to my app and wired it up in my custom UserManager. And I do hit a breakpoint when the class is instantiated, but I don't hit a breakpoint ever on the CreateAsync method and my claims aren't returned.

I also saw this IdentityServer3 Custom User sample here, and I found this GetProfileDataAsync method that looked like it might be the right thing (but it seems like I'm digging deeper than I should be for something seemingly so simple/common):

    public override Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        // issue the claims for the user
        var user = Users.SingleOrDefault(x => x.Subject == context.Subject.GetSubjectId());
        if (user != null)
        {
            context.IssuedClaims = user.Claims.Where(x => context.RequestedClaimTypes.Contains(x.Type));
        }

        return Task.FromResult(0);
    }

I had the same problem here, in that a breakpoint in this method was never tripped. I even went so far as to look at the IdentityServer3 source code, and see that this only gets called if the scope has the IncludeAllClaimsForUser flag set. But I'm using the standard profile scope here, so I started to question whether I needed to make my own definition for a profile scope that has the IncludAllClaimsForUser flag set, or if there was a way to add that flag to the standard scope.

And to add to all of this... This only needs to be done when using a local account. When I implement external login providers, I'll ask for the profile there, and expect to be able to get a first and last name. So then I wonder what happens once I've already got those claims (or how to determine whether I need to pull them from my user store or not). Seems like I need to hook in to something that only runs when doing a local login.

And then I started to really question whether I'm going about this the right way since I'm seeing/finding so little info on this (I would have expected this to be a fairly common scenario that others have already implemented, and expected to find docs/samples). Been trying to resolve this for a day now. Hopefully someone has a quick answer/pointer!

Upvotes: 2

Views: 2615

Answers (2)

Scuzzlebutt
Scuzzlebutt

Reputation: 505

The (A) correct answer to this question is to override the GetProfileDataAsync method in the AspNetIdentityUserService class like so:

public class AppUserService : AspNetIdentityUserService<AppUser, Guid>
{
    private AppUserManager _userManager;

    public AppUserService(AppUserManager userManager)
        : base(userManager)
    {
        _userManager = userManager;
    }

    public async override Task GetProfileDataAsync(ProfileDataRequestContext ctx)
    {
        var id = Guid.Empty;
        if (Guid.TryParse(ctx.Subject.GetSubjectId(), out id))
        {
            var user = await _userManager.FindByIdAsync(id);
            if (user != null)
            {
                var claims = new List<Claim>
                {
                    new Claim(Constants.ClaimTypes.PreferredUserName, user.UserName),
                    new Claim(Constants.ClaimTypes.Email, user.Email),
                    new Claim(Constants.ClaimTypes.GivenName, user.FirstName),
                    new Claim(Constants.ClaimTypes.FamilyName, user.LastName)
                };
                ctx.IssuedClaims = claims;
            }
        }
    }
}

But as I had discovered, this wasn't enough. Looking at the source code for IdentityServer, you will find this bit:

        if (scopes.IncludesAllClaimsForUserRule(ScopeType.Identity))
        {
            Logger.Info("All claims rule found - emitting all claims for user.");

            var context = new ProfileDataRequestContext(
                subject,
                client,
                Constants.ProfileDataCallers.ClaimsProviderIdentityToken);

            await _users.GetProfileDataAsync(context);

            var claims = FilterProtocolClaims(context.IssuedClaims);
            if (claims != null)
            {
                outputClaims.AddRange(claims);
            }

            return outputClaims;
        }

Notice that GetProfileDataAsync won't be called unless there is a flag set to include all claims (not sure why they chose to do it this way, but obviously there must be a good reason!). So I thought that meant that I needed to completely redefine the profile scope, but with further digging in the source I found this was not the case. The StandardScopes has a method that creates the scopes with the always include flag set. Instead of setting your scopes doing this:

        factory.UseInMemoryScopes(StandardScopes.All);

Do this:

        factory.UseInMemoryScopes(StandardScopes.AllAlwaysInclude);

THEN your GetProfileDataAsync will be run and you will get all of your claims!

Note: My first try using ClaimsIdentityFactory wasn't ever going to work, as I'm not logging in to Asp.Net Identity, and it makes sense that this wouldn't ever get called unless that is what I was doing.

Note: @Rosdi Kasim's answer is certainly valid if you desire to add claims (app specific claims especially) after you have already received your id_token from Identity Server.

Upvotes: 0

Rosdi Kasim
Rosdi Kasim

Reputation: 25976

I use OpenIdConnectAuthenticationNotifications to achieve this, you could connect to ASP.NET Identity database or do anything in there, here is a sample code I use for one of my project:

This is a complete source code from my Startup.cs, but what you really need is just the SecurityTokenValidated section ...

using System.Configuration;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Helpers;
using IdentityServer3.Core;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;

namespace MyProject
{
    public partial class Startup
    {
        public static string AuthorizationServer => ConfigurationManager.AppSettings["security.idserver.Authority"];

        public void ConfigureOAuth(IAppBuilder app)
        {
            AntiForgeryConfig.UniqueClaimTypeIdentifier = Constants.ClaimTypes.Subject;

            var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
            jwtSecurityTokenHandler.InboundClaimTypeMap.Clear();

            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = "Cookies"
            });

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                SecurityTokenValidator = jwtSecurityTokenHandler,
                Authority = AuthorizationServer,
                ClientId = ConfigurationManager.AppSettings["security.idserver.clientId"],
                PostLogoutRedirectUri = ConfigurationManager.AppSettings["security.idserver.postLogoutRedirectUri"],
                RedirectUri = ConfigurationManager.AppSettings["security.idserver.redirectUri"],
                ResponseType = ConfigurationManager.AppSettings["security.idserver.responseType"],
                Scope = ConfigurationManager.AppSettings["security.idserver.scope"],
                SignInAsAuthenticationType = "Cookies",
#if DEBUG
                RequireHttpsMetadata = false,   //not recommended in production
#endif
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    RedirectToIdentityProvider = n =>
                    {
                        if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout)
                        {
                            var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");

                            if (idTokenHint != null)
                            {
                                n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
                            }
                        }

                        return Task.FromResult(0);
                    },

                    SecurityTokenValidated = n =>
                    {
                        var id = n.AuthenticationTicket.Identity;

                        //// we want to keep first name, last name, subject and roles
                        //var givenName = id.FindFirst(Constants.ClaimTypes.GivenName);
                        //var familyName = id.FindFirst(Constants.ClaimTypes.FamilyName);
                        //var sub = id.FindFirst(Constants.ClaimTypes.Subject);
                        //var roles = id.FindAll(Constants.ClaimTypes.Role);

                        //// create new identity and set name and role claim type
                        var nid = new ClaimsIdentity(
                            id.AuthenticationType,
                            Constants.ClaimTypes.Name,
                            Constants.ClaimTypes.Role);

                        nid.AddClaims(id.Claims);
                        nid.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
                        nid.AddClaim(new Claim("access_Token", n.ProtocolMessage.AccessToken));

                        ////nid.AddClaim(givenName);
                        ////nid.AddClaim(familyName);
                        ////nid.AddClaim(sub);
                        ////nid.AddClaims(roles);

                        ////// add some other app specific claim
                        // Connect to you ASP.NET database for example
                        ////nid.AddClaim(new Claim("app_specific", "some data"));

                        //// keep the id_token for logout
                        //nid.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));

                        n.AuthenticationTicket = new AuthenticationTicket(
                            nid,
                            n.AuthenticationTicket.Properties);

                        return Task.FromResult(0);
                    }
                }
            });

            //app.UseResourceAuthorization(new AuthorizationManager());
        }
    }
}

Upvotes: 1

Related Questions