OnCertificateValidated not running - Self-Signed Certificate Client Authentication - ASP.NET Core and Kestrel

Viewed 3204

I would like to authenticate clients connecting to my ASP.NET Core Web API (.NET 5) running on Kestrel using certificate-based authentication.

In my Startup.cs I have the following in ConfigureServices:

services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.AllowedCertificateTypes = CertificateTypes.All;
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                // More code to verify certificates
            },
            OnAuthenticationFailed = context =>
            {
                // More code
            }
        };
    });

// Other services

And in Configure:

app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthentication();

app.UseEndpoints(endpoints =>
{
    // Endpoints
});

And in Program.cs I have included:

webBuilder.ConfigureKestrel(o =>
{
    o.ConfigureHttpsDefaults(o =>
        o.ClientCertificateMode = ClientCertificateMode.RequireCertificate);
});

If I connect to the API in a browser, it prompts me for a certificate, but after I select a certificate, neither the OnCertificateValidated nor the OnAuthenticationFailed events are triggered. After some further testing, I realized that the entire options configuration delegate inside the AddCertificate call in Startup.cs never runs. This makes me think I am missing some kind of configuration for Kestrel, but I do not know what that is. As a note, my Web API does NOT use IIS hosting. What else do I need to do to use self-signed certificate-based authentication?

The code I have so far is based on the instructions found in the documentation here: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-5.0

2 Answers

Ok, so in the end I was able to solve my own problem. There were two different parts to solving it, but ultimately it only required a few small modifications to my project code.

Recognizing client certificates

Firstly, the server was not recognizing the self-signed client certificates as valid certificates. This can be solved by either 1. adding all of the client certificates (or a root CA that signs them all) to the trusted certificate store of the operating system or 2. adding a ClientCertificateValidation callback to kestrel to determine whether or not a certificate is accepted or rejected.

Example of #2 (an adjustment to the ConfigureHttpsDefaults lambda in Program.cs) is below:

webBuilder.ConfigureKestrel(o =>
{
    o.ConfigureHttpsDefaults(opts =>
    {
        opts.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        opts.ClientCertificateValidation = (cert, chain, policyErrors) =>
        {
            // Certificate validation logic here
            // Return true if the certificate is valid or false if it is invalid
        };
    });
});

As a side note, calling opts.AllowAnyClientCertificate() is a shorthand for adding a ClientCertificateValidation callback that always returns true, making ALL self-signed certificates valid.


Required Authorization

After applying either of these approaches, my API would accept queries from valid certificates, but my extra certificate validation logic in the OnCertificateValidated event was still not running. This is because, according to comments on ASP.NET Core issue #14033, this event's extra certificate validation will never run unless authorization is enabled for the endpoint being accessed. This makes sense because this event is often used to generate a ClaimsPrincipal from a certificate per the ASP.NET Core Docs on the subject. Setting ASP.NET to use authorization and requiring authorization for API calls (e.g., by applying the [Authorize] attribute to all controllers) causes the additional authentication checks to run for those API calls.

app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthentication();

// Adding this and adding the [Authorize] attribute
// to controllers fixes the problem.
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    // Endpoints
});

After this, my OnCertificateValidated event was called for every connection and I was able to perform additional authentication logic and reject invalid certificates.

The accepted answer was very helpful for me (thanks!) but it did not solve my problem entirely.

I found that the ClientCertificateValidation function is not the only place the certificate is validated (and rejected). Using AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme). AddCertificate... also caused another validation to be triggered, after ClientCertificateValidation was handled.

To solve the problem for me, I had to configure a CustomTrustStore in lines to the CertificateAuthenticationOptions:

AddCertificate(options =>
{
    options.AllowedCertificateTypes = CertificateTypes.All;
    options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
    options.CustomTrustStore = new X509Certificate2Collection { rootCert };
    options.RevocationMode = X509RevocationMode.NoCheck;
    options.Events = new CertificateAuthenticationEvents
    {
        OnCertificateValidated = context =>
        {
            if (validationService.ValidateCertificate(context.ClientCertificate))
            {
                context.Success();
            }
            else
            {
                context.Fail("invalid cert");
            }

            return Task.CompletedTask;
        },
        OnAuthenticationFailed = context =>
        {
            context.Fail("invalid cert");
            return Task.CompletedTask;
        }
    };
});

Where rootCert is initiated with:

var rootCert = new X509Certificate2("RootCert.pfx", "1234");

And RootCert.pfx is added as file to the project.

Since we're using the built-in validation now from AddAuthentication(.).AddCertificate, we should disable the earlier validation with AllowAnyCertificate() (This validation does not know about our custom root trust):

builder.WebHost.ConfigureKestrel(kso =>
{
    kso.ConfigureHttpsDefaults(cao => {
        cao.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        cao.AllowAnyClientCertificate();
        cao.CheckCertificateRevocation = false;
    });
});
Related