Search code examples
asp.net-web-apijwt.net-6.0api-key

Can API Key and JWT Token be used in the same .Net 6 WebAPI


I am building a new .Net 6 WebAPI that will be consumed by many applications so I need to implement API Keys to limit access to only those applications. Only a very small amount of the individual users will require authorization (admins) so I would like to combine with JWT for the Admin endpoints. We do not want to require users to have to crate an account where not necessary (non-admins). Is this possible? Thank You.


Solution

  • Yes it is possible.
    The solution I recommend is to setup multiple authentication methods in asp.net core 6 using two authentication schemes that you have to specify inside Authorize attribute. Here a simple implementation of ApiKey authentication:

    namespace MyAuthentication;
    
    public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
    {
        private enum AuthenticationFailureReason
        {
            NONE = 0,
            API_KEY_HEADER_NOT_PROVIDED,
            API_KEY_HEADER_VALUE_NULL,
            API_KEY_INVALID
        }
    
        private readonly Microsoft.Extensions.Logging.ILogger _logger;
    
        private AuthenticationFailureReason _failureReason = AuthenticationFailureReason.NONE;
    
        public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options,
                                           ILoggerFactory loggerFactory,
                                           ILogger<ApiKeyAuthenticationHandler> logger,
                                           UrlEncoder encoder,
                                           ISystemClock clock) : base(options, loggerFactory, encoder, clock)
        {
            _logger = logger;
        }
    
        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            //ApiKey header get
            if (!TryGetApiKeyHeader(out string providedApiKey, out AuthenticateResult authenticateResult))
            {
                return authenticateResult;
            }
    
            //TODO: you apikey validity check
            if (await ApiKeyCheckAsync(providedApiKey))
            {
                var principal = new ClaimsPrincipal();  //TODO: Create your Identity retreiving claims
                var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationOptions.Scheme);
    
                return AuthenticateResult.Success(ticket);
            }
    
            _failureReason = AuthenticationFailureReason.API_KEY_INVALID;
            return AuthenticateResult.NoResult();
        }
    
        protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
        {
            //Create response
            Response.Headers.Append(HeaderNames.WWWAuthenticate, $@"Authorization realm=""{ApiKeyAuthenticationOptions.DefaultScheme}""");
            Response.StatusCode = StatusCodes.Status401Unauthorized;
            Response.ContentType = MediaTypeNames.Application.Json;
    
            //TODO: setup a response to provide additional information if you want
            var result = new
            {
                StatusCode = Response.StatusCode,
                Message = _failureReason switch
                {
                    AuthenticationFailureReason.API_KEY_HEADER_NOT_PROVIDED => "ApiKey not provided",
                    AuthenticationFailureReason.API_KEY_HEADER_VALUE_NULL => "ApiKey value is null",
                    AuthenticationFailureReason.NONE or AuthenticationFailureReason.API_KEY_INVALID or _ => "ApiKey is not valid"
                }
            };
    
            using var responseStream = new MemoryStream();
            await JsonSerializer.SerializeAsync(responseStream, result);
            await Response.BodyWriter.WriteAsync(responseStream.ToArray());
        }
    
        protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
        {
            //Create response
            Response.Headers.Append(HeaderNames.WWWAuthenticate, $@"Authorization realm=""{ApiKeyAuthenticationOptions.DefaultScheme}""");
            Response.StatusCode = StatusCodes.Status403Forbidden;
            Response.ContentType = MediaTypeNames.Application.Json;
    
            var result = new
            {
                StatusCode = Response.StatusCode,
                Message = "Forbidden"
            };
    
            using var responseStream = new MemoryStream();
            await JsonSerializer.SerializeAsync(responseStream, result);
            await Response.BodyWriter.WriteAsync(responseStream.ToArray());
        }
    
        #region Privates
        private bool TryGetApiKeyHeader(out string apiKeyHeaderValue, out AuthenticateResult result)
        {
            apiKeyHeaderValue = null;
            if (!Request.Headers.TryGetValue("X-Api-Key", out var apiKeyHeaderValues))
            {
                _logger.LogError("ApiKey header not provided");
    
                _failureReason = AuthenticationFailureReason.API_KEY_HEADER_NOT_PROVIDED;
                result = AuthenticateResult.Fail("ApiKey header not provided");
    
                return false;
            }
    
            apiKeyHeaderValue = apiKeyHeaderValues.FirstOrDefault();
            if (apiKeyHeaderValues.Count == 0 || string.IsNullOrWhiteSpace(apiKeyHeaderValue))
            {
                _logger.LogError("ApiKey header value null");
    
                _failureReason = AuthenticationFailureReason.API_KEY_HEADER_VALUE_NULL;
                result = AuthenticateResult.Fail("ApiKey header value null");
    
                return false;
            }
    
            result = null;
            return true;
        }
    
        private Task<bool> ApiKeyCheckAsync(string apiKey)
        {
            //TODO: setup your validation code...
    
            return Task.FromResult<bool>(true);
        }
        #endregion
    }
    
    public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
    {
        public const string DefaultScheme = "ApiKey";
    
        public static string Scheme => DefaultScheme;
        public static string AuthenticationType => DefaultScheme;
    }
    
    public static class AuthenticationBuilderExtensions
    {
        public static AuthenticationBuilder AddApiKeySupport(this AuthenticationBuilder authenticationBuilder, Action<ApiKeyAuthenticationOptions> options)
            => authenticationBuilder.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.DefaultScheme, options);
    }
    

    Then register inside builder setup:

    _ = services.AddAuthentication(options =>
                 {
                    options.DefaultAuthenticateScheme = ApiKeyAuthenticationOptions.DefaultScheme;
                    options.DefaultChallengeScheme = ApiKeyAuthenticationOptions.DefaultScheme;
                 })
                 .AddApiKeySupport(options => { });
    

    You have to also setup the standard JWT Bearer validation (I don't post it for the sake of brevity).

    To protect your endpoint add the Authorize attribute like:

    [Authorize(AuthenticationSchemes = ApiKeyAuthenticationOptions.DefaultScheme)]  //ApiKey
    [HttpGet]
    public async Task<IActionResult> Get()
    {
       //...omissis...
    
       return null;
    }
    
    //or..
    
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] //Jwt
    [HttpGet]
    public async Task<IActionResult> Get()
    {
       //...omissis...
    
       return null;
    }
    
    //or..
    [Authorize(AuthenticationSchemes = $"{JwtBearerDefaults.AuthenticationScheme},{ApiKeyAuthenticationOptions.DefaultScheme}" )] //ApiKey and Jwt
    [HttpGet]
    public async Task<IActionResult> Get()
    {
       //...omissis...
    
       return null;
    }
    

    For me it is the best way so as to carry out the authorization check before the start of the application pipeline (fail fast) and to be able to create the user identity.

    But if you don't need to put informations about the Api Key inside the ClaimsPrincipal and only check the validity of Api Key the simplest way to do that is:

    • Protect the "admin" actions with JWT auth (with Authorize attribute)
    • Setup and register a middleware to only check the Api Key in all actions Here is an example:
    public class SimpleApiKeyMiddleware
    {
        private static readonly string API_KEY_HEADER = "X-Api-Key";
    
        private readonly RequestDelegate _next;
        private readonly ILogger<SimpleApiKeyMiddleware> _logger;
    
        public SimpleApiKeyMiddleware(RequestDelegate next, ILogger<SimpleApiKeyMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }
    
        public async Task Invoke(HttpContext httpContext)
        {
            //Get apikey header
            if (!httpContext.Request.Headers.TryGetValue(API_KEY_HEADER, out var apiKey))
            {
                _logger.LogError("ApiKey not found inside request headers");
    
                //Error and exit from asp.net core pipeline
                await GenerateForbiddenResponse(httpContext, "ApiKey not found inside request headers");
            }
            else if (!await ApiKeyCheckAsync(apiKey))
            {
                _logger.LogError("ApiKey is not valid: {ApiKey}", apiKey);
    
                //Error and exit from asp.net core pipeline
                await GenerateForbiddenResponse(httpContext, "ApiKey not valid");
            }
            else
            {
                _logger.LogInformation("ApiKey validated: {ApiKey}", apiKey);
    
                //Proceed with pipeline
                await _next(httpContext);
            }
        }
    
        private Task<bool> ApiKeyCheckAsync(string apiKey)
        {
            //TODO: setup your validation code...
    
            return Task.FromResult<bool>(true);
        }
    
        private async Task GenerateForbiddenResponse(HttpContext context, string message)
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            context.Response.ContentType = MediaTypeNames.Application.Json;
    
            using var responseStream = new MemoryStream();
            await System.Text.Json.JsonSerializer.SerializeAsync(responseStream, new
            {
                Status = StatusCodes.Status403Forbidden,
                Message = message
            });
    
            await context.Response.BodyWriter.WriteAsync(responseStream.ToArray());
        }
    }
    

    Registration:

    _ = app.UseMiddleware<ApiKeyMiddleware>();  //Register as first middleware to avoid other middleware execution before api key check
    

    Usage:

    //Admin: Jwt and Api Key check
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] //Jwt and Api Key
    [HttpGet]
    public async Task<IActionResult> MyAdminApi()
    {
       //...omissis...
    }
    
    //Non Admin: Api Key check only
    [HttpGet]
    public async Task<IActionResult> MyNonAdminApi()
    {
       //...omissis...
    }
    

    Note: the middleware code above forces exit from pipeline returning an http result so as to stop next middleware execution. Also note that the asp.net core 6 pipeline executes Authorization first and then all the registered middlewares.