I'm developing this server hosted in a Kubernetes cluster which uses some basic certificate-based authentication. I have added the relevant parts of the server and client code but since this is quite a bit of lines I pasted it bellow with some explanatory text. The certificates used here were created based on this article.
The code seems to work when I run it on localhost via IIS Express however in a Linux based docker container I'm getting unexpected behavior.
When running the code locally the controller responds with 200 Ok if the client certificate is valid and a 403 Forbidden if I send no certificate or an invalid or untrusted one.
In the cluster the valid certificates work as well, however with the invalid or untrusted certificates I get the following exception:
Unhandled exception. System.Net.Http.HttpRequestException: An error occurred while sending the request.
---> System.IO.IOException: The response ended prematurely.
at System.Net.Http.HttpConnection.FillAsync()
at System.Net.Http.HttpConnection.ReadNextResponseHeaderLineAsync(Boolean foldedHeadersAllowed)
at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithNtConnectionAuthAsync(HttpConnection connection, HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.FinishSendAsyncBuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts)
at AzureCertAuthClientConsole.Program.GetApiDataUsingHttpClientHandler(Boolean localhost, WhichCert whichCert) in C:\Users\user\Documents\CertAuthClientConsole\Program.cs:line 121
Line 121 here refers to the following code var response = await client.SendAsync(request);
which you can see bellow.
I have enabled trace logging to get more insight in the server side of this error in which I can find this:
dbug: Microsoft.AspNetCore.Server.Kestrel[39]
Connection id "0HM9CM5OJEA3K" accepted.
dbug: Microsoft.AspNetCore.Server.Kestrel[1]
Connection id "0HM9CM5OJEA3K" started.
dbug: Microsoft.AspNetCore.Server.Kestrel.Https.Internal.HttpsConnectionMiddleware[1]
Failed to authenticate HTTPS connection.
System.Security.Authentication.AuthenticationException: The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
at System.Net.Security.SslStream.SendAuthResetSignal(ProtocolToken message, ExceptionDispatchInfo exception)
at System.Net.Security.SslStream.ForceAuthenticationAsync[TIOAdapter](TIOAdapter adapter, Boolean receiveFirst, Byte[] reAuthenticationData, Boolean isApm)
at Microsoft.AspNetCore.Server.Kestrel.Https.Internal.HttpsConnectionMiddleware.OnConnectionAsync(ConnectionContext context)
dbug: Microsoft.AspNetCore.Server.Kestrel[2]
Connection id "0HM9CM5OJEA3K" stopped.
dbug: Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets[7]
Connection id "0HM9CM5OJEA3K" sending FIN because: "The Socket transport's send loop completed gracefully."
The code
Server side:
Relevant part of the Dockerfile
FROM base AS final
WORKDIR /app
COPY CARoot.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
COPY ServerSSL.pfx ./cert/
ENV Logging__LogLevel__Microsoft=Trace
ENV ASPNETCORE_Kestrel__Certificates__Default__Password=1234
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/app/cert/ServerSSL.pfx
ENV ASPNETCORE_URLS=https://+:443;http://+:80
ENV ASPNETCORE_ENVIRONMENT=Development
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "AzureCertAuth.dll"]
As you can see I copy my root CA and the self-signed certificate to the image and update the ca certificates as to trust the my CA. Then I set some environment variables to configure Kestrel to use this server certifcate.
In Program.cs
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureKestrel(o =>
{
o.ConfigureHttpsDefaults(o =>
{
//o.ServerCertificate = new X509Certificate2(@"C:\Users\user\Documents\cert\ServerSSL.pfx", "1234"); // For localhost
o.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
});
});
});
In case I run it on localhost (IIS Express) I specify the server certificate here and in any case I require the client to send a certificate.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<MyCertificateValidationService>();
services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(o =>
{
o.AllowedCertificateTypes = CertificateTypes.All;
o.RevocationMode = X509RevocationMode.NoCheck;
o.ValidateCertificateUse = false;
o.ValidateValidityPeriod = true;
o.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var validationService = context.HttpContext.RequestServices.GetService<MyCertificateValidationService>();
if (validationService.ValidateCertificate(context.ClientCertificate))
{
var 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.Fail("invalid cert");
}
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
context.Fail("authentication failed");
return Task.CompletedTask;
}
};
});
services.AddControllers();
}
Here the singleton MyCertificateValidationService
validates that the thumbprint matches a list of thumbprints matching my client certs and if that fails or the default validation fails I fail the authentication context.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseMiddleware(typeof(RequestLoggingMiddleware));
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers().RequireAuthorization();
});
}
Each controller requires authorization.
Client side:
I have several certificates all to test different aspects: Valid certificates, expired certificates, valid but signed by an untrusted CA. The selected certificate is set to the X509Certificate2 cert
variable in the following snippet.
HttpClientHandler handler = new HttpClientHandler();
if (whichCert != WhichCert.None)
{
handler.ClientCertificates.Add(cert);
}
handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, error) => {
Console.WriteLine(cert.Thumbprint);
return true;
};
HttpClient client = new HttpClient(handler);
Uri uri;
if (localhost)
{
uri = new Uri("https://localhost:44361/WeatherForecast");
}
else
{
uri = new Uri($"https://{serverIp}:443/WeatherForecast");
}
HttpRequestMessage request = new HttpRequestMessage()
{
RequestUri = uri,
Method = HttpMethod.Get,
};
var response = await client.SendAsync(request); // Causes the exception
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
Console.WriteLine(responseContent);
var data = JsonDocument.Parse(responseContent);
return data;
}
Console.WriteLine($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
return;
To walk you through this snipper the first part is adding the client certificate to the HttpClientHandler and a custom validation callback that accepts any server certificate and prints the thumbprint just for me to verify that I have configure the server properly.
Then I create a HttpClient with a request to the default WeatherForecast controller and send and await the server's response.