Search code examples
.net-coreasp.net-identityidentityserver4openid-connect

UserID sub-claim generated, but disappears from Identity Server 4 tokens, causing UserManager.GetUser to fail


I am building a React SPA with Identity Server 4 and dotnetcore 3.1. The Identity Server client is defined in appsettings.json using the IdentityServerSPA profile. Using oidc-client on the frontend, I can login successfully. Inspecting the oidc object, I can confirm that:

  • token_type: Bearer
  • scope: openid profile OpenWorkShopAPI
  • profile.sub: (my user ID)
  • profile.name: (my username)

If I subsequently try to call the API (either with Authorization: Bearer as the id_token or the access_token), there is no sub claim present. Therefore, _userManager.GetUserAsync(User); fails.

Enumerating and printing the claims, I see:

Claim: "System.Security.Claims.ClaimsIdentity" "nbf" "1601597732"
Claim: "System.Security.Claims.ClaimsIdentity" "exp" "1601598032"
Claim: "System.Security.Claims.ClaimsIdentity" "iss" "http://dev.openwork.shop:5000"
Claim: "System.Security.Claims.ClaimsIdentity" "aud" "OpenWorkShopAPI"
Claim: "System.Security.Claims.ClaimsIdentity" "iat" "1601597732"
Claim: "System.Security.Claims.ClaimsIdentity" "at_hash" "MoAqNfND0ct1mUFKpUtgcg"
Claim: "System.Security.Claims.ClaimsIdentity" "s_hash" "D81HZF_ii2r0i5-4_ZxnLA"
Claim: "System.Security.Claims.ClaimsIdentity" "sid" "cdMH0nFKdikldL1Gy3S3Eg"
Claim: "System.Security.Claims.ClaimsIdentity" "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" "fdf0023d-7ae9-4aaf-87fe-0f320b869171"
Claim: "System.Security.Claims.ClaimsIdentity" "auth_time" "1601582136"
Claim: "System.Security.Claims.ClaimsIdentity" "http://schemas.microsoft.com/identity/claims/identityprovider" "local"
Claim: "System.Security.Claims.ClaimsIdentity" "http://schemas.microsoft.com/claims/authnmethodsreferences" "pwd"

Based upon this, I can work around this by accessing the name claim to find the ID directly:

string CurrentUserId => User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;

But that seems neither correct nor ideal.

I have noticed that all of my MySQL tables (except AspNetUsers) remain empty. There are no values in AspNetUserClaims, for example. Yet, as I showed above, the oidc-client is able to successfully see the sub claim for the user ID.

Since I am using the DefaultIdentity and AddApiAuthorization, I do not expect to need to add any special logic to implement such sub-claims, and am not sure why it is missing on the API calls.

Configuration:

services.AddDefaultIdentity<UserProfile>(options => {
        options.SignIn.RequireConfirmedAccount = true;
        options.SignIn.RequireConfirmedEmail = true;
        options.Stores.MaxLengthForKeys = 64;
      }).AddEntityFrameworkStores<OWSData>();

      WebHostOptions webHost = configuration.GetSection("WebHost").Get<WebHostOptions>();
      string root = webHost.Url;

      services.AddIdentityServer((options) => {
        options.PublicOrigin = root;
        options.IssuerUri = root;
        options.UserInteraction.LoginUrl = $"{root}/account/login";
        options.UserInteraction.LogoutUrl = $"{root}/account/logout";
        options.UserInteraction.ErrorUrl = $"{root}/account/error";
        options.UserInteraction.ConsentUrl = $"{root}/account/terms-of-service";
        options.UserInteraction.DeviceVerificationUrl = $"{root}/account/device-verification";
      }).AddApiAuthorization<UserProfile, OWSData>(options => {
        // Serilog.Log.Information("Clients: {@clients}", options.Clients);
      });
      services.AddScoped<ICurrentUser, CurrentUser>();
      services.AddTransient<IReturnUrlParser, AuthReturnUrl>();

      // Auth (JWT)
      services.AddAuthentication(options => {
                 // options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
               })
              .AddCookie((options) => {
                 webHost.ConfigureCookie(CookieName, options.Cookie);
                 options.LoginPath = "/account/login";
                 options.AccessDeniedPath = "/account/denied";
                 options.LogoutPath = "/account/logout";
                 options.SlidingExpiration = true;
               })
              .AddIdentityServerJwt()
              .AddGoogle(options => {
                 ConfigureOAuth(options, "Google", configuration, webHost);
               })
              .AddGitHub(options => {
                 ConfigureOAuth(options, "GitHub", configuration, webHost);
                 options.Scope.Add("user:email");
                 options.EnterpriseDomain = configuration["GitHub:EnterpriseDomain"];
               });

      services.AddAuthorization(options => {
        // options.DefaultPolicy = new AuthorizationPolicy();
      });

      // Auth (Password)
      services.Configure<IdentityOptions>(options => {
        //Password settings
        options.Password.RequireDigit = true;
        options.Password.RequireLowercase = true;
        options.Password.RequireNonAlphanumeric = true;
        options.Password.RequireUppercase = true;
        options.Password.RequiredLength = 6;
        options.Password.RequiredUniqueChars = 1;

        //Lockout settings
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.AllowedForNewUsers = true;

        //User settings
        options.User.AllowedUserNameCharacters =
          "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@";
        options.User.RequireUniqueEmail = false;
      });

      // Cookies
      services.ConfigureApplicationCookie(options => {
        webHost.ConfigureCookie(CookieName, options.Cookie);
      });

Solution

  • The JwtSecurityTokenHandler on JwtBearerAuthentication middleware, is mapping some claims by default. Along these mappings, sub claim is mapped to the ClaimTypes.NameIdentifier. Mappings are listed here.

    You can change the code on API to set the name identifier claim as NameClaimType. NameClaimType is used to set Identity.Name.

    Here is the code change required on API:

    services.AddAuthentication("Bearer").AddJwtBearer("Bearer",
                    options =>
                    {
                        options.Authority = "http://localhost:5000";
                        options.Audience = "api1";
                        options.RequireHttpsMetadata = false;
    
                        
                        options.TokenValidationParameters = new TokenValidationParameters()
                        {
                            NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"//To set Identity.Name 
                        };         
                    });
    

    Here is a sample working code: https://github.com/nahidf/IdentityServer4-adventures/blob/ids4-4/src/CoreApi/Startup.cs#L36

    Edit: If you are using IdentityServerSPA template to create the project, its just a template which has some extension methods and internally using same methods we use in manual setup

    For-example per Docs IdentityServerJwt is to do this:

    Represents an API that is hosted alongside with IdentityServer. The app is configured to have a single scope that defaults to the app name.

    To achieve above purpose IdentityServerJwt is calling AddJwtBearer internally.

    And also it uses IdentityServerJwtDescriptor to to add the api resource.

    So if we go with manual setup or just using IDS4 templates, we can see calls in our code.

    In this case cause you have access to AddJwtBearer as its called inside the extension method, you can change the JwtBearerOptions afterwards like this:

    services.Configure<JwtBearerOptions>("Bearer",
                    options =>
                    {
                        new TokenValidationParameters()
                        {
                            NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"//To set Identity.Name 
                        };
                    });
    

    You have another option too which will remove all the mappings I mentioned above, its to add this code on StartUp at the first line.

    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();