ASP.NET Core Authentication

One of the challenges I had early in developing my current project was getting authentication set up nicely. My back-end is an API running in .NET Core, and my general impression is that ASP.NET Core’s support for API use cases is somewhat weaker than for MVC applications.

ASP.NET Core’s default transport for authentication context still seems to be via cookies. This was quite surprising as my impression of the industry is that, between their complexity (from which it is easy to make security mistakes) and recent EU rules, cookies were on their way out. ASP.NET Core also introduced Identity for authentication, but the use of ViewModel in examples indicates that is targeted towards an MVC application.

My preference was to use JSON Web Tokens (JWTs) sent as bearer tokens in the authorization header of an HTTP request. I also wanted to use authorization attributes, like [Authorize("PolicyName")], to enforce security policy on the API controllers.

Validation and Authorization

.NET Core has support for validating JWTs via the System.IdentityModel.Tokens.Jwt package. Applying this requires something like the following in the Startup.Configure method:

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
app.UseJwtBearerAuthentication(new JwtBearerOptions()
{
    Authority = Configuration["AuthorityUrl"],
    TokenValidationParameters = new TokenValidationParameters() { ValidateAudience = false },
    RequireHttpsMetadata = true,
    AutomaticAuthenticate = true,
    Events = new JwtBearerEvents { OnTokenValidated = IocContainer.Resolve<Auth.IValidatedTokenHandling>().AddUserClaimsToContext },
};

The recommended approach to authorization in ASP.NET Core is to use claims and policies. To that end the code above responds to the OnTokenValidated event and sends it to a method that queries the user and adds claims based on information about the user.

public async Task AddUserClaimsToContext(TokenValidatedContext context) 
{
    var claims = new List<Claim>();

    // JWT subject is the userid
    var sub = context.Ticket.Principal.FindFirst("sub")?.Value;
    if(sub != null)
    {
        var user = await _users.FindById(Guid.Parse(sub));
        if(user != null)
        {
            if(user.UserVerification > 0)
                claims.Add(new Claim("MustBeValidatedUser", "true", ClaimValueTypes.Boolean));
        }
    }
    var claimsIdentity = context.Ticket.Principal.Identity as ClaimsIdentity;
    claimsIdentity.AddClaims(claims);
}

Finally the policies themselves must be defined, typically in the Startup.ConfigureServices method:

mvc.AddAuthorization(options => {
    options.AddPolicy("MustBeValidatedUser", policy => policy.RequireClaim(Auth.ClaimDefinitions.MustBeValidatedUser, "true"));                   
});

Generating Tokens

.NET Core does not have support for generating JWTs. For this it recommends IdentityServer4.

IdentityServer4 is intended to be a fully fledged authentication server supporting the many flows of OAuth2 and Open ID Connect. For my purposes I only required username and password validation, so in many respects IdentityServer4 was overkill, but given lack of alternatives for generating JWTs, I forged ahead with it anyway.

It is worth noting my solution deviates from the norm. IdentityServer seems predicated on the idea that the authentication service is a standalone server, microservice style. Given the early stage of development I was at, having another server seemed like an annoyance, so I opted to have the authentication service as part of the API server. Really the only problem with this was it obscured the distinction between the ‘client’ (the JWT validation and authorization) and the ‘server’ (IdentityServer4) meaning it perhaps took a little longer than I’d have preferred to understand my authentication and authorization solution.

Using identity server is trivial – one line in the Startup.Configure: app.UseIdentityServer();. Set up, even for a basic solution, is a little more complex and will admit that to this day I do not fully understand scopes and the implications of them.

Supporting the server involves defining various resources in Startup. The scopes referenced in the Configure method end up in the scopes field in the JWT payload.

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System.IdentityModel.Tokens.Jwt;
using Microsoft​.AspNetCore​.Authentication​.JwtBearer;
using Microsoft.IdentityModel.Tokens;

public virtual IServiceProvider ConfigureServices(IServiceCollection services)
{
    services.AddIdentityServer()
        .AddInMemoryIdentityResources(Auth.IdentityServerConfig.GetIdentityResources())
        .AddInMemoryApiResources(Auth.IdentityServerConfig.GetApiResources())
        .AddInMemoryClients(Auth.IdentityServerConfig.GetClients())
        .AddTemporarySigningCredential();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IApplicationLifetime appLifetime)
{
    app.UseIdentityServer();
    // Configure authorization in the API to parse and validate JWT bearer tokens
    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
    app.UseJwtBearerAuthentication(GetJwtBearerOptions());
    app.AllowScopes(new[] {
        IdentityServer4.IdentityServerConstants.StandardScopes.OpenId,
        IdentityServer4.IdentityServerConstants.StandardScopes.Profile,
        Auth.IdentityServerConfig.MY_API_SCOPE
    });
}

The configurations referenced in ConfigureServices link to a static class with a similar structure to that from the quick starts.

Testing

The final challenge with this set up was running integration tests with ASP.NET Core’s TestServer. The difficultly was that the authentication process would try to make a web request to the authentication server URL (e.g. http://localhost:5000). However because TestServer is not a real server listening on a port, then no authentication response would be received.

To resolve this an additional option was added to the JwtBearerOptions during Startup only for the integration tests. This class intercepts the authentication request and copies it to the TestServer’s client instance (using a static, which I’m not proud of). This is all illustrated below.

options.BackchannelHttpHandler = new RedirectToTestServerHandler();

public class RedirectToTestServerHandler : System.Net.Http.HttpClientHandler
{
    ///<summary>Change URL requests made to the server to use the TestServer.HttpClient rather than a custom one</summary>
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        HttpRequestMessage copy = new HttpRequestMessage(request.Method, request.RequestUri);
        foreach (var header in request.Headers)
            copy.Headers.Add(header.Key, header.Value);
        copy.Content = request.Content;

        Serilog.Log.Information("Intercepted request to {uri}", request.RequestUri);
        HttpResponseMessage result = TestContext.Instance.Client.SendAsync(copy, cancellationToken).GetAwaiter().GetResult();
        return Task.FromResult(result);
    }
}