Testing

webTiger Logo Wide

Injecting Claims into the ASP.NET Core Auth Pipeline

Padlock

Most modern cloud authentication providers (i.e. Microsoft Entra ID, Auth0, Okta, etc.) provide a claims-based user management capability. A user can be assigned roles and permissions and these are surfaced in .NET’s user identity classes as ‘claims’.

Although, most of the time, claims can be configured and managed in the identity provider’s management console there may be times when you want to inject additional claims, and this article discusses how that can be done.

In this article:

Preamble

TLDR; skip straight to the Modifying and Injecting Claims section if you already know all about ASP.NET Core authentication and just want to know how to modify and inject claims into user identities.

Securing .NET Applications – A Quick Explainer

.NET provides a dedicated set of classes for defining and managing user identity, as well as a formal authentication and authorisation framework.

The most common access control mechanisms implemented to secure access in software applications are:

  • Front-door access control, which provides authentication only. Users and devices simply provide credentials to gain access to resources. In this scenario, finer-grain permissions do not exist and, once logged in, full access to the resource is granted.
  • Access control lists (ACL), which are often also referred to as fine-grain permissions. In this scenario, a set of individual permissions are defined and granted to individual users as necessary.
  • Role-based access control (RBAC), which allows named roles to be defined that users can be assigned to. In its simplest form, the role names themselves become the security identifiers used to control access, but RBAC is sometimes combined with ACLs (so a role is granted discrete permissions) for greater flexibility.

Modern identity management and security is usually based on the zero-trust framework, where everyone/everything accessing a system needs to be verified first – and no user or device can be trusted by default.

Translating this into software terms, this usually means that users need to be granted access to each resource or application feature they are attempting to use.

The difference between authentication and authorisation may not be immediately obvious, so let’s explain it. Authentication is concerned with verifying a user or device is who they say they are (usually via ‘username and password’ or ‘ID and secret key’). Authorisation, on the other hand, focuses on granting or denying access to a particular resource.

One of the most popular authorisation protocols in use today is OAuth2. It uses secure access tokens to authorise access to resources on a security prinicipal’s behalf (i.e. a user or device). Users, or devices, may have to log into the auth server to initially retrieve an access token but, after that, the token can be used exclusively to authorise and access remote resources that support it until the token expires (remote resources simply need to check the token is valid and has the required authorisation ‘claims’).

Although OAuth2 does involve an authentication step in many implementations (initially a user or device often has to login to retrieve an access token), it is primarily an authorisation mechanism and, depending on the provider, it may not have an explicit login step at all – devices may be assigned access tokens (generated by the auth server) directly.

Complementary to OAuth2 is a very popular identity protocol called OpenID Connect (OIDC). This is a layer that sits on top of OAuth2 and offers authentication and identity management features.

The solution has the concept of ‘claims’, which are name-value pairs representing pieces of information about an identity (e.g. user account) such as username, full name, email address, roles granted, permissions granted, etc.

OIDC is implemented in most modern cloud identity providers such as Microsoft Entra ID, Google, Auth0, Okta, etc., and Microsoft provides support for consuming OIDC services out-of-the-box in .NET.

A Cautionary Tale for RBAC

A common pitfall that inexperienced designers/developers can fall into when specifying roles in a role-based access control solution is not considering the longevity of the project/application/system, or how the organisation might change during it’s lifetime.

It is very tempting to define roles based on business structure, but it may be prudent to resist this.

For example, you have an IT department with a Networking team, a Service team, a Development team, a Database team, etc. and each have a set of job roles for the staff working in those teams (e.g. Network Administrator and IT Administrator in Networks team, Service Advisor and IT Maintenance Technician in Service Desk team, Technical Analyst and Software Developer in Dev team, DB Administrator in Database team, etc.)

When you develop your application, you control access to admin related areas of the app with an “IT Administrator” role as they manage servers and operating systems in the Networking team, and will be accessing that functionality as the administrators of the software.

Later, a business decision is made for Service Advisors in the Service Desk team to have access to the admin areas of the app instead of the Networking team members, because all of the administrative functionality is more relevant to service and support staff than to the Networking team.

At this point there is a problem. Other applications and systems are using the “IT Administrator” role to grant access – and the Service Desk team shouldn’t have access to those other resources. This means the Service Advisors cannot simply be granted the existing role.

The same issue can occur if the organisation goes through a department reshuffle where teams or roles and responsibilities are changed.

In both the above cases, it is likely any applications relying on RBAC defined using ‘job roles’ would need to be modified to work under the new business processes or department structure.

The issue can be avoided by decoupling the roles used in apps from business job roles, by using more granular role names that are based on the feature/functionality in the app being granted. For example, “Shopfront – Products Administrator”, “Order Processing – Shipping Administrator”.

Now, if the responsibilities of a job role change then user management is just a simple matter of adding or removing application roles from users’ accounts. The RBAC role names are now independent of organisation structure.

Why Might We Want To Inject Claims?

Before continuing, you may be asking yourself something at this point… If most OIDC auth providers offer claims management via their administration consoles, why would we want to implement additional code to inject claims programmatically into the authorisation pipeline?

Well, there are two obvious reasons I can think of. Firstly, you want to manage per app permissions completely independently of the auth provider (e.g. an organisation’s IT Administrators manage the auth provider itself but the organisation’s Service/Support team want to manage app roles/permissions assignments separately, e.g. via an in-house admin app). Secondly, an auth provider is being used that doesn’t support management of roles/permissions/claims on their administration console.

A real-world example of this is Microsoft’s Azure B2C (Business-to-Consumer) federated identity management (FIM) solution. This provides high-level authentication and authorisation but does not support any granular permissions via claims at all. To utilise Azure B2C in an app that needs granular permissions you’d need to use B2C for the initial authentication and then your own custom roles/permissions management solution beyond that. You then need some way of injecting those externally managed claims into the ASP.NET Core auth pipeline.

It is exactly this scenario, using Azure B2C with a requirement for fine-grain permissions, that led me to investigate how to inject claims into the auth pipeline in the first place. (NOTE: at the time of writing Azure B2C is going to be superceded by Azure External Identities at some point; and, when this happens, External Identities is supposed to natively support roles).

Identity in ASP.NET Core – A Quick Walkthrough

What we’ll mainly be talking about here is ‘ASP.NET Core Identity’. This is a complete authentication and authorisation solution Microsoft provide primarily for use with Web Apps and Web APIs, but it can theoretically be utilised elsewhere too.

It is worth noting at this point that ‘ASP.NET Core Identity’ in modern .NET differs significantly from traditional ‘ASP.NET 2.0 Identity’ in the old .NET Framework, to the extent they are incompatible. This article is focusing on the newer ‘.NET Core’ offering.

Security classes in .NET are spread across a few primary root namespaces:

  • System.Security, which contains some entity definitions such as ‘Claim’, ‘Principal’, ‘Identity’, etc.
  • Microsoft.IdentityModel, used mainly for use working with tokens.
  • Microsoft.AspNetCore.Authentication, which provides the nuts and bolts for authentication and authorisation.
  • Any number of authentication scheme implementations, each with their own namespaces.

In ASP.NET Core, authentication is handled by an implementation of Microsoft.AspNetCore.Authentication.IAuthenticationService. Implementations of this service contract are called ‘Authentication Schemes’.

Microsoft include some default authentication schemes, and these can be registered with ASP.NET Core’s authentication middleware during app initialisation using the AddAuthentication extension method, supplying the name of the scheme to use.

Scheme-specific extension methods may also exist to further configure the auth mechanism. For example, the default Cookie based authentication scheme provides an extension method called AddCookie, and the JWT Bearer based authentication scheme provides an AddJwtBearer method.

Here is a simple example that registers authentication using a JWT Bearer in Program.cs:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme,
        options => builder.Configuration.Bind("JwtBearerSettings", options));Code language: C# (cs)

The above code simply adds JWT Bearer as the authentication type and specifies that the configuration settings related to it are in the JwtBearerSettings section of the application configuration file (i.e. appsettings.json).

To use OIDC we would need to register that auth provider during app start-up:

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme,
            options => builder.Configuration.Bind("JwtBearerSettings", options))
    .AddOpenIdConnect(options =>
    {
        IConfigurationSection settings = 
            builder.Configuration.GetSection("AuthSettings");

        options.Authority = settings["Authority"];
        options.ClientId = settings["ClientId"];
        options.ClientSecret = settings["ClientSecret"];

        options.SignInScheme = JwtBearerDefaults.AuthenticationScheme;
        options.ResponseType = OpenIdConnectResponseType.Code;

        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;

        options.MapInboundClaims = false;
        options.TokenValidationParameters.NameClaimType = "email";
        options.TokenValidationParameters.RoleClaimType = "roles";        
});Code language: C# (cs)

The above code requires the Microsoft.AspNetCore.Authentication.OpenIdConnect nuget package.

As you can see above, most of the auth provider configuration is stored in the application configuration file (appsettings.json) and we’re coercing a few things.

You may have noticed that we have explicitly specified the claim types that hold the ‘display name’ and the names of the roles a user has been assigned. We’ve specified that the user’s email address should be used as the default display name (i.e. on a Razor page’s the User.Name property). For roles, ‘roles’ is the claim type name that Microsoft Azure AD / Entra ID uses. Other auth providers may use different claim type names, and the claim type names may also be configurable.

Something to mention, since we’re talking about claims is the System.Security.Claims.ClaimTypes class, which is a built-in class that describes itself as specifying constant definitions for ‘well-known’ claim types. Those claim types are all from traditional (on-premise) Active Directory, and I’m pointing it out here to reduce the potential for confusion.

You may think having this ClaimTypes class simplifies things, but beware that using it with OIDC servers may lead to undesirable behaviour because claims are not constrained to match this list. I think Microsoft have demonstrated this perfectly themselves, having moved from ClaimTypes.Role in on-premise Active Directory to "roles" in Microsft Entra ID.

Once authentication has been configured, the app must also be explicitly told to use the authentication that has been registered, and to require authorisation to access its resources.

To initially set this up in Program.cs can as simple as:

var app = builder.Build();

// Some code omitted for brevity.

app.UseAuthentication();
app.UseAuthorization();Code language: C# (cs)

Then, authorisation can be controlled within the app in a number of ways.

To restrict access app-wide, simply add an authorisation policy (earlier in the app startup code, before the builder object was built):

const requireAuthPolicyName = "RequireAuthenticatedUserPolicy";

builder.Services.AddAuthorization(options => 
{
    AuthorizationPolicy policy = 
        new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    options.AddPolicy(policy, requireAuthPolicyName);
    options.DefaultPolicy = policy;
});Code language: C# (cs)

By setting an application-wide policy, it gets applied to all resources in the app. There may be occasions when you want users to be able to access a page of the app (e.g. the Homepage may need to be accessible to all, with basic information about the app and a sign-up or login option). To allow this when app-wide user authentication is set, we can use the AllowAnonymousAttribute attribute. For example, in an ASP.NET Core Razor page:

@page
@using Microsoft.AspNetCore.Authorization
@model ExtendedCustomerDetails
@attribute [AllowAnonymous]

<h1>Welcome</h1>
<p>This is the website's Homepage that is accessible by all.</p>
<p>Use the Login/Register option on the menu bar to login.</p>Code language: HTML, XML (xml)

If an application-wide authorisation policy isn’t being applied, to restrict access to a specific page we can use the AuthorizeAttribute attribute. For example, in an ASP.NET Core Razor page:

@page
@using Microsoft.AspNetCore.Authorization
@model ExtendedCustomerDetails
@attribute [Authorize(Roles = "ExampleAuthApp - Power Users, ExampleAuthApp - Admin")]

<h1>Page Title</h1>
<p>Something only 'Power Users' should have access to!</p>Code language: HTML, XML (xml)

The above markup specifies the page can only be viewed by users who are assigned either of the roles claims that have been specified in the comma separated list.

Modifying and Injecting Claims

During the authentication lifecycle, ASP.NET Core provides a means to intercept claims processing and transform or inject claims. The Microsoft.AspNetCore.Authentication.IClaimsTransformation interface provides a service contract that allows transformation services to be dependency-injected into the auth pipeline at runtime.

To begin with let’s define a transformer that detects alternative role claim types (e.g. ‘role’, ‘app role’, ‘user role’) and coerces them all to the ‘roles’ claim type.

public class RoleClaimsTransformer : IClaimsTransformation 
{
    /// <inheritdoc />
    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        ClaimsIdentity? identity = principal.Identity as ClaimsIdentity;

        if (identity is null)
        {
            return principal;
        }

        List<Claim> toCoerce = new();
        foreach (Claim claim in identity.Claims)
        {
            if (claim.ClaimType == "role" ||
                claim.ClaimType == "app role" ||
                claim.ClaimType == "user role")
            {
                toCoerce.Add(claim);
            }
        }

        if (toCoerce.Count > 0)
        {
            Claim? modifiedClaim = null;
            foreach (Claim claim in toCoerce)
            {
                modifiedClaim = new Claim("roles", claim.Value);
                if (identity.TryRemoveClaim(claim))
                {
                    identity.AddClaim(modifiedClaim);
                }
            }
        }

        return new ClaimsPrincipal(identity);
    }
}Code language: C# (cs)

The above code holds the key to being able to inject new claims too.

We’ve essentially already done that by removing one set of claims and re-adding them again with a different claim type name, so let’s make it more formal and flexible by defining a new service contract that we can query the service provider for and inject new claims if appropriate.

/// <summary>
/// Defines a service contract that allows an instance of 
/// <see cref="IClaimsTranformation" /> to request external claims that will
/// be injected into the current user identity.
/// </summary>
public interface IClaimsInjector
{
    /// <summary>
    /// Returns a collection of zero or more claims to add to the specified 
    /// user identity.
    /// </summary>
    /// <param name="id">
    /// The unique ID of the account to retrieve external claims for.</param>
    public Task<IEnumerable<Claim>> GetClaimsAsync(string id); 
}

/// <summary>
/// An implementation of <see cref="IClaimsTransformation" /> supporting
/// external claims injection.
/// </summary>
public class ExternalClaimsTransformer : IClaimsTransformation 
{
    private readonly IServiceProvider = _services;

    public RoleClaimsTransformer (IServiceProvider services)
    {
        _services = services;
    }

    /// <inheritdoc />
    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        IClaimsInjector? externalClaims = _services.GetService<IClaimsInjector>();
        ClaimsIdentity? identity = principal.Identity as ClaimsIdentity;
        string? id = principal.GetHomeObjectId();

        if (externalClaims is null or identity is null or id is null)
        {
            return principal;
        }

        List<Claim> toAdd = (await externalClaims.GetClaimsAsync(id)).ToList();
        if (toAdd.Count > 0) 
        {
            identity.AddClaims(toAdd);
            return new ClaimsPrincipal(identity);
        }

        return principal;
    }
}Code language: C# (cs)

In the above code, our ExternalClaimsTransformer class accepts an instance of IServiceProvider in the constructor. This allows us to retrieve an implementation of IClaimsInjector that we can use to retrieve the externally managed claims for a specific user/account ID.

IClaimsInjector can be implemented in a standalone class library (or NuGet package) by the external roles/permissions management solution so that it can look up claims based on a specific user/account ID. In this way, one solution can be used for authentication (e.g. Azure B2C) and another can be used for authorisation, via the late binding of external claims.