Coding with Titans

so breaking things happens constantly, but never on purpose

Multiple parallel authentications in ASP.NET Core 3.x

One day you write an application and all seems to go smoothly and quickly. The other day you have a brilliant idea, new improvement that was bothering you for very long time. Even due to world global pandemic (ironic, right?) you finally found a free slot to implement it! Then you got immediately blocked, stopped and wasting whole day on browsing the Internet looking for a solution. You start experimenting, playing with the technology/framework/library and finally you open the documentation and read the manual. Should it always be in that order? I think not, but our nature drives us on a path to always make shortcuts and keep preserving the attention, effort and time. But as the life in IT shows up, it's never really working that way. Testing, small steps and determination are the key to success.

And so is the story of my pet project. It begins, where I imagined a feature of two totally different authentications working in parallel in ASP.NET Core 3.x… along with my journey though constantly changing .NET APIs inside ASP.NET itself.

In the very far, far away past I used to rely fully only on Basic Authentication. That is neat and easy way of securing server resources. It also works perfectly fine in a project I am supposed to be the only user (or one of a few).

More nowadays way of authentication I wished to introduce into the system was JSON Web Token Authentication (JWT). It significantly decreases the database workload, as each Web API request doesn't required read from users and access-rights tables. All required information is passed inside the mentioned token (digitally signed by the server!) that caller is supposed to deliver.

TL;DR

If you need to add Basic Authentication or JWT into your ASP.NET Core 3.x WebAPI application there are already very good tutorials out there from Jason Watmore (respectively here and here).

ASP.NET Core documentation from Microsoft explains very nicely, how to combine Cookie Authentication along with JWT or two JWTs issued by two distinct authorities.

Finally, this very good answer from StackOverflow.com (plus one older here) gives a hint, how to implement a working solution via overwriting default authentication policy and combine multiple schemes.

Let's go back to our requirements.

Basic Authentication

In condensed description Basic Authentication looks like following.

  1. Create class BasicAuthenticationHandler that handles reading of Authorization header and creates proper user's identity (ClaimsIdentity, following from here).

  2. Use own external user-service (here IUserAuthService) to obtain credentials, roles etc. from database or any other source you need.

  3. Implement another method to issue the authentication challenge back inside the browser (to let it ask the user for credentials). It's very important here to let the identity know, which scheme was used for authentication. We could differentiate them later in the code, if needed.

  4. Register it on WebAPI application startup. It will be covered later, along with JWT.

public sealed class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly IUserAuthService _userService;

    // ... dependency injection removed for clarity

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // 1.
        // read data from Authorization header

        // ... also removed for clarity

        // 2.
        // get information about user
        var user = await _userService.Authenticate(Scheme.Name, credentials[0], credentials[1]);

        var identity = CreateIdentity(user, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }

    // 3.
    // trigger proper authentication challenge,
    // when no Authorization header found or HandleAuthenticateAsync() returned an error
    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = $"Basic realm=\"CodeTitans DataCenter\", charset=\"UTF-8\"";
        return base.HandleChallengeAsync(properties);
    }

    // 1. (continuation)
    // ... following part of ClaimsIdentity creation
    public static ClaimsIdentity CreateIdentity(User user, string authenticationScheme)
    {
        if (user == null)
            throw new ArgumentNullException(nameof(user));

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString("D")),
            new Claim(ClaimTypes.Name, user.Name),
            new Claim(ClaimTypes.Email, user.Email),
        };

        var identity = new ClaimsIdentity(claims, authenticationScheme);

        // add all roles:
        if (user.Roles != null && user.Roles.Length > 0)
        {
            foreach (var role in user.Roles)
            {
                identity.AddClaim(new Claim(ClaimTypes.Role, role));
            }
        }
        return identity;
    }
}

User authentication service might be as simple as:

public interface IUserAuthService
{
    Task<User?> Authenticate(string? authenticationScheme, string login, string password);
}

public class User
{
    public Guid Id { get; }
    public string Name { get; }
    public string Login { get; }
    public string Password { get; }
    public string Email { get; }
    public string[]? Roles { get; }
    public string? AccessToken { get; }

    public User(Guid id, string name, string login, string password, string email, string[]? roles = null, string? accessToken = null)
    {
        Id = id;
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Login = login ?? throw new ArgumentNullException(nameof(login));
        Password = password ?? throw new ArgumentNullException(nameof(password));
        Email = email ?? throw new ArgumentNullException(nameof(email));
        Roles = roles;
        AccessToken = accessToken;
    }

    public User ToSecure(string? accessToken = null)
    {
        return new User(Id, Name, Login, string.Empty, Email, Roles, accessToken);
    }

    public override string ToString()
    {
        return $"{Id}: {Name}";
    }
}

JWT Authentication

Adding JWT support is even simpler than the scheme above. All is already provided by Microsoft and most of the classes could be already reused. What we really need to do is:

  1. Add reference to those two NuGet packages:

    • Microsoft.AspNetCore.Authentication.JwtBearer
    • System.IdentityModel.Tokens.Jwt
  2. Implement user-authentication service in a way that reads all data from database or other source and issues a JWT token, if used authentication scheme requires it. Assuming that this service will be used also with Basic Authentication, there is no need of the token at all. Please note here that we create ClaimsIdentity with proper authentication scheme name.

  3. Create a WebAPI controller to expose authorization action.

  4. Register it on WebAPI application startup. It is covered in the next section.

// 2.
public class JwtUsersService : IUserAuthService
{
    // ... dependency injection removed for clarity

    public Task<User?> Authenticate(string? authenticationScheme, string login, string password)
    {
        // TODO: implement the proper validation of user and read it's properties like ID/email and roles
    }

    private User? PrepareUser(string? authenticationScheme, User user)
    {
        // authentication successfully, check, if we need to generate JWT token:
        if (string.Compare(authenticationScheme, JwtBearerDefaults.AuthenticationScheme, StringComparison.OrdinalIgnoreCase) != 0)
        {
            return user.ToSecure();
        }

        var tokenHandler = new JwtSecurityTokenHandler();
        var identity = BasicAuthenticationHandler.CreateIdentity(user, JwtBearerDefaults.AuthenticationScheme);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = identity,

            // other parameters omitted for clarity;
            // must much the same parameters set during JwtBarear registration below
        };
        var accessToken = tokenHandler.CreateToken(tokenDescriptor);

        return user.ToSecure(tokenHandler.WriteToken(accessToken));
    }
}

// 3.
[Authorize]
[ApiController]
[Route("[controller]")]
public sealed class UsersController : ControllerBase
{
    private readonly IUserAuthService _authService;

    public UsersController(IUserAuthService authService)
    {
        _authService = authService ?? throw new ArgumentNullException(nameof(authService));
    }

    [AllowAnonymous]
    [HttpPost("authenticate")]
    public async Task<IActionResult> AuthenticateAsync([FromBody]UserAuthRequestDto model)
    {
        // issue new identity along with JWT token
        var user = await _authService.Authenticate(JwtBearerDefaults.AuthenticationScheme, model.Login, model.Password);

        if (user == null)
            return BadRequest(new { message = "Login or password is incorrect" });

        return Ok(user);
    }

    [HttpGet("short_info")]
    [Authorize]
    public IActionResult GetShortInfo()
    {
        var id = User.FindFirst(ClaimTypes.NameIdentifier).Value;
        var name = User.Identity.Name;
        var authType = User.Identity.AuthenticationType;
        return Ok(new { Id = id, Name = name, AuthType = authType });
    }
}

public sealed class UserAuthRequestDto
{
    [Required]
    public string Login { get; set; } = string.Empty;

    [Required]
    public string Password { get; set; } = string.Empty;
}

Registration

Finally - the most important part is the proper registration (check here for more info). What we need to specify is not hard, although not self-explanatory:

  1. Disable default authentication-scheme, otherwise only this single scheme will be checked during authentication.

  2. Set the default scheme for challenges. In my case I used “Basic”, so if user tries to access the WebAPI without any credentials, he/she will be asked by the browser to provide ones. The ‘realm’ should be displayed as a hint, where trying to login.

  3. JWT bearer defines respective authentication type (without that depending on a call sequence we might got unexpected value in User.Identity.AuthenticationType inside WebAPI controller's action method).

  4. Overwrite the default authentication policy, to check available set of schemes inside the Authorization header.

  5. Optional - add own, custom policies that might enforce particular age or role of the user, before accessing given resource.

public void ConfigureServices(IServiceCollection services)
{

    // ... register options and other services here
    services.AddScoped<IUserAuthService, JwtUsersService>();

    services.AddAuthentication(x =>
        {
            // 1.
            // avoid using default scheme, use policy below to enumerate and choose the proper one:
            x.DefaultAuthenticateScheme = null;
            // 2.
            // by default ask for password using basic-auth:
            x.DefaultChallengeScheme = "Basic";
        })
        .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("Basic", options => { })
        .AddJwtBearer(x =>
        {
            x.RequireHttpsMetadata = false;
            x.SaveToken = true;
            x.TokenValidationParameters = new TokenValidationParameters
            {
                // 3. important!!
                // define the authentication type of the user's identity
                AuthenticationType = JwtBearerDefaults.AuthenticationScheme,
                ValidateIssuerSigningKey = true,
                ValidateLifetime = true,

                // other parameters omitted for clarity
            };
        });

    services.AddAuthorization(options =>
        {
            // 4.
            // change the default policy to try out all existing authentication handlers
            options.DefaultPolicy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddAuthenticationSchemes("Basic", JwtBearerDefaults.AuthenticationScheme)
                .Build();

            // 5.
            // use it later as [Authorize(Policy = "AdminPolicy")]
            options.AddPolicy("AdminPolicy", new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddAuthenticationSchemes("Basic", JwtBearerDefaults.AuthenticationScheme)
                .RequireClaim(ClaimTypes.Role, "Admin")
                .Build());
        });
}

Issue

Presented method works perfectly without one case I noticed so far. It will ask user for credentials, when no Authorization header is provided and will authenticate, when “Basic” or “Bearer” are used. However, if you try to use roles checking only over particular action if might fail always with Basic Authentication challenge. I don't know the reason of this behavior, but found a workaround. The whole controller must be marked with [Authorize] attribute. It is usually not an issue at all, as I prefer this style of accessing resources, only marking public methods with [AllowAnonymous].

[ApiController]
[Authorize] // important !!
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    // this action will fail and cause infinite 401 response,
    // if whole controller is not marked with [Authorize]
    [HttpGet]
    [Authorize(Roles = Role.Admin)]
    public IEnumerable<WeatherForecast> Get()
    {
        // TODO: implement
    }

    // this will work always,
    // even if controller is not marked with [Authorize],
    // what is really strange!
    [HttpGet("restricted")]
    [Authorize(Policy = "AdminPolicy")]
    public WeatherForecast RestrictedForecast()
    {
    }
}

Final words

As always. It was not complicated at all. Just few hints were required to make it behave as expected. Now, it's time to add more stuff into it! If you are not yet familiar with the concept I highly suggest reading about refresh tokens. This terms comes from OAuth 2.0.

Looking around I found a good implementation of own mechanism supporting them on a blog of Piotr Gankewicz with code and also some info here.

See you on the next stop!