Debugging JwtBearer Claim Problems in ASP.NET Core

Debugging OpenID Connect Claim Problems in ASP.NET Core

A common issue with OpenID Connect authentication in ASP.NET Core is that expected claims are missing from the ClaimsPrincipal user object. In this blog post, I will provide some ideas for how to diagnose these types of problems.

 

What is the purpose of the OpenIDConnect handler?

The handler has two main tasks:

  1. When the user is challenged (requested to log in), it will redirect the user to the authorization provider, for example, Duende IdentityServer.

2. When the user is returned to the handler, it will request the tokens from the authorization provider using the authorization code and create an authentication ticket. This ticket is typically passed to the cookie handler, setting the session cookie. 

 

First check: Does the authorization server actually provide the claims? 

The OpenIDConnect handler will get the claims from two sources:

  1. The ID-Token
  2. Optionally, from the UserInfo endpoint.

Using a tool like Fiddler, you can capture the ID token and the optional request to the UserInfo endpoint.

Why does it use two sources? One reason is that it reduces the size of the ID token. The UserInfo endpoint is also helpful because it lets the client get the latest user details when needed. You can read more about the specification for the UserInfo endpoint here. 

If the expected claims are not found in the ID token, then enabling the GetClaimsFromUserInfoEndpoint flag will tell the OIDC handler to make an additional request to this endpoint.

				
					.AddOpenIdConnect(options =>
{
    ...
    options.GetClaimsFromUserInfoEndpoint = true;
	...
}

				
			

For example, when using IdentityServer, the request to this endpoint and its response can look like this:

Request:

				
					GET https://identityservice.secure.nu/connect/userinfo HTTP/1.1
Host: identityservice.secure.nu
Authorization: Bearer <accesstoken>
User-Agent: Microsoft ASP.NET Core OpenIdConnect handler


				
			

Response:

				
					{
  "name": "Bob Smith",
  "given_name": "Bob",
  "family_name": "Smith",
  "email": "BobSmith@email.com",
  "email_verified": true,
  "website": "http://bob.com",
  "employment_start": "2019-01-02",
  "seniority": "Senior",
  "contractor": "no",
  "role": [
    "ceo",
    "finance",
    "developer"
  ],
  "sub": "2"
}



				
			

 

How can I see these raw claims using code?

One approach is to add this piece of configuration code that sends all the claims received from the ID token and UserInfo endpoint to the console:

				
					.AddOpenIdConnect("oidc", options =>
{
    //...
    options.Events.OnUserInformationReceived = ctx =>
    {
        Console.WriteLine();
        Console.WriteLine("Claims from the ID token");
        foreach(var claim in ctx.Principal.Claims)
        {
            Console.WriteLine($"{claim.Type} - {claim.Value}");
        }
        Console.WriteLine();
        Console.WriteLine("Claims from the UserInfo endpoint");
        foreach (var property in ctx.User.RootElement.EnumerateObject())
        {
            Console.WriteLine($"{property.Name} - {property.Value}");
        }
        return Task.CompletedTask;
    };
};



				
			

Notice that all claims received are listed above. However, some claims might have been renamed internally.

In my sample application, I will see the following claims written to the console:

				
					Claims from the ID token
iss - https://identityservice.secure.nu
nbf - 1679224115
iat - 1679224115
exp - 1679224415
aud - missingclaims-client
http://schemas.microsoft.com/claims/authnmethodsreferences - pwd
nonce - 638148209061465255...
at_hash - htlWY11Ch-wCFfRAlusRRw
sid - 04D122AC27794662F59D55E9D6E85022
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier - 2
auth_time - 1679224111
http://schemas.microsoft.com/identity/claims/identityprovider - local

Claims from the UserInfo endpoint
name - Bob Smith
given_name - Bob
family_name - Smith
email - BobSmith@email.com
email_verified - True
website - http://bob.com
employment_start - 2019-01-02
seniority - Senior
contractor - no
role - ["ceo","finance","developer"]
sub - 2





				
			

As you noticed above, some claims have been renamed to what Microsoft thinks the claims should be named internally.

You can disable this renaming by setting this flag to false:

				
					.AddOpenIdConnect("oidc", options =>
{
    //...
    options.MapInboundClaims = false;
}






				
			

You can move to the next step if the expected claims are found above. Otherwise, you have a configuration issue in your authorization server or are asking for the wrong scopes.

If you’re curious, the actual mapping logic is found in the JwtSecurityTokenHandler class, and the source code for it can be found here

 

What about the claims in the access token?
The access token is only used for accessing APIs. This means the handler does not care what is inside the access token. So, we will ignore what it contains. I have written a blog post about debugging API claim problems here.

 

The Cookie Handler

You have determined that the claims you are looking for are passed to the OpenIConnect handler as expected.
Great! So, why are the claims not part of the User object? First, let’s explore the Cookie handler.

When the OpenID Connect handler is done, it will create an authentication ticket and ask the cookie handler to sign in the user using this ticket. The ticket contains the claims and other details about the user, as shown below:

By adding the following event handler to the Cookie handler, we can see what claims it has received:

				
					.AddCookie("cookie", options =>
{
    ...
    options.Events.OnSigningIn = ctx =>
    {
        Console.WriteLine();
        Console.WriteLine("Claims received by the Cookie handler");
        foreach (var claim in ctx.Principal.Claims)
        {
            Console.WriteLine($"{claim.Type} - {claim.Value}");
        }
        Console.WriteLine();

        return Task.CompletedTask;
    };
})







				
			

In my example, I get the following:

				
					Claims received by the Cookie handler
amr - pwd
sid - 233D90C7590B9405E6EAFBE8BBF9A167
sub - 2
auth_time - 1679224198
idp - local
name - Bob Smith
given_name - Bob
family_name - Smith
email - BobSmith@email.com








				
			

I would expect something else! So, what is going on here?

Inside the OpenID Connect handler is a separate ClaimsMapper function that will remove unnecessary claims and only include some specific ones. 

The default mapping is found in the OpenIdConnectOptions class, and it looks like this:

				
					...
ClaimActions.DeleteClaim("nonce");
ClaimActions.DeleteClaim("aud");
ClaimActions.DeleteClaim("azp");
ClaimActions.DeleteClaim("acr");
ClaimActions.DeleteClaim("iss");
ClaimActions.DeleteClaim("iat");
ClaimActions.DeleteClaim("nbf");
ClaimActions.DeleteClaim("exp");
ClaimActions.DeleteClaim("at_hash");
ClaimActions.DeleteClaim("c_hash");
ClaimActions.DeleteClaim("ipaddr");
ClaimActions.DeleteClaim("platf");
ClaimActions.DeleteClaim("ver");

// http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
ClaimActions.MapUniqueJsonKey("sub", "sub");
ClaimActions.MapUniqueJsonKey("name", "name");
ClaimActions.MapUniqueJsonKey("given_name", "given_name");
ClaimActions.MapUniqueJsonKey("family_name", "family_name");
ClaimActions.MapUniqueJsonKey("profile", "profile");
ClaimActions.MapUniqueJsonKey("email", "email");









				
			

To get the desired claims to be added, we need to explicitly map the ones that we want:

				
					AddOpenIdConnect("oidc", options =>
{
    //...
    options.ClaimActions.MapUniqueJsonKey("employment_start", "employment_start");
    options.ClaimActions.MapUniqueJsonKey("seniority", "seniority");
    options.ClaimActions.MapUniqueJsonKey("contractor", "contractor");
    options.ClaimActions.MapUniqueJsonKey("employee", "employee");
    options.ClaimActions.MapUniqueJsonKey("management", "management");
    options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.Role, JwtClaimTypes.Role);
};

				
			

What is the result if we do this?

				
					Claims received by the Cookie handler
amr - pwd
at_hash - nlHaI2Y0sZo6B2rctDULnw
sid - 01616A969D17F7F92EEEDF7B7CA18BEC
sub - 2
auth_time - 1679224524
idp - local
name - Bob Smith
given_name - Bob
family_name - Smith
email - BobSmith@email.com
email_verified - True
website - http://bob.com
employment_start - 2019-01-02
seniority - Senior
contractor - no
role - ["ceo","finance","developer"]



				
			

Alternatively, we can do the following as a shortcut:

				
					options.ClaimActions.MapAllExcept("iss", "nbf", "exp", "aud", "nonce", "iat", "c_hash");



				
			

Doing so will result in the following:

				
					Claims received by the Cookie handler
amr - pwd
at_hash - nlHaI2Y0sZo6B2rctDULnw
sid - 01616A969D17F7F92EEEDF7B7CA18BEC
sub - 2
auth_time - 1679224524
idp - local
name - Bob Smith
given_name - Bob
family_name - Smith
email - BobSmith@email.com
email_verified - True
website - http://bob.com
employment_start - 2019-01-02
seniority - Senior
contractor - no
role - ["ceo","finance","developer"]



				
			

Fixing the user name and roles properties

The next problem is to fix the User name and roles. Again, this is a common problem on Stack Overflow. 

To get the name of the user, or check if the user is in a given role, you add the following code to any of your action methods:

				
					public IActionResult Index()
{
    if (User.Identity.IsAuthenticated)
    {
        var name = User.Identity.Name;       
	 var isCeo = User.IsInRole("ceo");    

        Console.WriteLine(name);    //null
        Console.WriteLine(isCeo);   //false
    }
	
    return View();
}




				
			

So, let’s fix this! 

One problem is the claims mapping. Microsoft has a different opinion of what the name and role claim should be named, and out of the box, it will just ignore your name and role claims because it thinks they should have a different name.

By default, it will look for these two claim types:

OpenID Connect claim type

Microsoft claim type

role

http://schemas.microsoft.com/ws/2008/06/identity/claims/role

name

http://schemas.microsoft.com/ws/2008/06/identity/claims/name

They are defined in the .NET source code here. 

To verify this, you can run this code, It will print the name of the expected role/name claim, like this:

				
					if (User.Identity.IsAuthenticated)
{
    var user = (ClaimsIdentity)User.Identity;
    Console.WriteLine(user.NameClaimType);
    Console.WriteLine(user.RoleClaimType);
}



				
			

It will print:

				
					http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name

http://schemas.microsoft.com/ws/2008/06/identity/claims/role



				
			

To fix this, we need to set the name of your name and role claim as:

				
					.AddOpenIdConnect(options =>
{
    ...
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "name",
        RoleClaimType = "role"
    };	
    ...
}




				
			

If we log in again, we will see this code:

				
					if (User.Identity.IsAuthenticated)
{
    var user = (ClaimsIdentity)User.Identity;
    Console.WriteLine(user.NameClaimType);    //name
    Console.WriteLine(user.RoleClaimType);    //role

    var name = User.Identity.Name;
    var isCeo = User.IsInRole("ceo");      

    Console.WriteLine(name);                 //Bob Smith
    Console.WriteLine(isCeo);                //false
    //....
}




				
			

Which will print the following:

				
					name
role
Bob Smith
False





				
			

We fixed the name, but the role check still fails. Strange!

Fixing the roles

To fix the roles, we must first examine the claims provided to the Cookies handler.

				
					Claims received by the Cookie handler
…
contractor - no
role - ["ceo","finance","developer"]




				
			

Here we see that all of our roles are added as an array to the role claim, and that is now what the IsInRole method expects.

What is the problem here? 

The first problem is this statement:

				
					options.ClaimActions.MapAllExcept("iss", "nbf", "exp", "aud", "nonce", "iat", "c_hash");



				
			

It will map multiple claims with the same name into an array.

Let’s remove it and replace it with the mapping we used earlier:

				
					options.ClaimActions.MapUniqueJsonKey("employment_start", "employment_start");
options.ClaimActions.MapUniqueJsonKey("seniority", "seniority");
options.ClaimActions.MapUniqueJsonKey("contractor", "contractor");
options.ClaimActions.MapUniqueJsonKey("employee", "employee");
options.ClaimActions.MapUniqueJsonKey("management", "management");
options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.Role, JwtClaimTypes.Role);



				
			

Using this code will result in the same result. So, this is clearly not a fix!

The problem is the MapUniqueJsonKey method that will map duplicate claims into an array. So, we should use is the MapJsonKey method instead, like this:

				
					options.ClaimActions.MapUniqueJsonKey("employment_start", "employment_start");
options.ClaimActions.MapUniqueJsonKey("seniority", "seniority");
options.ClaimActions.MapUniqueJsonKey("contractor", "contractor");
options.ClaimActions.MapUniqueJsonKey("employee", "employee");
options.ClaimActions.MapUniqueJsonKey("management", "management");
options.ClaimActions.MapJsonKey(JwtClaimTypes.Role, JwtClaimTypes.Role);


				
			

If we log in again, we will see the following claims in the Cookies handler:

				
					Claims received by the Cookie handler
…
seniority - Senior
contractor - no
role - ceo
role - finance
role - developer


				
			

Now, the IsInRole (“ceo”) check will also work!

Summary

The final code for the OpenID Connect handler is as follows:

				
					.AddOpenIdConnect("oidc", options =>
{
    //...
    options.GetClaimsFromUserInfoEndpoint = true;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = JwtClaimTypes.Name,
        RoleClaimType = JwtClaimTypes.Role,
    };
	
    options.ClaimActions.MapUniqueJsonKey("employment_start", "employment_start");
    options.ClaimActions.MapUniqueJsonKey("seniority", "seniority");
    options.ClaimActions.MapUniqueJsonKey("contractor", "contractor");
    options.ClaimActions.MapUniqueJsonKey("employee", "employee");
    options.ClaimActions.MapUniqueJsonKey("management", "management");
	
    options.ClaimActions.MapJsonKey(JwtClaimTypes.Role, JwtClaimTypes.Role);

    options.MapInboundClaims = false;
    //...
});





				
			

Conclusions

I hope the steps outlined above can help you troubleshoot your claim problems. If you have any suggestions for this blog post, feedback, questions on this blog post, or would like to get in touch, you can find my contact details here.

About the author

Hi, I’m Tore! I have been fascinated by computers since I unpacked my first Commodore VIC-20. Today, I enjoy providing freelance development and developer training services, focusing on ASP.NET Core, IdentityServer, OpenID Connect, Architecture, and Web Security. You can connect with me on LinkedIn and Twitter. You can find out more about me and my services here, as well as my classes for developers, including my course, Introduction to IdentityServer and OpenID-Connect.

 

Related posts:

Share This Story
Share on linkedin
Share on twitter
Share on facebook
Share on pinterest
Share on email

About me

My name is Tore Nestenius and I’m a trainer and senior software developer focusing on Architecture, Security and Identity, .NET, C#, Backend, and Cloud, among other things.

Do You Want Tore To Be Your Mentor?

Categories