How to Secure ASP.NET Core Using Identity Providers – Windows ASP.NET Core Hosting 2024 | Review and Comparison

The ASP.NET Core API has a single API and needs to accept access tokens from three different identity providers. Auth0OpenIddict and Azure AD are used as identity providers. OAuth2 is used to acquire the access tokens. I used self contained access tokens and only signed, not encrypted. This can be changed and would result in changes to the ForwardDefaultSelector implementation. Each of the access tokens need to be validated fully and also the signatures. How to validate a self contained JWT access token is documented in the OAuth2 best practices. We use an ASP.NET Core authentication handler to validate the specific claims from the different identity providers.

The authentication is added like any API implementation, except the default scheme is setup to a new value which is not used by any of the specific identity providers. This scheme is used to implement the ForwardDefaultSelector switch. When the API receives a HTTP request, it must decide what token this is and implement the token validation for this identity provider. The Auth0 token validation is implemented used standard AddJwtBearer which validates the issuer, audience and the signature.

services.AddAuthentication(options =>
{
    options.DefaultScheme = "UNKNOWN";
    options.DefaultChallengeScheme = "UNKNOWN";
 
})
.AddJwtBearer(Consts.MY_AUTH0_SCHEME, options =>
{
    options.Authority = Consts.MY_AUTH0_ISS;
    options.Audience = "https://auth0-api1";
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateIssuerSigningKey = true,
        ValidAudiences = Configuration.GetSection("ValidAudiences").Get<string[]>(),
        ValidIssuers = Configuration.GetSection("ValidIssuers").Get<string[]>()
    };
})

I also used AddOpenIddict to implement the JWT access token validation from OpenIddict. In this example, I use self contained unencrypted access tokens so I disable the default more secure solution using introspection and encrypted access tokens (reference). This would also need to be changed on the IDP. I used the vendor specific client here because it does not override to ASP.NET Core default middleware and so does not break the validation from the other vendors. You could also validate this access token like above with plain JWT OAuth.

// Register the OpenIddict validation components.
// Scheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
services.AddOpenIddict() 
.AddValidation(options =>
{
    // Note: the validation handler uses OpenID Connect discovery
    // to retrieve the address of the introspection endpoint.
    options.SetIssuer("https://localhost:44318/");
    options.AddAudiences("rs_dataEventRecordsApi");
 
    // Configure the validation handler to use introspection and register the client
    // credentials used when communicating with the remote introspection endpoint.
    //options.UseIntrospection()
    //        .SetClientId("rs_dataEventRecordsApi")
    //        .SetClientSecret("dataEventRecordsSecret");
 
    // disable access token encryption for this
    options.UseAspNetCore();
 
    // Register the System.Net.Http integration.
    options.UseSystemNetHttp();
 
    // Register the ASP.NET Core host.
    options.UseAspNetCore();
});

The AddPolicyScheme method is used to implement the ForwardDefaultSelector switch. The default scheme is set to UNKNOWN and so per default access tokens will use this first. Depending on the issuer, the correct scheme is set and the access token is fully validated using the correct signatures etc. You could also implement logic here for reference tokens using introspection or cookies authentication etc. This implementation will always be different depending on how you secure the API. Sometimes you use cookies, sometimes reference tokens, sometimes encrypted tokens and so you need to identity the identity provider somehow and forward this on to the correct validation.

.AddPolicyScheme("UNKNOWN", "UNKNOWN", options =>
{
    options.ForwardDefaultSelector = context =>
    {
        string authorization = context.Request.Headers[HeaderNames.Authorization];
        if (!string.IsNullOrEmpty(authorization) && authorization.StartsWith("Bearer "))
        {
            var token = authorization.Substring("Bearer ".Length).Trim();
            var jwtHandler = new JwtSecurityTokenHandler();
 
            // it's a self contained access token and not encrypted
            if (jwtHandler.CanReadToken(token)) 
            {
                var issuer = jwtHandler.ReadJwtToken(token).Issuer;
                if(issuer == Consts.MY_OPENIDDICT_ISS) // OpenIddict
                {
                    return OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
                }
 
                if (issuer == Consts.MY_AUTH0_ISS) // Auth0
                {
                    return Consts.MY_AUTH0_SCHEME;
                }
 
                if (issuer == Consts.MY_AAD_ISS) // AAD
                {
                    return Consts.MY_AAD_SCHEME;
                }
            }
        }
 
        // We don't know what it is
        return Consts.MY_AAD_SCHEME;
    };
});

Now that the signature, issuer and the audience is validated, specific claims can also be checked using an ASP.NET Core policy and a handler. The AddAuthorization is used to add this.

services.AddSingleton<IAuthorizationHandler, AllSchemesHandler>();
 
services.AddAuthorization(options =>
{
    options.AddPolicy(Consts.MY_POLICY_ALL_IDP, policyAllRequirement =>
    {
        policyAllRequirement.Requirements.Add(new AllSchemesRequirement());
    });
});

The handler checks the specific identity provider access claims using the iss cliam as the switch information. You can add scopes, roles or whatever and this is identity provider specific. All do this differently.

using Microsoft.AspNetCore.Authorization;
 
namespace WebApi;
 
public class AllSchemesHandler : AuthorizationHandler<AllSchemesRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        AllSchemesRequirement requirement)
    {
        var issuer = string.Empty;
   
        var issClaim = context.User.Claims.FirstOrDefault(c => c.Type == "iss");
        if (issClaim != null)
            issuer = issClaim.Value;
 
        if (issuer == Consts.MY_OPENIDDICT_ISS) // OpenIddict
        {
            var scopeClaim = context.User.Claims.FirstOrDefault(c => c.Type == "scope"
                && c.Value == "dataEventRecords");
            if (scopeClaim != null)
            {
                // scope": "dataEventRecords",
                context.Succeed(requirement);
            }
        }
 
        if (issuer == Consts.MY_AUTH0_ISS) // Auth0
        {
            // add require claim "gty", "client-credentials"
            var azpClaim = context.User.Claims.FirstOrDefault(c => c.Type == "azp"
                && c.Value == "naWWz6gdxtbQ68Hd2oAehABmmGM9m1zJ");
            if (azpClaim != null)
            {
                context.Succeed(requirement);
            }
        }
 
        if (issuer == Consts.MY_AAD_ISS) // AAD
        {
            // "azp": "--your-azp-claim-value--",
            var azpClaim = context.User.Claims.FirstOrDefault(c => c.Type == "azp"
                && c.Value == "46d2f651-813a-4b5c-8a43-63abcb4f692c");
            if (azpClaim != null)
            {
                context.Succeed(requirement);
            }
        }
 
        return Task.CompletedTask;
    }
}

An authorize attribute can be added to the controller exposing the API and the policy is added. The AuthenticationSchemes is used to add a comma separated string of all the supported schemes.

[Authorize(AuthenticationSchemes = Consts.ALL_MY_SCHEMES, Policy = Consts.MY_POLICY_ALL_IDP)]
[Route("api/[controller]")]
public class ValuesController : Controller
{
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new string[] { "data 1 from the api", "data 2 from the api" };
    }
}

This works good and you can force the authentication at the application level. Using this, you can implement a single API to use multiple access tokens but this does not mean that you should do this. I would always separate the APIs and identity providers to different endpoints if possible. Sometimes you need this and ASP.NET Core makes this easy as long as you use the standard implementations. If you use specific vendor client libraries to implement the security, then you need to understand what the wrapper do and how the schemes, policies in the ASP.NET Core middleware are implemented. Setting the default scheme affects all the clients and not just the specific vendor implementation.