.NET Aspirations - Use ASP.NET Core HTTPS Development Certificate


It's a good practice to use HTTPS in your local development environment to closely match the production environment and prevent future HTTPS issues.
In a previous article, I discussed how to set up HTTPS on a web application with an ASP.NET Core API and a Nuxt.js front end. This was done using the ASP.NET Core HTTPS Development certificate. However, for the front end, it required using the dev-certs
built-in command of the .NET CLI to export the certificate files (key file and pem
file). That's fine, but it would be better if this step could be automated when using .NET Aspire. Let's explore that.
As usual, we will use our AspnetWithNuxt
sample. In our WebApp
, we currently assume that the certificate files are already generated and available in our folder, so the paths to these files are hard-coded in the Nuxt configuration:
$development: {
routeRules: {
'/api/**': {
proxy: 'https://localhost:7238/**',
}
},
devServer: {
https: {
key: 'dev-cert.key',
cert: 'dev-cert.pem'
}
}
},
What we would need instead is to use environment variables that contain these paths and default to the hard-coded values otherwhise:
$development: {
routeRules: {
'/api/**': {
proxy: `${import.meta.env.ApiUrl}/**`,
}
},
devServer: {
https: {
key: import.meta.env.CERT_KEY_PATH ?? 'dev-cert.key',
cert: import.meta.env.CERT_PATH ?? 'dev-cert.pem'
}
}
},
That seems better. But now we need a way to ensure the certificate files exist, generate them if they don’t, and automatically inject these environment variables. Ideally, this would be configured in the AppHost
by calling a specific method when defining the WebApp
resource.
And guess what, there is an issue on the .NET Aspire GitHub repository discussing exactly such a method. Unfortunately, at the time of writing this method is not built-in but can be found in the aspire-samples
repository. We just need to copy it and place it in our AppHost
project
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
public static class DevCertHostingExtensions
{
/// <summary>
/// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
/// <paramref name="builder"/>.<see cref="IResourceBuilder{T}.ApplicationBuilder">ApplicationBuilder</see>.<see cref="IDistributedApplicationBuilder.ExecutionContext">ExecutionContext</see>.<see cref="DistributedApplicationExecutionContext.IsRunMode">IsRunMode</see><c> == true</c>.<br/>
/// If the resource is a <see cref="ContainerResource"/>, the certificate files will be bind mounted into the container.
/// </summary>
/// <remarks>
/// This method <strong>does not</strong> configure an HTTPS endpoint on the resource.
/// Use <see cref="ResourceBuilderExtensions.WithHttpsEndpoint{TResource}"/> to configure an HTTPS endpoint.
/// </remarks>
public static IResourceBuilder<TResource> RunWithHttpsDevCertificate<TResource>(
this IResourceBuilder<TResource> builder, string certFileEnv, string certKeyFileEnv, Action<string, string>? onSuccessfulExport = null)
where TResource : IResourceWithEnvironment
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment())
{
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>(async (e, ct) =>
{
var logger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource);
// Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via
// the specified environment variables.
var (exported, certPath, certKeyPath) = await TryExportDevCertificateAsync(builder.ApplicationBuilder, logger);
if (!exported)
{
// The export failed for some reason, don't configure the resource to use the certificate.
return;
}
if (builder.Resource is ContainerResource containerResource)
{
// Bind-mount the certificate files into the container.
const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs";
var certFileName = Path.GetFileName(certPath);
var certKeyFileName = Path.GetFileName(certKeyPath);
var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException();
var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}";
var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}";
builder.ApplicationBuilder.CreateResourceBuilder(containerResource)
.WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: true)
.WithEnvironment(certFileEnv, certFileDest)
.WithEnvironment(certKeyFileEnv, certKeyFileDest);
}
else
{
builder
.WithEnvironment(certFileEnv, certPath)
.WithEnvironment(certKeyFileEnv, certKeyPath);
}
if (onSuccessfulExport is not null)
{
onSuccessfulExport(certPath, certKeyPath);
}
});
}
return builder;
}
private static async Task<(bool, string CertFilePath, string CertKeyFilPath)> TryExportDevCertificateAsync(IDistributedApplicationBuilder builder, ILogger logger)
{
// Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary
// directory and returns the path.
// TODO: Check if we're running on a platform that already has the cert and key exported to a file (e.g. macOS) and just use those instead.
var appNameHash = builder.Configuration["AppHost:Sha256"]![..10];
var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}");
var certExportPath = Path.Combine(tempDir, "dev-cert.pem");
var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key");
if (File.Exists(certExportPath) && File.Exists(certKeyExportPath))
{
// Certificate already exported, return the path.
logger.LogDebug("Using previously exported dev cert files '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
return (true, certExportPath, certKeyExportPath);
}
if (File.Exists(certExportPath))
{
logger.LogTrace("Deleting previously exported dev cert file '{CertPath}'", certExportPath);
File.Delete(certExportPath);
}
if (File.Exists(certKeyExportPath))
{
logger.LogTrace("Deleting previously exported dev cert key file '{CertKeyPath}'", certKeyExportPath);
File.Delete(certKeyExportPath);
}
if (!Directory.Exists(tempDir))
{
logger.LogTrace("Creating directory to export dev cert to '{ExportDir}'", tempDir);
Directory.CreateDirectory(tempDir);
}
string[] args = ["dev-certs", "https", "--export-path", $"\"{certExportPath}\"", "--format", "Pem", "--no-password"];
var argsString = string.Join(' ', args);
logger.LogTrace("Running command to export dev cert: {ExportCmd}", $"dotnet {argsString}");
var exportStartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = argsString,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
var exportProcess = new Process { StartInfo = exportStartInfo };
Task? stdOutTask = null;
Task? stdErrTask = null;
try
{
try
{
if (exportProcess.Start())
{
stdOutTask = ConsumeOutput(exportProcess.StandardOutput, msg => logger.LogInformation("> {StandardOutput}", msg));
stdErrTask = ConsumeOutput(exportProcess.StandardError, msg => logger.LogError("! {ErrorOutput}", msg));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to start HTTPS dev certificate export process");
return default;
}
var timeout = TimeSpan.FromSeconds(5);
var exited = exportProcess.WaitForExit(timeout);
if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath))
{
logger.LogDebug("Dev cert exported to '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
return (true, certExportPath, certKeyExportPath);
}
if (exportProcess.HasExited && exportProcess.ExitCode != 0)
{
logger.LogError("HTTPS dev certificate export failed with exit code {ExitCode}", exportProcess.ExitCode);
}
else if (!exportProcess.HasExited)
{
exportProcess.Kill(true);
logger.LogError("HTTPS dev certificate export timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds);
}
else
{
logger.LogError("HTTPS dev certificate export failed for an unknown reason");
}
return default;
}
finally
{
await Task.WhenAll(stdOutTask ?? Task.CompletedTask, stdErrTask ?? Task.CompletedTask);
}
static async Task ConsumeOutput(TextReader reader, Action<string> callback)
{
char[] buffer = new char[256];
int charsRead;
while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
callback(new string(buffer, 0, charsRead));
}
}
}
}
Then we can call this method like this:
var webApp= builder.AddPnpmApp("WebApp", "../WebApp", "dev")
.WithHttpsEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.WithPnpmPackageInstallation()
.WithReference(webApi)
.WaitFor(webApi)
.WithEnvironment("ApiUrl", webApi.GetEndpoint("https"))
.WithEnvironmentPrefix("NUXT_PUBLIC_")
.RunWithHttpsDevCertificate("CERT_PATH", "CERT_KEY_PATH");
And that's it. Thanks to .NET Aspire, setting up HTTPS in our application is now easier. And from what I've read about the issues on the repository, it seems there are likely improvements coming to .NET Aspire to make this feature built-in and even better. Anyway, this is a great example of how we can easily customize .NET Aspire to fit our needs and enhance the developer experience.
Subscribe to my newsletter
Read articles from Alexandre Nedelec directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Alexandre Nedelec
Alexandre Nedelec
I am a software developer living in Bordeaux. I am a .NET and Azure enthusiast, primarly coding in C# but I like to discover other languages and technologies too.