From dc0e67f1430bd105281e080cab772a37754d16b3 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 26 Mar 2026 17:25:28 -0700 Subject: [PATCH 1/2] Build a PEM bundle for the dev cert --- .../Certificates/CertificateService.cs | 39 ++++++++- .../Certificates/ICertificateToolRunner.cs | 8 ++ .../NativeCertificateToolRunner.cs | 37 +++++++++ .../Projects/GuestAppHostProject.cs | 9 +++ .../TypeScriptLanguageSupport.cs | 4 +- .../Certificates/CertificateServiceTests.cs | 79 +++++++++++++++++++ .../TestServices/TestCertificateService.cs | 3 +- .../TestServices/TestCertificateToolRunner.cs | 8 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 4 +- 9 files changed, 185 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index ca7d627d6b0..8c3c6065c8a 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -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; @@ -21,6 +22,11 @@ internal sealed class EnsureCertificatesTrustedResult /// to ensure certificates are properly trusted. /// public required IDictionary EnvironmentVariables { get; init; } + + /// + /// Gets the path to the exported PEM certificate file, if one was exported. + /// + public string? DevCertPemPath { get; init; } } internal interface ICertificateService @@ -31,9 +37,15 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry) : ICertificateService + AspireCliTelemetry telemetry, + CliExecutionContext executionContext, + ILogger 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 EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -45,9 +57,12 @@ public async Task EnsureCertificatesTrustedAsyn var trustResult = await CheckMachineReadableAsync(); await HandleMachineReadableTrustAsync(trustResult, environmentVariables); + ExportDevCertificatePem(); + return new EnsureCertificatesTrustedResult { - EnvironmentVariables = environmentVariables + EnvironmentVariables = environmentVariables, + DevCertPemPath = File.Exists(DevCertPemPath) ? DevCertPemPath : null }; } @@ -130,6 +145,26 @@ private static void ConfigureSslCertDir(Dictionary environmentVa } } + private void 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"); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to export dev certificate as PEM"); + } + } + } internal sealed class CertificateServiceException(string message) : Exception(message) diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs index f800927769e..d67af14e17b 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -24,4 +24,12 @@ internal interface ICertificateToolRunner /// Removes all HTTPS development certificates. /// CertificateCleanResult CleanHttpCertificate(); + + /// + /// Exports the public key of the highest-versioned ASP.NET Core HTTPS development certificate + /// as a PEM file at the specified path. + /// + /// The file path to write the PEM certificate to. + /// The output path if a certificate was exported; if no valid certificate was found. + string? ExportDevCertificatePublicPem(string outputPath); } diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index ca9726743c3..2b4ebabb22e 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -173,6 +173,43 @@ public CertificateCleanResult CleanHttpCertificate() } } + public string? ExportDevCertificatePublicPem(string outputPath) + { + 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); + + return outputPath; + } + finally + { + CertificateManager.DisposeCertificates(availableCertificates); + } + } + private static string[]? GetSanExtension(X509Certificate2 cert) { var dnsNames = new List(); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2f3ba592b13..78dbb8f88e7 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -346,10 +346,12 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken { // Step 1: Ensure certificates are trusted Dictionary certEnvVars; + string? devCertPemPath; try { var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); certEnvVars = new Dictionary(certResult.EnvironmentVariables); + devCertPemPath = certResult.DevCertPemPath; } catch { @@ -489,6 +491,13 @@ 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 + if (devCertPemPath is not null && LanguageId.Contains("nodejs", StringComparison.OrdinalIgnoreCase)) + { + environmentVariables["NODE_EXTRA_CA_CERTS"] = devCertPemPath; + } + // Check if the extension should launch the guest app host (for VS Code debugging). // This mirrors the pattern in DotNetCliRunner.ExecuteAsync for .NET app hosts. // The RuntimeSpec declares the required extension capability (e.g., "node" for TypeScript); diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs index cfbe6abec09..fc8014fb1ec 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs @@ -192,7 +192,7 @@ public RuntimeSpec GetRuntimeSpec() Execute = new CommandSpec { Command = "npx", - Args = ["tsx", "{appHostFile}"] + Args = ["tsx", "{appHostFile}"], }, WatchExecute = new CommandSpec { @@ -205,7 +205,7 @@ public RuntimeSpec GetRuntimeSpec() "--ignore", "node_modules/", "--ignore", ".modules/", "--exec", "npx tsx {appHostFile}" - ] + ], } }; } diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d6677f2a846..f47c96744b6 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -221,10 +221,82 @@ 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(); + + await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.True(exportCalled); + } + + [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(); + + // Should not throw even when PEM export fails + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(result); + } + private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } + public Func? ExportDevCertificatePublicPemCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -251,5 +323,12 @@ public EnsureCertificateResult TrustHttpCertificate() public CertificateCleanResult CleanHttpCertificate() => new CertificateCleanResult { Success = true }; + + public string? ExportDevCertificatePublicPem(string outputPath) + { + return ExportDevCertificatePublicPemCallback is not null + ? ExportDevCertificatePublicPemCallback(outputPath) + : null; + } } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs index fbc98351ea3..043efde595f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs @@ -11,7 +11,8 @@ public Task EnsureCertificatesTrustedAsync(Canc { return Task.FromResult(new EnsureCertificatesTrustedResult { - EnvironmentVariables = new Dictionary() + EnvironmentVariables = new Dictionary(), + DevCertPemPath = null }); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 14939e7f043..9dcfca69f7a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -15,6 +15,7 @@ internal sealed class TestCertificateToolRunner : ICertificateToolRunner public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } + public Func? ExportDevCertificatePublicPemCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -45,4 +46,11 @@ public CertificateCleanResult CleanHttpCertificate() ? CleanHttpCertificateCallback() : new CertificateCleanResult { Success = true }; } + + public string? ExportDevCertificatePublicPem(string outputPath) + { + return ExportDevCertificatePublicPemCallback is not null + ? ExportDevCertificatePublicPemCallback(outputPath) + : null; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e89b6fdca5c..62fb408bff4 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -408,7 +408,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry); + var executionContext = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, executionContext, logger); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => From 1b55a63c8d005d4213a5532b13ff5f70236b52f8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 26 Mar 2026 17:44:31 -0700 Subject: [PATCH 2/2] Use response from cert bundle directly --- src/Aspire.Cli/Certificates/CertificateService.cs | 9 ++++++--- .../Certificates/CertificateServiceTests.cs | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index 8c3c6065c8a..2ed2c9e01cc 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -57,12 +57,12 @@ public async Task EnsureCertificatesTrustedAsyn var trustResult = await CheckMachineReadableAsync(); await HandleMachineReadableTrustAsync(trustResult, environmentVariables); - ExportDevCertificatePem(); + var devCertPemPath = ExportDevCertificatePem(); return new EnsureCertificatesTrustedResult { EnvironmentVariables = environmentVariables, - DevCertPemPath = File.Exists(DevCertPemPath) ? DevCertPemPath : null + DevCertPemPath = devCertPemPath }; } @@ -145,7 +145,7 @@ private static void ConfigureSslCertDir(Dictionary environmentVa } } - private void ExportDevCertificatePem() + private string? ExportDevCertificatePem() { try { @@ -158,10 +158,13 @@ private void ExportDevCertificatePem() { 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; } } diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index f47c96744b6..576d6f46441 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -254,9 +254,10 @@ public async Task EnsureCertificatesTrustedAsync_ExportsPemCertificate() var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.True(exportCalled); + Assert.NotNull(result.DevCertPemPath); } [Fact] @@ -290,6 +291,7 @@ public async Task EnsureCertificatesTrustedAsync_PemExportFailure_DoesNotThrow() // 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