Search code examples
c#rest.net-6.0ssl-client-authentication

.NET6 REST API with Client Certificate Authentication always throws 403 Forbidden


I'm implementing rest api project using .net6 protected with Client Certificate Authentication. I'm currently trying everything locally.

Following the MS docs I first created a selfsigned client certificate to test:

New-SelfSignedCertificate -Type Custom -Subject "CN=TestCert,OU=UserAccounts,DC=test,DC=User,DC=com" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.2","2.5.29.17={text}upn=m.peru@directio.it") -KeyUsage DigitalSignature -FriendlyName "TestCleint1" -KeyAlgorithm RSA -KeyLength 2048 -CertStoreLocation "Cert:\CurrentUser\My"

I then exported the .pfx file to re-import it in the Trusted Root Certification Authorities folder in Local Computer Certificates

This is how the authentication is configured in the API Program.cs:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<KestrelServerOptions>(options =>
{
    options.ConfigureHttpsDefaults(
        confOpt => confOpt.ClientCertificateMode = ClientCertificateMode.RequireCertificate);
});

builder.Services.AddScoped<ICertificateValidator, ThumprintsCertificateValidator>();

builder.Services
    .AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        if (options.Events != null)
        {
            options.RevocationMode = X509RevocationMode.NoCheck;
            options.AllowedCertificateTypes = CertificateTypes.All;

            options.Events.OnCertificateValidated = context =>
            {
                ThumprintsCertificateValidator validationService = context.HttpContext.RequestServices.GetRequiredService<ThumprintsCertificateValidator>();
                if (validationService.ValidateCertificate(context.ClientCertificate))
                {
                    Claim[] claims = new[]
                    {
                        new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer),
                        new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer)
                    };

                    context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));
                    context.Success();
                }
                else
                {
                    context.Principal = null;
                    context.Fail("Request certificate is not valid");
                }

                return Task.CompletedTask;
            };
        }
    })
    .AddCertificateCache(options =>
    {
        options.CacheSize = 1024;
        options.CacheEntryExpiration = TimeSpan.FromMinutes(60);
    });

builder.Services.AddAuthorization();

// Add services to the container.
builder.Services.AddControllers();

WebApplication app = builder.Build();


app.UseStaticFiles();
app.UseHttpsRedirection();

//app.UseCertificateForwarding();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

and then I simply have an HomeController.cs class:

 [ApiController]
 [Route("[controller]")]
 public class HomeController : ControllerBase
 {
     private readonly ILogger<HomeController> _logger;

     public HomeController(ILogger<HomeController> logger)
     {
         _logger = logger;
     }

     [HttpGet("test")]
     [Authorize]
     public async Task<IActionResult> Get()
     {
         return Ok("entered");
     }

     [HttpGet("test2")]
     public async Task<IActionResult> Get2()
     {
         return Ok("entered");
     }
 }

When I run the project, the browser asks me to choose from the available client certificates and I find my TestCert available for choice, and I select that one.

Then I created a Console Application to test a call to the authorization-protected endpoint loading the client certificate into my HttpClient instance:

Console App Program.cs:

HttpClientHandler handler = new();

FileInfo cert = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory).GetFiles("TestCert.pfx", searchOption: SearchOption.AllDirectories).First();

handler.ClientCertificateOptions = ClientCertificateOption.Manual;
handler.SslProtocols = SslProtocols.Tls12;
handler.ClientCertificates.Add(new X509Certificate2(cert.FullName, password: "{password}"));

HttpClient client = new(handler);
HttpResponseMessage result = await client.GetAsync("https://localhost:7276/Home/test");

string res = await result.Content.ReadAsStringAsync();
Console.WriteLine(res);

But the answer is always 403 forbidden. The first thing I noticed is that when the call is made, the Events property of the CertificateAuthenticationEvents type inside the CertificateAuthenticationOptions is always null so I had to add the if (options.Events) condition != null) because otherwise it went into exception as soon as I performed GetAsync from my console program, but I think that's exactly the problem.

Is something am I still missing in the configuration?


Solution

  • Solved

    First of all I changed the validation service registration because I was having troubles resolving the ThumprintsCertificateValidator type from its interface:

    builder.Services.AddScoped<ThumprintsCertificateValidator>();
    

    Then it was enough to override the Events property with a new instance of CertificateAuthenticationEvents:

    options.Events = new CertificateAuthenticationEvents() // <- NEW instance
    {
        OnCertificateValidated = context =>
        {
            ThumprintsCertificateValidator validationService = context.HttpContext.RequestServices.GetRequiredService<ThumprintsCertificateValidator>();
            if (validationService.ValidateCertificate(context.ClientCertificate))
            {
                Claim[] claims = new[]
                {
                    new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer),
                    new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer)
                };
    
                context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));
                context.Success();
            }
            else
            {
                context.Principal = null;
                context.Fail("Request certificate is not valid");
            }
    
            return Task.CompletedTask;
        }
    };
    

    and at the time of the call the validation and assignment of the claims were successful, finally giving me access to the protected "test" endpoint.