Photo by Samantha Lam

Disclaimer

There's a bunch of different ways to handle authentication/authorization. As pointed out here on Twitter by Greg Bair, API keys has some limitations/drawbacks.

One problem is that usually API keys never expires and that's obviously bad from a security point of view. It's better to have short lived tokens.

For my use case (described below) API keys are more than good enough, but if I were to build an application that was publicly available, I would go for something like OAuth 2/JWT/Identityserver4 instead.
It's important to always think twice about security and not just blindly copy/paste code from the internet.

2019-09-25 This blog post has now been updated to use ASP.NET Core 3.0. If you are looking for information about how to do this using ASP.NET Core 2.0, just checkout this git commit and you should be good to go.

The problem

We've an API that are going to be used internally (called by other internal applications).

The consumers of our API are a lot of different departments in our company like accounting, customer service and so on.

We want to solve the following problems:

  1. Identify who is using our API (which department)
  2. Only authenticated and authorized calls should be allowed access.
  3. Give different access levels to different departments

The solution

We are going to generate API keys, one for each department. They will then need to add the API key in all of their API requests. This will allow us to lock down our endpoints, see who is using our API (and keep a bunch of statistics) and much more!

It's important to know the difference between Authentication and Authorization, I will just copy paste this straight from Microsoft:

Authentication is a process in which a user provides credentials that are then compared to those stored in an operating system, database, app or resource. If they match, users authenticate successfully, and can then perform actions that they're authorized for, during an authorization process. The authorization refers to the process that determines what a user is allowed to do.

Authentication

So, let's start!
First we need to define our ApiKey.
ApiKey.cs

public class ApiKey
{
    public ApiKey(int id, string owner, string key, DateTime created, IReadOnlyCollection<string> roles)
    {
        Id = id;
        Owner = owner ?? throw new ArgumentNullException(nameof(owner));
        Key = key ?? throw new ArgumentNullException(nameof(key));
        Created = created;
        Roles = roles ?? throw new ArgumentNullException(nameof(roles));
    }

    public int Id { get; }
    public string Owner { get; }
    public string Key { get; }
    public DateTime Created { get; }
    public IReadOnlyCollection<string> Roles { get; }
}

We also need a place to store/retrieve our API keys so let's create the following interface and implementation.
IGetAllApiKeysQuery.cs

public interface IGetApiKeyQuery
{
    Task<ApiKey> Execute(string providedApiKey);
}

InMemoryGetApiKeyQuery.cs

public class InMemoryGetApiKeyQuery : IGetApiKeyQuery
{
    private readonly IDictionary<string, ApiKey> _apiKeys;

    public InMemoryGetApiKeyQuery()
    {
        var existingApiKeys = new List<ApiKey>
        {
            new ApiKey(1, "Finance", "C5BFF7F0-B4DF-475E-A331-F737424F013C", new DateTime(2019, 01, 01),
                new List<string>
                {
                    Roles.Employee,
                }),
            new ApiKey(2, "Reception", "5908D47C-85D3-4024-8C2B-6EC9464398AD", new DateTime(2019, 01, 01),
                new List<string>
                {
                    Roles.Employee
                }),
            new ApiKey(3, "Management", "06795D9D-A770-44B9-9B27-03C6ABDB1BAE", new DateTime(2019, 01, 01),
                new List<string>
                {
                    Roles.Employee,
                    Roles.Manager
                }),
            new ApiKey(4, "Some Third Party", "FA872702-6396-45DC-89F0-FC1BE900591B", new DateTime(2019, 06, 01),
                new List<string>
                {
                    Roles.ThirdParty
                })
        };

        _apiKeys = existingApiKeys.ToDictionary(x => x.Key, x => x);
    }

    public Task<ApiKey> Execute(string providedApiKey)
    {
        _apiKeys.TryGetValue(providedApiKey, out var key);
        return Task.FromResult(key);
    }
}

As you can see, for this post we are storing the keys in memory, but in the real world we would use a database.

The endpoints we want to protect looks like this:
UserController.cs

[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
    [HttpGet("anyone")]
    public IActionResult Anyone()
    {
        var message = $"Hello from {nameof(Anyone)}";
        return new ObjectResult(message);
    }

    [HttpGet("only-authenticated")]
    public IActionResult OnlyAuthenticated()
    {
        var message = $"Hello from {nameof(OnlyAuthenticated)}";
        return new ObjectResult(message);
    }

    [HttpGet("only-employees")]
    public IActionResult OnlyEmployees()
    {
        var message = $"Hello from {nameof(OnlyEmployees)}";
        return new ObjectResult(message);
    }

    [HttpGet("only-managers")]
    public IActionResult OnlyManagers()
    {
        var message = $"Hello from {nameof(OnlyManagers)}";
        return new ObjectResult(message);
    }

    [HttpGet("only-third-parties")]
    public IActionResult OnlyThirdParties()
    {
        var message = $"Hello from {nameof(OnlyThirdParties)}";
        return new ObjectResult(message);
    }
}

So right now, anyone can call any endpoint. Not good, so let's fix that.
We will start with the OnlyAuthenticated(/api/user/only-authenticated) endpoint.

By adding the [Authorize] attribute, we are saying that (in this example) "as long as you are authenticated, you are allowed access".

UserController.cs

[HttpGet("only-authenticated")]
[Authorize]
public IActionResult OnlyAuthenticated()
{
    var message = $"Hello from {nameof(OnlyAuthenticated)}";
    return new ObjectResult(message);
}

If we try to call our endpoint now with our API key...it will not work, of course. We need to setup the authentication.

First, we add the following to our Startup class
Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = ApiKeyAuthenticationOptions.DefaultScheme;
        options.DefaultChallengeScheme = ApiKeyAuthenticationOptions.DefaultScheme;
    })
    .AddApiKeySupport(options => {});

The important thing to note here is the AddApiKeySupport extension method.
ApiKeyAuthenticationOptions.cs

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
    public const string DefaultScheme = "API Key";
    public string Scheme => DefaultScheme;
    public string AuthenticationType = DefaultScheme;
}

AuthenticationBuilderExtensions.cs

public static class AuthenticationBuilderExtensions
{
    public static AuthenticationBuilder AddApiKeySupport(this AuthenticationBuilder authenticationBuilder, Action<ApiKeyAuthenticationOptions> options)
    {
        return authenticationBuilder.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.DefaultScheme, options);
    }
}

In the AddApiKeySupportmethod we are adding a scheme, we are basically saying that ApiKeyAuthenticationHandler should handle the Api Key scheme.

ApiKeyAuthenticationHandler.cs

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private const string ProblemDetailsContentType = "application/problem+json";
    private readonly IGetApiKeyQuery _getApiKeyQuery;
    private const string ApiKeyHeaderName = "X-Api-Key";
    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IGetApiKeyQuery getApiKeyQuery) : base(options, logger, encoder, clock)
    {
        _getApiKeyQuery = getApiKeyQuery ?? throw new ArgumentNullException(nameof(getApiKeyQuery));
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeaderValues))
        {
            return AuthenticateResult.NoResult();
        }

        var providedApiKey = apiKeyHeaderValues.FirstOrDefault();

        if (apiKeyHeaderValues.Count == 0 || string.IsNullOrWhiteSpace(providedApiKey))
        {
            return AuthenticateResult.NoResult();
        }

        var existingApiKey = await _getApiKeyQuery.Execute(providedApiKey);

        if (existingApiKey != null)
        {
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.Name, existingApiKey.Owner)
            };

            claims.AddRange(existingApiKey.Roles.Select(role => new Claim(ClaimTypes.Role, role)));

            var identity = new ClaimsIdentity(claims, Options.AuthenticationType);
            var identities = new List<ClaimsIdentity> { identity };
            var principal = new ClaimsPrincipal(identities);
            var ticket = new AuthenticationTicket(principal, Options.Scheme);

            return AuthenticateResult.Success(ticket);
        }

        return AuthenticateResult.NoResult();
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.StatusCode = 401;
        Response.ContentType = ProblemDetailsContentType;
        var problemDetails = new UnauthorizedProblemDetails();

        await Response.WriteAsync(JsonSerializer.Serialize(problemDetails, DefaultJsonSerializerOptions.Options));
    }

    protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
    {
        Response.StatusCode = 403;
        Response.ContentType = ProblemDetailsContentType;
        var problemDetails = new ForbiddenProblemDetails();

        await Response.WriteAsync(JsonSerializer.Serialize(problemDetails, DefaultJsonSerializerOptions.Options));
    }
}

This is were the fun starts!
This method gets called for every request that requires authentication.
The logic goes something like this:

  • If no X-Api-Keyheader is present -> Return no result, let other handlers (if present) handle the request.
  • If the header is present but null or empty -> Return no result.
  • If the provided key does not exists -> Return no result.
  • If the key is valid, create a new identity, add the name claim and add all the roles to the identity.

Not that hard to follow, right?
Now we only need to add the authentication and authorization middleware to our pipeline and we should be good to go.
Startup.cs

public void Configure(IApplicationBuilder app, IHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

That is basically all it takes to add Authentication based on API keys to our API.

Authorization

Now, say that we want to have specific endpoints that only the upper management can call, how can we achieve that?

Let's modify our OnlyManagement action method to look like this:
UserController.cs

[HttpGet("only-managers")]
[Authorize(Policy = Policies.OnlyManagers)]
public IActionResult OnlyManagers()
{
    var message = $"Hello from {nameof(OnlyManagers)}";
    return new ObjectResult(message);
}

We've added the [Authorize]attribute and set the policy to OnlyManagers.
Think of a policy as something that needs to evaluate to true for us to get access to that specific resource. So in our case, our OnlyManagers policy will, most likely, check that we in fact are managers. You can read more about policies here.

Policies.cs

public static class Policies
{
    public const string OnlyEmployees = nameof(OnlyEmployees);
    public const string OnlyManagers = nameof(OnlyManagers);
    public const string OnlyThirdParties = nameof(OnlyThirdParties);
}

Let's start by adding the following to our Startup class:
Startup.cs

public void ConfigureServices(IServiceCollection services)
{

 ...............
 services.AddAuthorization(options =>
 {
     options.AddPolicy(Policies.OnlyEmployees, policy => policy.Requirements.Add(new OnlyEmployeesRequirement()));
     options.AddPolicy(Policies.OnlyManagers, policy => policy.Requirements.Add(new OnlyManagersRequirement()));
     options.AddPolicy(Policies.OnlyThirdParties, policy => policy.Requirements.Add(new OnlyThirdPartiesRequirement()));
 });

 services.AddSingleton<IAuthorizationHandler, OnlyEmployeesAuthorizationHandler>();
 services.AddSingleton<IAuthorizationHandler, OnlyManagersAuthorizationHandler>();
 services.AddSingleton<IAuthorizationHandler, OnlyThirdPartiesAuthorizationHandler>();
 ......

So first, we are registering three different policies, OnlyEmployees, OnlyManagers and OnlyThirdParties, and then we are registering a IAuthorizationHandler for each of the policies.

We are focusing on the OnlyManagers policy so let's check it out.
OnlyManagersRequirement.cs

public class OnlyManagersRequirement : IAuthorizationRequirement
{
    // This is empty, but you can have a bunch of properties and methods here if you like that you can later access from the AuthorizationHandler.
}

OnlyManagersAuthorizationHandler.cs

public class OnlyManagersAuthorizationHandler : AuthorizationHandler<OnlyManagersRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, OnlyManagersRequirement requirement)
    {
        if (context.User.IsInRole(Roles.Manager))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

So this is really simple, we are just checking if the user is in the Manager role, if true, we call Succeed on the context. If not, we don't do anything.

So, how do we know that the current user is actually part of the management team?
Well, actually, we don't know that for sure since we have just created generic API keys that are shared by all the management applications, but we trust them right? :)

Anyhow, let's recap how we know that the request is actually allowed access to the OnlyManagers endpoint.

  1. The request hits our API
  2. We validate that the request contains a valid API key - Authentication
  3. If the key is valid, we map the roles from the existing API key.
    ApiKeyAuthenticationHandler.cs
.......
var claims = new List<Claim>
{
    new Claim(ClaimTypes.Name, apiKey.Owner)
};

claims.AddRange(apiKey.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
.......
  1. We then validate that the API key contains the Manager role - Authorization
  2. P R O F I T

Revocation of API Keys

It's not covered in this post but it's rather simple to handle it. We created a IHostedService that runs every minute and looks for changes in the database. If a key has been removed or added, we simply update the cache.

A complete project (with integration tests as well!) can be found here.