Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/Aspire.Cli/Certificates/CertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Certificates;

Expand All @@ -21,6 +22,11 @@ internal sealed class EnsureCertificatesTrustedResult
/// to ensure certificates are properly trusted.
/// </summary>
public required IDictionary<string, string> EnvironmentVariables { get; init; }

/// <summary>
/// Gets the path to the exported PEM certificate file, if one was exported.
/// </summary>
public string? DevCertPemPath { get; init; }
}

internal interface ICertificateService
Expand All @@ -31,9 +37,15 @@ internal interface ICertificateService
internal sealed class CertificateService(
ICertificateToolRunner certificateToolRunner,
IInteractionService interactionService,
AspireCliTelemetry telemetry) : ICertificateService
AspireCliTelemetry telemetry,
CliExecutionContext executionContext,
ILogger<CertificateService> logger) : ICertificateService
{
private const string SslCertDirEnvVar = "SSL_CERT_DIR";
private const string DevCertPemFileName = "aspire-dev-cert.pem";

internal string DevCertPemPath => Path.Combine(
executionContext.HomeDirectory.FullName, ".aspire", "dev-certs", DevCertPemFileName);

public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken)
{
Expand All @@ -45,9 +57,12 @@ public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsyn
var trustResult = await CheckMachineReadableAsync();
await HandleMachineReadableTrustAsync(trustResult, environmentVariables);

var devCertPemPath = ExportDevCertificatePem();

return new EnsureCertificatesTrustedResult
{
EnvironmentVariables = environmentVariables
EnvironmentVariables = environmentVariables,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExportDevCertificatePem() runs on every call to EnsureCertificatesTrustedAsync, including for .NET app hosts where the PEM is never used. Consider making the export opt-in (e.g., a parameter or flag) or moving it to the call site that actually needs it (GuestAppHostProject), so .NET app hosts don't pay for the cert store enumeration + file write on every run.

DevCertPemPath = devCertPemPath
};
}

Expand Down Expand Up @@ -130,6 +145,29 @@ private static void ConfigureSslCertDir(Dictionary<string, string> environmentVa
}
}

private string? ExportDevCertificatePem()
{
try
{
var result = certificateToolRunner.ExportDevCertificatePublicPem(DevCertPemPath);
if (result is not null)
{
logger.LogDebug("Exported dev certificate public PEM to {Path}", result);
}
else
{
logger.LogDebug("No valid dev certificate found to export as PEM");
}

return result;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to export dev certificate as PEM");
return null;
}
}

}

internal sealed class CertificateServiceException(string message) : Exception(message)
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Certificates/ICertificateToolRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,12 @@ internal interface ICertificateToolRunner
/// Removes all HTTPS development certificates.
/// </summary>
CertificateCleanResult CleanHttpCertificate();

/// <summary>
/// Exports the public key of the highest-versioned ASP.NET Core HTTPS development certificate
/// as a PEM file at the specified path.
/// </summary>
/// <param name="outputPath">The file path to write the PEM certificate to.</param>
/// <returns>The output path if a certificate was exported; <see langword="null"/> if no valid certificate was found.</returns>
string? ExportDevCertificatePublicPem(string outputPath);
}
37 changes: 37 additions & 0 deletions src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,43 @@ public CertificateCleanResult CleanHttpCertificate()
}
}

public string? ExportDevCertificatePublicPem(string outputPath)
Copy link
Copy Markdown
Member

@JamesNK JamesNK Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't any logging of what's going on here. Could you get agent to add more logging throughout this method.

There are other methods in this class that could do with more logging. Use your judgement on what's important.

Double check that nothing sensitive is logged.

{
var availableCertificates = certificateManager.ListCertificates(
StoreName.My, StoreLocation.CurrentUser, isValid: true);

try
{
var now = DateTimeOffset.Now;
var certificate = availableCertificates
.Where(c => CertificateManager.IsHttpsDevelopmentCertificate(c)
&& c.NotBefore <= now && now <= c.NotAfter)
.OrderByDescending(CertificateManager.GetCertificateVersion)
.ThenByDescending(c => c.NotAfter)
.FirstOrDefault();

if (certificate is null)
{
return null;
}

var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}

var pem = certificate.ExportCertificatePem();
File.WriteAllText(outputPath, pem);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PEM file is written unconditionally on every run, even if the certificate hasn't changed. This updates the file timestamp unnecessarily and does redundant I/O. Consider comparing the existing file content (or certificate thumbprint) before overwriting, e.g.:

var pem = certificate.ExportCertificatePem();
if (File.Exists(outputPath) && File.ReadAllText(outputPath) == pem)
{
    return outputPath;
}
File.WriteAllText(outputPath, pem);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you whether it's worth doing this, or unconditionally overwriting is better/simplier. Add a comment if so.


return outputPath;
}
finally
{
CertificateManager.DisposeCertificates(availableCertificates);
}
}

private static string[]? GetSanExtension(X509Certificate2 cert)
{
var dnsNames = new List<string>();
Expand Down
35 changes: 35 additions & 0 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,12 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken
{
// Step 1: Ensure certificates are trusted
Dictionary<string, string> certEnvVars;
string? devCertPemPath;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

devCertPemPath is declared but only assigned inside the try. If anyone later changes the catch to not re-throw, it would be used uninitialized. Safer to initialize at declaration:

string? devCertPemPath = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm surprised this compiles. I would have thought it would complain about using an uninitialized variable if it's only set in a try block.

try
{
var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken);
certEnvVars = new Dictionary<string, string>(certResult.EnvironmentVariables);
devCertPemPath = certResult.DevCertPemPath;
}
catch
{
Expand Down Expand Up @@ -491,6 +493,10 @@ await GenerateCodeViaRpcAsync(
["ASPIRE_APPHOST_FILEPATH"] = appHostFile.FullName
};

// Set NODE_EXTRA_CA_CERTS for Node.js-based runtimes so the TypeScript app host
// trusts the ASP.NET Core development certificate when connecting over HTTPS
ConfigureNodeCertificateEnvironment(environmentVariables, context.EnvironmentVariables, devCertPemPath);

// Pass debug flag to the guest process
if (context.Debug)
{
Expand Down Expand Up @@ -1404,4 +1410,33 @@ private async Task<int> InstallDependenciesAsync(
var id = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName);
return Task.FromResult<string?>(id);
}

/// <summary>
/// Configures NODE_EXTRA_CA_CERTS for Node.js-based runtimes to trust the ASP.NET Core
/// development certificate. Skips configuration and warns if the variable is already set.
/// </summary>
internal void ConfigureNodeCertificateEnvironment(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDictionary<string, string> environmentVariables,
IDictionary<string, string> contextEnvironmentVariables,
string? devCertPemPath)
{
if (devCertPemPath is null || !LanguageId.Contains("nodejs", StringComparison.OrdinalIgnoreCase))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See other comment about improving LanguageId check.

{
return;
}

var existingNodeExtraCaCerts = Environment.GetEnvironmentVariable("NODE_EXTRA_CA_CERTS")
?? (contextEnvironmentVariables.TryGetValue("NODE_EXTRA_CA_CERTS", out var ctxValue) ? ctxValue : null);

if (existingNodeExtraCaCerts is not null)
{
_interactionService.DisplayMessage(
KnownEmojis.Warning,
$"NODE_EXTRA_CA_CERTS is already set to '{existingNodeExtraCaCerts}'. Skipping Aspire dev certificate configuration.");
}
else
{
environmentVariables["NODE_EXTRA_CA_CERTS"] = devCertPemPath;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ public RuntimeSpec GetRuntimeSpec()
Execute = new CommandSpec
{
Command = "npx",
Args = ["tsx", "{appHostFile}"]
Args = ["tsx", "{appHostFile}"],
},
WatchExecute = new CommandSpec
{
Expand All @@ -205,7 +205,7 @@ public RuntimeSpec GetRuntimeSpec()
"--ignore", "node_modules/",
"--ignore", ".modules/",
"--exec", "npx tsx {appHostFile}"
]
],
}
};
}
Expand Down
81 changes: 81 additions & 0 deletions tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,84 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar
Assert.NotNull(result);
}

[Fact]
public async Task EnsureCertificatesTrustedAsync_ExportsPemCertificate()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var exportCalled = false;

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CertificateToolRunnerFactory = sp =>
{
return new TestCertificateToolRunner
{
CheckHttpCertificateCallback = () =>
{
return new CertificateTrustResult
{
HasCertificates = true,
TrustLevel = CertificateManager.TrustLevel.Full,
Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }]
};
},
ExportDevCertificatePublicPemCallback = (path) =>
{
exportCalled = true;
return path;
}
};
};
});

var sp = services.BuildServiceProvider();
var cs = sp.GetRequiredService<ICertificateService>();

var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout();

Assert.True(exportCalled);
Assert.NotNull(result.DevCertPemPath);
}

[Fact]
public async Task EnsureCertificatesTrustedAsync_PemExportFailure_DoesNotThrow()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CertificateToolRunnerFactory = sp =>
{
return new TestCertificateToolRunner
{
CheckHttpCertificateCallback = () =>
{
return new CertificateTrustResult
{
HasCertificates = true,
TrustLevel = CertificateManager.TrustLevel.Full,
Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }]
};
},
ExportDevCertificatePublicPemCallback = (_) => throw new IOException("Disk full")
};
};
});

var sp = services.BuildServiceProvider();
var cs = sp.GetRequiredService<ICertificateService>();

// Should not throw even when PEM export fails
var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout();
Assert.NotNull(result);
Assert.Null(result.DevCertPemPath);
}

private sealed class TestCertificateToolRunner : ICertificateToolRunner
{
public Func<CertificateTrustResult>? CheckHttpCertificateCallback { get; set; }
public Func<EnsureCertificateResult>? TrustHttpCertificateCallback { get; set; }
public Func<string, string?>? ExportDevCertificatePublicPemCallback { get; set; }

public CertificateTrustResult CheckHttpCertificate()
{
Expand All @@ -251,5 +325,12 @@ public EnsureCertificateResult TrustHttpCertificate()

public CertificateCleanResult CleanHttpCertificate()
=> new CertificateCleanResult { Success = true };

public string? ExportDevCertificatePublicPem(string outputPath)
{
return ExportDevCertificatePublicPemCallback is not null
? ExportDevCertificatePublicPemCallback(outputPath)
: null;
}
}
}
Loading
Loading