If you have ever been curious how authentication schemes work in ASP.NET Core, this is the article for you!

The purpose of this article is to show you how custom authentication schemes can be defined. This allows you to understand how other authentication schemes work better.

I use HTTP Basic as an example so I have something practical to implement within the authentication framework, and you can see how it interacts with other components.

NOTE: This is not meant to be an example implementation of HTTP Basic authentication. No security testing has been done, and the implementation is very naive. Personally I recommend using other authentication schemes especially for user-facing applications. Since HTTP Basic sends the username and password in every request, HTTPS must be used. Other sufficient security measures should be implemented as well.

With that out of the way, let's start exploring what is needed to define an authentication scheme.

Setting up a custom authentication scheme

To add an authentication scheme, we call AddScheme<TOptions, THandler>(string, Action<TOptions>) on the AuthenticationBuilder.

So for our HTTP Basic authentication implementation, it could look like this in ConfigureServices:

services.AddAuthentication("Basic")
    .AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>("Basic", null);

"Basic" is the identifier for the authentication scheme.

You can also specify a callback function to configure the options object, but we will leave it null for now.

The first type parameter is the scheme's options type, which must inherit from AuthenticationSchemeOptions. The second is the handler type, which must inherit from AuthenticationHandler<TOptions>.

There are also RemoteAuthenticationHandler<TOptions> and RemoteAuthenticationOptions which you would use as base classes if you are implementing authentication that requires redirects to an identity provider, like OpenID Connect. But that's beyond the scope of this article.

The options needed here are quite simple:

public class BasicAuthenticationOptions : AuthenticationSchemeOptions
{
    public string Realm { get; set; }
}

Realm will contain the identifier for the "protection space" (more info on Stack Overflow).

Now before we move on to the handler, there is one thing we need to address. In this case we would like to make Realm mandatory.

The way this can be done is by using a class that implements IPostConfigureOptions<TOptions>.

public class BasicAuthenticationPostConfigureOptions : IPostConfigureOptions<BasicAuthenticationOptions>
{
    public void PostConfigure(string name, BasicAuthenticationOptions options)
    {
        if (string.IsNullOrEmpty(options.Realm))
        {
            throw new InvalidOperationException("Realm must be provided in options");
        }
    }
}

Then we would need to register it in the DI container:

services.AddSingleton<IPostConfigureOptions<BasicAuthenticationOptions>, BasicAuthenticationPostConfigureOptions>();

Now the PostConfigure method will be run after the options object is configured. If the realm has not been specified, we throw an error, and the app will fail on start. This is the exact pattern used by e.g. the OpenID Connect authentication scheme classes.

Defining the authentication handler

The handler is the core of everything when it comes to an authentication scheme.

Just look at the OpenID Connect handler for example. It is 1239 lines at the time of writing!

After making your class that inherits from AuthenticationHandler<TOptions>, there is only one method that you must implement.

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
}

This method is called when the authentication middleware calls the DefaultAuthenticateScheme.

You can see it should return an AuthenticateResult, which has the following useful static methods that you can use to construct the result:

  1. AuthenticateResult.NoResult()
    • Creates a result that indicates there was, well, no result
  2. AuthenticateResult.Fail("Invalid username or password")
    • Indicates a failure, you can give it an Exception or an error message
  3. AuthenticateResult.Success(ticket)
    • Indicates a successful authentication, the parameter is an AuthenticationTicket containing the user info

You can also return null to indicate no result.

Handler constructor

Since the base class has no default constructor, we must implement at least the following:

public BasicAuthenticationHandler(
    IOptionsMonitor<BasicAuthenticationOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    ISystemClock clock)
    : base(options, logger, encoder, clock)
{
}

We can access the options object in the methods via the protected Options property. All of the constructor parameters have protected properties in the base class actually.

HandleChallengeAsync and HandleForbiddenAsync

These two are methods you can override in your handler to influence what happens when an authentication challenge (401) or a forbidden response (403) is returned from later layers.

The base class implementations set the response status codes to 401 and 403 respectively.

In the case of Basic authentication, we need to return a WWW-Authenticate header on a 401.

protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{Options.Realm}\", charset=\"UTF-8\"";
    await base.HandleChallengeAsync(properties);
}

This is the standard header the server is required to return when authentication is needed. In a browser scenario, returning this header triggers the login pop-up.

Implementing HandleAuthenticateAsync

Okay, let's get started with the naive implementation of the authentication logic.

So, in HandleAuthenticateAsync we should first check if the request contains an Authorization header.

if (!Request.Headers.ContainsKey("Authorization"))
{
    //Authorization header not in request
    return AuthenticateResult.NoResult();
}

Next we can attempt to parse a valid header value from it:

if(!AuthenticationHeaderValue.TryParse(Request.Headers["Authorization"], out AuthenticationHeaderValue headerValue))
{
    //Invalid Authorization header
    return AuthenticateResult.NoResult();
}

Now since the Authorization header is used for other authentication schemes as well, we also need to check the scheme is "Basic":

if(!"Basic".Equals(headerValue.Scheme, StringComparison.OrdinalIgnoreCase))
{
    //Not Basic authentication header
    return AuthenticateResult.NoResult();
}

Then we need to decode the username and password from the header value.

byte[] headerValueBytes = Convert.FromBase64String(headerValue.Parameter);
string userAndPassword = Encoding.UTF8.GetString(headerValueBytes);
string[] parts = userAndPassword.Split(':');
if(parts.Length != 2)
{
    return AuthenticateResult.Fail("Invalid Basic authentication header");
}
string user = parts[0];
string password = parts[1];

Here we see our first failure condition. If the header does not contain a colon (or contains more than one), it cannot be a valid value.

Now in order to check the username and password, we will define an interface for a service which does that:

public interface IBasicAuthenticationService
{
    Task<bool> IsValidUserAsync(string user, string password);
}

We can then assume a type implementing this interface is registered in DI and get it in the constructor.

The authentication handler is not a singleton, so you can use any services from DI. The handler is instantiated once per request.

private readonly IBasicAuthenticationService _authenticationService;

public BasicAuthenticationHandler(
    IOptionsMonitor<BasicAuthenticationOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    ISystemClock clock,
    IBasicAuthenticationService authenticationService)
    : base(options, logger, encoder, clock)
{
    _authenticationService = authenticationService;
}

Now we can conclude our handler with the validity check:

bool isValidUser = await _authenticationService.IsValidUserAsync(user, password);

if (!isValidUser)
{
    return AuthenticateResult.Fail("Invalid username or password");
}
var claims = new[] { new Claim(ClaimTypes.Name, user) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);

Here we construct a user principal with one claim, their username. That then becomes User.Identity.Name later down the stack.

If the validation fails, we of course return a failure.

Tidying up

We could make it a bit easier to register this authentication scheme with an extension method.

Following the example of the framework extensions, here are the ones defined for this scheme:

public static class BasicAuthenticationExtensions
{
    public static AuthenticationBuilder AddBasic<TAuthService>(this AuthenticationBuilder builder)
        where TAuthService : class, IBasicAuthenticationService
    {
        return AddBasic<TAuthService>(builder, BasicAuthenticationDefaults.AuthenticationScheme, _ => { });
    }

    public static AuthenticationBuilder AddBasic<TAuthService>(this AuthenticationBuilder builder, string authenticationScheme)
        where TAuthService : class, IBasicAuthenticationService
    {
        return AddBasic<TAuthService>(builder, authenticationScheme, _ => { });
    }

    public static AuthenticationBuilder AddBasic<TAuthService>(this AuthenticationBuilder builder, Action<BasicAuthenticationOptions> configureOptions)
        where TAuthService : class, IBasicAuthenticationService
    {
        return AddBasic<TAuthService>(builder, BasicAuthenticationDefaults.AuthenticationScheme, configureOptions);
    }

    public static AuthenticationBuilder AddBasic<TAuthService>(this AuthenticationBuilder builder, string authenticationScheme, Action<BasicAuthenticationOptions> configureOptions)
        where TAuthService : class, IBasicAuthenticationService
    {
        builder.Services.AddSingleton<IPostConfigureOptions<BasicAuthenticationOptions>, BasicAuthenticationPostConfigureOptions>();
        builder.Services.AddTransient<IBasicAuthenticationService, TAuthService>();

        return builder.AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>(
            authenticationScheme, configureOptions);
    }
}

Note all of them require the developer to specify their authentication service, which the handler uses to validate usernames and passwords.

This approach to me felt like the most straight-forward way, as it makes it impossible to even compile the app without implementing the interface.

You might also notice that we now have the authentication scheme name in a defaults class:

public static class BasicAuthenticationDefaults
{
    public const string AuthenticationScheme = "Basic";
}

The extensions then allow the registration of the scheme in the same way as the framework schemes:

services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme)
    .AddBasic<AuthenticationService>(o =>
    {
        o.Realm = "My App";
    });

I also cleaned up the handler a bit so the final result looks like this:

public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions>
{
    private const string AuthorizationHeaderName = "Authorization";
    private const string BasicSchemeName = "Basic";
    private readonly IBasicAuthenticationService _authenticationService;

    public BasicAuthenticationHandler(
        IOptionsMonitor<BasicAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IBasicAuthenticationService authenticationService)
        : base(options, logger, encoder, clock)
    {
        _authenticationService = authenticationService;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey(AuthorizationHeaderName))
        {
            //Authorization header not in request
            return AuthenticateResult.NoResult();
        }

        if(!AuthenticationHeaderValue.TryParse(Request.Headers[AuthorizationHeaderName], out AuthenticationHeaderValue headerValue))
        {
            //Invalid Authorization header
            return AuthenticateResult.NoResult();
        }

        if(!BasicSchemeName.Equals(headerValue.Scheme, StringComparison.OrdinalIgnoreCase))
        {
            //Not Basic authentication header
            return AuthenticateResult.NoResult();
        }

        byte[] headerValueBytes = Convert.FromBase64String(headerValue.Parameter);
        string userAndPassword = Encoding.UTF8.GetString(headerValueBytes);
        string[] parts = userAndPassword.Split(':');
        if(parts.Length != 2)
        {
            return AuthenticateResult.Fail("Invalid Basic authentication header");
        }
        string user = parts[0];
        string password = parts[1];

        bool isValidUser = await _authenticationService.IsValidUserAsync(user, password);

        if (!isValidUser)
        {
            return AuthenticateResult.Fail("Invalid username or password");
        }
        var claims = new[] { new Claim(ClaimTypes.Name, user) };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);
        return AuthenticateResult.Success(ticket);
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{Options.Realm}\", charset=\"UTF-8\"";
        await base.HandleChallengeAsync(properties);
    }
}

Summary

So to implement an authentication scheme, you must at minimum:

  1. Implement the options class inheriting from AuthenticationSchemeOptions
  2. Create the handler, inherit from AuthenticationHandler<TOptions>
  3. Implement the constructor and HandleAuthenticateAsync in the handler
    • Use the static methods of AuthenticateResult to create different results (None, Fail or Success)
  4. Override other methods to change standard behaviour
  5. Register the scheme with AddScheme<TOptions, THandler>(string, Action<TOptions>) on the AuthenticationBuilder, which you get by calling AddAuthentication on the service collection

Remember that HTTP Basic should always be used only over HTTPS! This authentication scheme's serious con is that it submits the username and password with every request in encoded form. Note it is encoded, not encrypted. That means it can be reversed, like the handler here does.

Use something else like OAuth or OpenID Connect if you can. Your key take-away from this article should be an inside look to authentication handlers in ASP.NET Core so you can understand better how they function.

Anyway, thanks a bunch for reading and see you next time! If you haven't subscribed to the RSS yet, now is a good chance :) I also always post new articles on Twitter, so feel free to follow me there.

Links: