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?
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.