Skip to content

Commit 56f5dc0

Browse files
committed
feat: add article about Dev Certs with .NET Aspire
1 parent 9c4ce87 commit 56f5dc0

File tree

2 files changed

+273
-0
lines changed

2 files changed

+273
-0
lines changed

content/1.posts/73.aspire-devcert.md

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
---
2+
title: ".NET Aspirations - Use ASP.NET Core HTTPS Development Certificate"
3+
lead: "Simplify HTTP set up in your local development environment"
4+
date: 2025-03-13
5+
image:
6+
src: /images/aspire-devcert.png
7+
badge:
8+
label: Development
9+
tags:
10+
- .NET Aspire
11+
- HTTP
12+
- Nuxt
13+
- .NET
14+
- Security
15+
---
16+
17+
It's a good practice to use HTTPS in your local development environment to closely match the production environment and prevent future HTTPS issues.
18+
19+
In a previous [article](https://techwatching.dev/posts/aspnetcore-with-nuxt-https), 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**](https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl?view=aspnetcore-9.0&tabs=visual-studio%2Clinux-sles&wt.mc_id=MVP_430820#trust-the-aspnet-core-https-development-certificate) **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.
20+
21+
As usual, we will use our [`AspnetWithNuxt` sample](https://github.com/TechWatching/AspnetWithNuxt). 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:
22+
23+
```typescript {8-11} [nuxt.config.ts]
24+
$development: {
25+
routeRules: {
26+
'/api/**': {
27+
proxy: 'https://localhost:7238/**',
28+
}
29+
},
30+
devServer: {
31+
https: {
32+
key: 'dev-cert.key',
33+
cert: 'dev-cert.pem'
34+
}
35+
}
36+
},
37+
```
38+
39+
What we would need instead is to use environment variables that contain these paths and default to the hard-coded values otherwhise:
40+
41+
```typescript {8-11} [nuxt.config.ts]
42+
$development: {
43+
routeRules: {
44+
'/api/**': {
45+
proxy: `${import.meta.env.ApiUrl}/**`,
46+
}
47+
},
48+
devServer: {
49+
https: {
50+
key: import.meta.env.CERT_KEY_PATH ?? 'dev-cert.key',
51+
cert: import.meta.env.CERT_PATH ?? 'dev-cert.pem'
52+
}
53+
}
54+
},
55+
```
56+
57+
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.
58+
59+
And guess what, there is an [issue](https://github.com/dotnet/aspire/issues/6890) 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](https://github.com/dotnet/aspire-samples/blob/0c27e4e3bac5f102db1002fd2e0e1ba894e1009a/samples/Shared/DevCertHostingExtensions.cs). We just need to copy it and place it in our `AppHost` project
60+
61+
::collapsible{openText="Show the code of the" closeText="Hide the code of the" name="DevCertHostingExtensions.cs file"}
62+
63+
```csharp [AppHost/DevCertHostingExtensions.cs]
64+
using System.Diagnostics;
65+
using Microsoft.Extensions.DependencyInjection;
66+
using Microsoft.Extensions.Hosting;
67+
using Microsoft.Extensions.Logging;
68+
69+
namespace Aspire.Hosting;
70+
71+
public static class DevCertHostingExtensions
72+
{
73+
/// <summary>
74+
/// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
75+
/// <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/>
76+
/// If the resource is a <see cref="ContainerResource"/>, the certificate files will be bind mounted into the container.
77+
/// </summary>
78+
/// <remarks>
79+
/// This method <strong>does not</strong> configure an HTTPS endpoint on the resource.
80+
/// Use <see cref="ResourceBuilderExtensions.WithHttpsEndpoint{TResource}"/> to configure an HTTPS endpoint.
81+
/// </remarks>
82+
public static IResourceBuilder<TResource> RunWithHttpsDevCertificate<TResource>(
83+
this IResourceBuilder<TResource> builder, string certFileEnv, string certKeyFileEnv, Action<string, string>? onSuccessfulExport = null)
84+
where TResource : IResourceWithEnvironment
85+
{
86+
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment())
87+
{
88+
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>(async (e, ct) =>
89+
{
90+
var logger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource);
91+
92+
// Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via
93+
// the specified environment variables.
94+
var (exported, certPath, certKeyPath) = await TryExportDevCertificateAsync(builder.ApplicationBuilder, logger);
95+
96+
if (!exported)
97+
{
98+
// The export failed for some reason, don't configure the resource to use the certificate.
99+
return;
100+
}
101+
102+
if (builder.Resource is ContainerResource containerResource)
103+
{
104+
// Bind-mount the certificate files into the container.
105+
const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs";
106+
107+
var certFileName = Path.GetFileName(certPath);
108+
var certKeyFileName = Path.GetFileName(certKeyPath);
109+
110+
var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException();
111+
112+
var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}";
113+
var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}";
114+
115+
builder.ApplicationBuilder.CreateResourceBuilder(containerResource)
116+
.WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: true)
117+
.WithEnvironment(certFileEnv, certFileDest)
118+
.WithEnvironment(certKeyFileEnv, certKeyFileDest);
119+
}
120+
else
121+
{
122+
builder
123+
.WithEnvironment(certFileEnv, certPath)
124+
.WithEnvironment(certKeyFileEnv, certKeyPath);
125+
}
126+
127+
if (onSuccessfulExport is not null)
128+
{
129+
onSuccessfulExport(certPath, certKeyPath);
130+
}
131+
});
132+
}
133+
134+
return builder;
135+
}
136+
137+
private static async Task<(bool, string CertFilePath, string CertKeyFilPath)> TryExportDevCertificateAsync(IDistributedApplicationBuilder builder, ILogger logger)
138+
{
139+
// Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary
140+
// directory and returns the path.
141+
// 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.
142+
var appNameHash = builder.Configuration["AppHost:Sha256"]![..10];
143+
var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}");
144+
var certExportPath = Path.Combine(tempDir, "dev-cert.pem");
145+
var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key");
146+
147+
if (File.Exists(certExportPath) && File.Exists(certKeyExportPath))
148+
{
149+
// Certificate already exported, return the path.
150+
logger.LogDebug("Using previously exported dev cert files '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
151+
return (true, certExportPath, certKeyExportPath);
152+
}
153+
154+
if (File.Exists(certExportPath))
155+
{
156+
logger.LogTrace("Deleting previously exported dev cert file '{CertPath}'", certExportPath);
157+
File.Delete(certExportPath);
158+
}
159+
160+
if (File.Exists(certKeyExportPath))
161+
{
162+
logger.LogTrace("Deleting previously exported dev cert key file '{CertKeyPath}'", certKeyExportPath);
163+
File.Delete(certKeyExportPath);
164+
}
165+
166+
if (!Directory.Exists(tempDir))
167+
{
168+
logger.LogTrace("Creating directory to export dev cert to '{ExportDir}'", tempDir);
169+
Directory.CreateDirectory(tempDir);
170+
}
171+
172+
string[] args = ["dev-certs", "https", "--export-path", $"\"{certExportPath}\"", "--format", "Pem", "--no-password"];
173+
var argsString = string.Join(' ', args);
174+
175+
logger.LogTrace("Running command to export dev cert: {ExportCmd}", $"dotnet {argsString}");
176+
var exportStartInfo = new ProcessStartInfo
177+
{
178+
FileName = "dotnet",
179+
Arguments = argsString,
180+
RedirectStandardOutput = true,
181+
RedirectStandardError = true,
182+
UseShellExecute = false,
183+
CreateNoWindow = true,
184+
WindowStyle = ProcessWindowStyle.Hidden,
185+
};
186+
187+
var exportProcess = new Process { StartInfo = exportStartInfo };
188+
189+
Task? stdOutTask = null;
190+
Task? stdErrTask = null;
191+
192+
try
193+
{
194+
try
195+
{
196+
if (exportProcess.Start())
197+
{
198+
stdOutTask = ConsumeOutput(exportProcess.StandardOutput, msg => logger.LogInformation("> {StandardOutput}", msg));
199+
stdErrTask = ConsumeOutput(exportProcess.StandardError, msg => logger.LogError("! {ErrorOutput}", msg));
200+
}
201+
}
202+
catch (Exception ex)
203+
{
204+
logger.LogError(ex, "Failed to start HTTPS dev certificate export process");
205+
return default;
206+
}
207+
208+
var timeout = TimeSpan.FromSeconds(5);
209+
var exited = exportProcess.WaitForExit(timeout);
210+
211+
if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath))
212+
{
213+
logger.LogDebug("Dev cert exported to '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
214+
return (true, certExportPath, certKeyExportPath);
215+
}
216+
217+
if (exportProcess.HasExited && exportProcess.ExitCode != 0)
218+
{
219+
logger.LogError("HTTPS dev certificate export failed with exit code {ExitCode}", exportProcess.ExitCode);
220+
}
221+
else if (!exportProcess.HasExited)
222+
{
223+
exportProcess.Kill(true);
224+
logger.LogError("HTTPS dev certificate export timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds);
225+
}
226+
else
227+
{
228+
logger.LogError("HTTPS dev certificate export failed for an unknown reason");
229+
}
230+
return default;
231+
}
232+
finally
233+
{
234+
await Task.WhenAll(stdOutTask ?? Task.CompletedTask, stdErrTask ?? Task.CompletedTask);
235+
}
236+
237+
static async Task ConsumeOutput(TextReader reader, Action<string> callback)
238+
{
239+
char[] buffer = new char[256];
240+
int charsRead;
241+
242+
while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
243+
{
244+
callback(new string(buffer, 0, charsRead));
245+
}
246+
}
247+
}
248+
}
249+
```
250+
251+
::
252+
253+
::callout{icon="i-heroicons-light-bulb"}
254+
What’s interesting is that this method will also work with a resource container by mounting the certificate files in the container.
255+
::
256+
257+
Then we can call this method like this:
258+
259+
```csharp [AppHost/Program.cs] {9}
260+
var webApp= builder.AddPnpmApp("WebApp", "../WebApp", "dev")
261+
.WithHttpsEndpoint(env: "PORT")
262+
.WithExternalHttpEndpoints()
263+
.WithPnpmPackageInstallation()
264+
.WithReference(webApi)
265+
.WaitFor(webApi)
266+
.WithEnvironment("ApiUrl", webApi.GetEndpoint("https"))
267+
.WithEnvironmentPrefix("NUXT_PUBLIC_")
268+
.RunWithHttpsDevCertificate("CERT_PATH", "CERT_KEY_PATH");
269+
```
270+
271+
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.
272+
273+
You can find the complete code [here](https://github.com/TechWatching/AspnetWithNuxt/tree/6e8f2efa7c68d6399f12859230cdd0beaa8d8218). Keep learning.

public/images/aspire-devcert.png

518 KB
Loading

0 commit comments

Comments
 (0)