OpenID Connect Providers, Claims, and Duende IdentityServer

Khalid Abuhakmeh |

Have you ever asked the question, "What is a claim?", “how do apps ask for just the necessary claim information?” or “how does Duende IdentityServer complete an authentication request behind the scenes?” All important and necessary questions on your OpenID Connect implementation journey

Get ready for an exciting walkthrough of OpenID Connect's world. We will demystify claims and scopes and explain how Duende IdentityServer teaches these concepts to .NET developers through our SDK implementation.

We'll cover these topics together and, along the way, become OIDC and Duende IdentityServer pros together!

What is a Claim?

When working with OpenID Connect (OIDC), you’ll request and handle claims from the OIDC provider, but what exactly is a claim? In its simplest form, a claim is a key/value pair of information emitted from an OIDC provider, typically (but not limited to) an end-user. While implementors of OIDC providers have the freedom to define any claims they choose, there is generally a set of predefined standard claims, including but not limited to sub, name, birthdate, and more. The server should express claim value types in string, boolean, number, or JSON. The requesting client uses these claims to know more about the authenticated user during a session.

But how does a client know which claims it will receive, and how does it ensure it gets all the required claims?

Clients Asking For Claims

The client is responsible for asking an OIDC provider for claims, which developers can do in two ways, as outlined by the OIDC specification.

In the OIDC 1.0 specification, each authentication request can provide a list of individual claims the OIDC provider can fulfil and return. This mechanism is the Claims parameter on the protocol request. For example, a client may want to return a claim from the standard claims, at which time the client may ask for the sub, name, email, and locale. Upon receiving a request, the OIDC provider will process the request and return the claims to the client. Some OIDC providers could provide hundreds of claims, and this can quickly become tedious if not painful to implement. Luckily, there’s also an alternative way.

Many OIDC providers choose not to implement the individual claims parameter on OIDC protocol requests because that level of claim granularity is not needed. There is a much better approach to grouping claims clearly and concisely. This concept is known as a scope. For OpenID Connect, the client uses scopes that a server understands to request that specific sets of information be made available as claims. For example, you may ask for the standard openid, profile, email, and phone scopes, with each scope returning one or more claim values. You can immediately see a scope's value when juxtaposed with its claims. Here are the claims typically provided by the profile scope: name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at. You can see why many OIDC implementors just stick with scopes, and even add custom scopes to satisfy business requirements.

Speaking of OIDC providers, how does Duende IdentityServer handle claims and scopes?

How Does Duende IdentityServer Handle Requests?

Duende IdentityServer is an SDK solution aimed at helping developers successfully build and enhance an OIDC implementation. We provide our knowledge and expertise to developers by exposing several C# classes that represent the concepts of claims and scopes. Developers looking to represent scopes in their Duende IdentityServer implementations should look no further than the IdentityResource class.

The IdentityResource class defines a named group of claims about a user that can be requested using the scope parameter. The OpenID Connect specification suggests some standard scope names to claim type mappings that might be useful for inspiration, but you can freely design them yourself.

return new List<IdentityResource>
{
	new IdentityResource(
    	  name: "openid",
    	  userClaims: new[] { "sub" },
    	  displayName: "Your user identifier"),

	new IdentityResource(
    	  name: "profile",
    	  userClaims: new[] { "name", "email", "website" },
    	  displayName: "Your profile data"),
      
    // Custom scope
	new IdentityResource(
    	  name: "employee",
    	  userClaims: new[] { "employee_id", "division_id", "name", "email" },
    	  displayName: "employment information")
};

IdentityServer already implements these standard scope names for developers, so there’s no need to fret about remembering each claim.

return new List<IdentityResource>
{
	IdentityResources.OpenId(),
	IdentityResources.Profile(),
    
    // Custom scope
	new IdentityResource(
    	  name: "employee",
    	  userClaims: new[] { "employee_id", "division_id", "name", "email" },
    	  displayName: "employment information")      
};

Then, we use the scopes on our client definitions to limit which scopes a protocol request can ask the OIDC provider to process.

var client = new Client
{
	ClientId = "client",
	AllowedScopes = { "openid", "profile", "employee" }
};

Setting the AllowedScopes for each client is essential since not every client that authenticates with Duende IdentityServer can or should access all claims.

As a protocol request progresses through the Duende IdentityServer pipeline, developers have opportunities to augment and set claims. In most cases, the OIDC implementor determines what claims should be returned with a successful request. How we decide which claims get emitted is typically implemented in an IProfileService instance or a type derived from DefaultProfileService.

public class SampleProfileService : DefaultProfileService
{
	public virtual async Task GetProfileDataAsync(ProfileDataRequestContext context)
	{
    	var claims = await GetClaimsAsync(context);

    	context.AddRequestedClaims(claims);
	}


	private async Task<List<Claim>> GetClaimsAsync(ProfileDataRequestContext context)
	{
    	// Your implementation that retrieves claims goes here
	}
}

In Duende IdentityServer, we even have a helper method named AddRequestedClaims, which adds only the requested and approved claims based on the protocol request and client configuration. You can even choose to set claims that the client didn’t specifically ask for, if it is necessary information that all clients need.

Now that we have a successful protocol request through Duende IdentityServer, how do the claims get back to the client? The OIDC protocol defines two ways.

The first is in an assertion commonly known as the id_token. This token is sent back directly to the client at the end of the request. At this point, the client processes the response into a context-specific artifact. In the case of .NET, that artifact is a ClaimsPrincipal.

The second approach uses the UserInfo endpoint, a specification-defined endpoint that all OIDC clients can call with an access token to retrieve more information on the current user. This approach's advantages are that it keeps the initial id_token payload lightweight and avoids passing sensitive information via the front-channel portion of the OIDC protocol lifecycle.

Let’s examine how these approaches work on the client and how to intercept these events for additional customization or troubleshooting.

How Does a Client Handle Successful Requests?

To add an OIDC provider to your ASP.NET Core application, you first need to set up an OIDC handler.

builder.Services.AddAuthentication()
	.AddOpenIdConnect("oidc", "Duende Demo", options =>
	{
    	options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
    	options.SignOutScheme = IdentityServerConstants.SignoutScheme;
    	options.SaveTokens = true;

    	options.Authority = "https://demo.duendesoftware.com";
    	options.ClientId = "interactive.confidential";
    	options.ClientSecret = "secret";
    	options.ResponseType = "code";

    	options.TokenValidationParameters = new TokenValidationParameters
    	{
        	NameClaimType = "name",
        	RoleClaimType = "role"
    	};
	});

In most cases, this is all you need to get started, but sometimes you need to intercept events and process a response before handlers process it into a ClaimsPrincipal. Since this post has focused primarily on scopes and claims, we’ll look at two significant events that can help you better understand and diagnose any issues during a protocol request.

The first event is OnTicketReceived, one of the earliest events after an OIDC provider returns a response.

options.Events.OnTicketReceived = context =>
{
	var logger = context
    	.HttpContext
    	.RequestServices
    	.GetRequiredService<ILogger<OpenIdConnectEvents>>();
    
	if (!context.Result.Succeeded)
	{
    	  logger.LogError(
        	context.Result.Failure,
        	"Ticket received failed");
	}
    
	return Task.CompletedTask;
};

In this event, you’ll have an opportunity to better understand the inner workings of the request, including the ability to check the status of a request and view the id_token. You can take the JSON Web Token (JWT) and decode its contents with a tool like JWT.ms. The event allows you to intercept that first method of returning claims via the id_token, but what about calls to the UserInfo endpoint? Well, there’s an event for that, too: OnUserInformationReceived.

options.Events.OnUserInformationReceived = context =>
{
	var json = context.User;
	return Task.CompletedTask;
};

Like the first event, this gives you access to the inner workings of the protocol request, but after each call to the UserInfo endpoint, and the added benefit of accessing the User property, which is a JsonDocument.

While these two events can help inspect behavior on the client or troubleshoot issues between your OIDC provider and client configurations, you should only implement the events with a clear and precise purpose.

Conclusion

In summary, OpenID Connect relies on claims to convey user information. Clients typically request these claims via scopes managed in Duende IdentityServer through IdentityResource.

Developers have several customization points, including profile services for claim emission and client-side interception of token and user information responses. Thoughtful use of these customization options is crucial for maintaining a straightforward and efficient authentication process.

We hope you enjoyed this walkthrough on OpenID concepts and how they translate into the Duende IdentityServer codebase. We'd love to hear from you if you have any questions, thoughts, or comments.