asp.net-coreasp.net-core-mvcasp.net-core-webapi

ASP.NET Core 8.0 RESTful API Certificate Authentication not working correctly


I have a class library project that implements Certificate Authentication EXACTLY as it is described at https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-8.0. Unfortunately, when my custom authentication handler returns context.Fail(message), it is ignored and certificate authentication passes. I want to limit the specific certificates that can be used, so my custom authentication handler checks the certificate supplied against a list of valid certificates that can be authenticated. This is done by looking at the subject and issuer combination. The logic in my handler is flawless. The problem is that the caller is not respecting the failure status I am sending back. Hopefully I can upload my project as a zip file. That would be easiest, but I will post the code so everyone can see it. Thank you!

This is my very simple project: Project Screenshot

Here is the code from top file to bottom:

ClientConfigurationSettings.cs:

namespace Corp.Lib.CertificateAuthentication.Configuration
{
    public class ClientCertificateSettings
    {
        public bool CheckThumbprintInCertStore { get; set; }

        public List<ClientCertificate> AllowedCertificates { get; set; } = null!;
    }

    public class ClientCertificate
    {
        public string Subject { get; set; } = null!;

        public string Issuer { get; set; } = null!;
    }
}

ServiceCollectionExtensions.cs:

using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using Corp.Lib.CertificateAuthentication.Configuration;
using Corp.Lib.CertificateAuthentication.Services;
using Corp.Lib.CertificateAuthentication.Services.Interfaces;
using Microsoft.AspNetCore.Authentication.Certificate;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.DependencyInjection;

namespace Corp.Lib.CertificateAuthentication.Extensions
{
    public static class ServiceCollectionExtensions
    {
        public static void AddCertificateAuthenticationService(this WebApplicationBuilder builder)
        {
            builder.Services.Configure<ClientCertificateSettings>(builder.Configuration.GetSection("ClientCertificateSettings"));

            // Require a certificate from any client sending a request.
            builder.WebHost.ConfigureKestrel(kestrel =>
            {
                kestrel.ConfigureHttpsDefaults(defaults =>
                {
                    defaults.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
                    
                    //defaults.ClientCertificateValidation = (certificate, chain, errors) =>
                    //{
                    //  return true;
                    //};
                });
            });

            builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();

            builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(options =>
            {
                options.Events = new CertificateAuthenticationEvents
                {
                    OnCertificateValidated = context =>
                    {
                        var validationService = context.HttpContext.RequestServices.GetRequiredService<IAuthenticationService>();

                        if (!validationService.IsValidClientCertificate(context.ClientCertificate))
                        {
                            context.Principal = null;
                            
                            context.Fail("Invalid client certificate.");
                        }
                        else
                        {
                            var claims = new[]
                            {
                                new Claim(
                                    ClaimTypes.NameIdentifier,
                                    context.ClientCertificate.GetNameInfo(X509NameType.SimpleName, false),
                                    ClaimValueTypes.String,
                                    context.Options.ClaimsIssuer),
                                new Claim(
                                    ClaimTypes.Name,
                                    context.ClientCertificate.GetNameInfo(X509NameType.SimpleName, false),
                                    ClaimValueTypes.String,
                                    context.Options.ClaimsIssuer)
                            };

                            context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));

                            context.Success();
                        }

                        return Task.CompletedTask;
                    }
                };
            });
        }

        public static void UseCertificateAuthenticationService(this WebApplication app)
        {
            app.UseAuthentication();
        }
    }
}

IAuthenticationService.cs:

using System.Security.Cryptography.X509Certificates;

namespace Corp.Lib.CertificateAuthentication.Services.Interfaces
{
    public interface IAuthenticationService
    {
        bool IsValidClientCertificate(X509Certificate2 certificate);
    }
}

AuthenitcationService.cs:

using System.Configuration;
using Corp.Lib.CertificateAuthentication.Services.Interfaces;
using System.Security.Cryptography.X509Certificates;
using Corp.Lib.CertificateAuthentication.Configuration;
using Microsoft.Extensions.Options;
using Corp.Lib.Logging;

namespace Corp.Lib.CertificateAuthentication.Services
{
    public class AuthenticationService(IOptions<ClientCertificateSettings> options) : IAuthenticationService
    {
        private readonly ClientCertificateSettings _ClientCertificateSettings = options.Value;

        public bool IsValidClientCertificate(X509Certificate2 clientCertificate)
        {
            if (clientCertificate == null!)
            {
                var error = new ConfigurationErrorsException("Missing certificate or certificate not sent by client.");

                Logger.Log.Error(error, "Certificate validation failed. Missing certificate or certificate not sent by client.");

                throw error;
            }

            if (_ClientCertificateSettings == null! || _ClientCertificateSettings.AllowedCertificates == null! || !_ClientCertificateSettings.AllowedCertificates.ToList().Any())
            {
                var error = new ConfigurationErrorsException("Certificate configuration missing. Check AppSettings.");

                Logger.Log.Error(error, "Certificate validation failed. Certificate configuration missing. Check AppSettings.");

                throw error;
            }

            if (_ClientCertificateSettings.AllowedCertificates.Any(cert => string.IsNullOrEmpty(cert.Subject)))
            {
                var error = new ConfigurationErrorsException("Certificate configuration missing Subject. Check AppSettings.");

                Logger.Log.Error(error, "Certificate validation failed. Certificate configuration missing Subject. Check AppSettings.");

                throw error;
            }

            if (_ClientCertificateSettings.AllowedCertificates.Any(cert => string.IsNullOrEmpty(cert.Issuer)))
            {
                var error = new ConfigurationErrorsException("Certificate configuration missing Subject. Check AppSettings.");

                Logger.Log.Error(error, "Certificate validation failed. Certificate configuration missing Issuer. Check AppSettings.");

                throw error;
            }

            // 1. Check time validity of certificate.
            if (DateTime.Compare(DateTime.UtcNow, clientCertificate.NotBefore) < 0 || DateTime.Compare(DateTime.UtcNow, clientCertificate.NotAfter) > 0)
            {
                Logger.Log.Warning($"Certificate with thumbprint {clientCertificate.Thumbprint} is expired.");

                return false;
            }

            // 2. Check subject name of certificate.
            var foundSubject = false;

            var certSubjectData = clientCertificate.Subject.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);

            if (certSubjectData.Any(certSubject => _ClientCertificateSettings.AllowedCertificates.Any(cert => cert.Subject.Equals(certSubject.Trim(), StringComparison.InvariantCultureIgnoreCase))))
            {
                foundSubject = true;
            }

            if (!foundSubject)
            {
                Logger.Log.Warning($"Certificate with thumbprint {clientCertificate.Thumbprint} does not have a matching Subject.");

                return false;
            }

            // 3. Check issuer name of certificate.
            var certIssuerData = clientCertificate.Issuer.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);

            var foundIssuer = certIssuerData.Any(issuerData => _ClientCertificateSettings.AllowedCertificates.Any(cert => cert.Issuer.Equals(issuerData.Trim(), StringComparison.InvariantCultureIgnoreCase)));

            if (!foundIssuer)
            {
                Logger.Log.Warning($"Certificate with thumbprint {clientCertificate.Thumbprint} does not have a matching Issuer.");

                return false;
            }

            // Check if the Certificate exists in the personal cert store.
            if (_ClientCertificateSettings.CheckThumbprintInCertStore)
            {
                var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);

                try
                {
                    store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);

                    var certs = store.Certificates.Find(X509FindType.FindByThumbprint, clientCertificate.Thumbprint, true);

                    if (certs.Count == 0)
                    {
                        Logger.Log.Warning("Invalid client certificate. The thumbprint does not match with a certificate in the certificate store. {@ClientCertificate}", clientCertificate);

                        return false;
                    }
                }
                catch (Exception ex)
                {
                    Logger.Log.Error(ex, "An exception occurred searching for the client certificate in the certificate store. {@ClientCertificate}", clientCertificate);

                    throw;
                }
                finally
                {
                    store.Close();

                    store.Dispose();
                }
            }

            return true;
        }
    }
}

That's all the code in the project. This project is referenced by an ASP.NET Core Web API. It uses the ServiceCollectionExtensions class methods to setup certificate authentication in Program.cs. Then the calling client sends a certificate and it is authenticated. Or it's supposed to be anyway.


Solution

  • It's an authorization concern that the user be authenticated, so I had to that as the fallback policy and everything works. I made the following addition to the ServiceCollectionExtensions.AddCertificateAuthenticationService method:

    builder.Services.AddAuthorization(options =>
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder()
                        .RequireAuthenticatedUser()
                        .Build();
    });