File: LocalDeveloperCertificateTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Azure.EntraId.Tests\Aspire.Hosting.Azure.EntraId.Tests.csproj (Aspire.Hosting.Azure.EntraId.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Principal;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.EntraId.Tests.TestServices;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;
 
namespace Aspire.Hosting.Azure.EntraId.Tests;
 
public class LocalDeveloperCertificateTests
{
    [Fact]
    public async Task Store_CreatesReusableTwoDayCertificateWithPrivateKey()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clock = new CertificateTestTimeProvider();
        using var app = BuildApplication(builder, clock);
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
 
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var second = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
 
        Assert.Equal(clock.UtcNow, first.NotBefore);
        Assert.Equal(clock.UtcNow.AddDays(2), first.NotAfter);
        Assert.Equal(first.Thumbprint, second.Thumbprint);
        Assert.Equal(first.Base64EncodedValue, second.Base64EncodedValue);
        using var privateCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(first.Base64EncodedValue), null,
            OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet);
        using var publicCertificate = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(first.PublicCertificate));
        using var privateKey = privateCertificate.GetRSAPrivateKey();
        using var publicKey = publicCertificate.GetRSAPublicKey();
        Assert.NotNull(privateKey);
        Assert.NotNull(publicKey);
        Assert.False(publicCertificate.HasPrivateKey);
        Assert.Collection(publicCertificate.Extensions,
            extension =>
            {
                var usage = Assert.IsType<X509KeyUsageExtension>(extension);
                Assert.Equal(X509KeyUsageFlags.DigitalSignature, usage.KeyUsages);
                Assert.True(usage.Critical);
            },
            extension => Assert.IsType<X509SubjectKeyIdentifierExtension>(extension),
            extension => Assert.IsType<X509AuthorityKeyIdentifierExtension>(extension));
        var signature = privateKey.SignData([1, 2, 3], HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        Assert.True(publicKey.VerifyData(new byte[] { 1, 2, 3 }, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
        Assert.Equal(
            Path.Combine(app.Services.GetRequiredService<IAspireStore>().BasePath, "dev-certs", "Entra", "entra-web.pfx"),
            store.GetPath("entra-web"));
    }
 
    [Theory]
    [InlineData(86399, false)]
    [InlineData(86400, true)]
    [InlineData(86401, true)]
    [InlineData(172801, true)]
    public async Task Store_RenewsAtOneDayRemainingAndOverwritesTheSameFile(int elapsedSeconds, bool renews)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clock = new CertificateTestTimeProvider();
        using var app = BuildApplication(builder, clock);
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var path = store.GetPath("entra-web");
 
        clock.UtcNow = clock.UtcNow.AddSeconds(elapsedSeconds);
        var second = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
 
        Assert.Equal(renews, first.Thumbprint != second.Thumbprint);
        Assert.Equal(second.Base64EncodedValue, Convert.ToBase64String(await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)));
        Assert.Equal(["entra-web.pfx", "entra-web.pfx.lock"], Directory.GetFiles(Path.GetDirectoryName(path)!).Select(Path.GetFileName).Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public async Task Store_IsolatesResourcesAndAppHosts()
    {
        using var firstBuilder = TestDistributedApplicationBuilder.Create();
        using var secondBuilder = TestDistributedApplicationBuilder.Create();
        using var firstApp = BuildApplication(firstBuilder, new CertificateTestTimeProvider());
        using var secondApp = BuildApplication(secondBuilder, new CertificateTestTimeProvider());
        var firstStore = firstApp.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var secondStore = secondApp.Services.GetRequiredService<LocalDeveloperCertificateStore>();
 
        var first = await firstStore.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var otherResource = await firstStore.GetCertificateAsync("entra-api", TestContext.Current.CancellationToken);
        var otherAppHost = await secondStore.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
 
        Assert.NotEqual(first.Thumbprint, otherResource.Thumbprint);
        Assert.NotEqual(first.Thumbprint, otherAppHost.Thumbprint);
        Assert.NotEqual(firstStore.GetPath("entra-web"), secondStore.GetPath("entra-web"));
    }
 
    [Fact]
    public async Task Store_ConcurrentCallersSelectTheSameCertificate()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var app = BuildApplication(builder, new CertificateTestTimeProvider());
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var token = TestContext.Current.CancellationToken;
        var certificates = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => store.GetCertificateAsync("entra-web", token), token)));
 
        Assert.Single(certificates.Select(c => c.Thumbprint).Distinct());
        Assert.Single(Directory.GetFiles(Path.GetDirectoryName(store.GetPath("entra-web"))!, "*.pfx"));
    }
 
    [Theory]
    [InlineData("missing")]
    [InlineData("corrupt")]
    [InlineData("public-only")]
    [InlineData("wrong-resource")]
    public async Task Store_RecoversMissingOrIncompatibleMaterial(string scenario)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var app = BuildApplication(builder, new CertificateTestTimeProvider());
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var path = store.GetPath("entra-web");
        switch (scenario)
        {
            case "missing":
                File.Delete(path);
                break;
            case "corrupt":
                await File.WriteAllBytesAsync(path, [1, 2, 3], TestContext.Current.CancellationToken);
                break;
            case "public-only":
                using (var certificate = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(first.PublicCertificate)))
                {
                    await File.WriteAllBytesAsync(path, certificate.Export(X509ContentType.Pfx), TestContext.Current.CancellationToken);
                }
                break;
            case "wrong-resource":
                File.Copy(store.GetPath("entra-web"), store.GetPath("entra-api"));
                path = store.GetPath("entra-api");
                break;
        }
 
        var replacement = await store.GetCertificateAsync(scenario == "wrong-resource" ? "entra-api" : "entra-web", TestContext.Current.CancellationToken);
 
        Assert.NotEqual(first.Thumbprint, replacement.Thumbprint);
        Assert.Equal(replacement.Base64EncodedValue, Convert.ToBase64String(await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)));
    }
 
    [Fact]
    public async Task Store_CancellationWhileLockedPreservesTheOldFile()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clock = new CertificateTestTimeProvider();
        using var app = BuildApplication(builder, clock);
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        clock.UtcNow = clock.UtcNow.AddDays(1);
        var path = store.GetPath("entra-web");
        using var fileLock = new FileStream(path + ".lock", FileMode.Open, FileAccess.ReadWrite, FileShare.None);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var pending = store.GetCertificateAsync("entra-web", cancellation.Token);
        Assert.False(pending.IsCompleted);
 
        await cancellation.CancelAsync();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
        Assert.Equal(first.Base64EncodedValue, Convert.ToBase64String(await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)));
        Assert.False(File.Exists(path + ".tmp"));
    }
 
    [Fact]
    public async Task Store_ReplacementFailurePreservesTheOldFileAndCanBeRetried()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clock = new CertificateTestTimeProvider();
        using var app = BuildApplication(builder, clock);
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        clock.UtcNow = clock.UtcNow.AddDays(1);
        var path = store.GetPath("entra-web");
        Directory.CreateDirectory(path + ".tmp");
 
        await Assert.ThrowsAnyAsync<UnauthorizedAccessException>(() => store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken));
 
        Assert.Equal(first.Base64EncodedValue, Convert.ToBase64String(await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)));
        Directory.Delete(path + ".tmp");
        var replacement = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        Assert.NotEqual(first.Thumbprint, replacement.Thumbprint);
        Assert.False(File.Exists(path + ".tmp"));
    }
 
    [Fact]
    public async Task Store_RemovesAnInterruptedWriteEvenWhenReusingTheCertificate()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var app = BuildApplication(builder, new CertificateTestTimeProvider());
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        var first = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var temporaryPath = store.GetPath("entra-web") + ".tmp";
        await File.WriteAllBytesAsync(temporaryPath, [1, 2, 3], TestContext.Current.CancellationToken);
 
        var reused = await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
 
        Assert.Equal(first.Thumbprint, reused.Thumbprint);
        Assert.False(File.Exists(temporaryPath));
    }
 
    [Fact]
    public async Task Store_RestrictsPrivateFilePermissions()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var app = BuildApplication(builder, new CertificateTestTimeProvider());
        var store = app.Services.GetRequiredService<LocalDeveloperCertificateStore>();
        await store.GetCertificateAsync("entra-web", TestContext.Current.CancellationToken);
        var path = store.GetPath("entra-web");
        if (OperatingSystem.IsWindows())
        {
            var security = new FileInfo(path).GetAccessControl();
            Assert.True(security.AreAccessRulesProtected);
            var rule = Assert.Single(security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>());
            Assert.Equal(WindowsIdentity.GetCurrent().User, rule.IdentityReference);
            Assert.Equal(AccessControlType.Allow, rule.AccessControlType);
            Assert.Equal(FileSystemRights.FullControl, rule.FileSystemRights);
        }
        else
        {
            Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path));
            Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, File.GetUnixFileMode(Path.GetDirectoryName(path)!));
        }
    }
 
    [Fact]
    public async Task RunWithLocalDeveloperCertificateCredential_IsIdempotentAndDoesNotGenerateDuringModelConstruction()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Same(entra, entra.RunWithLocalDeveloperCertificateCredential());
        Assert.Same(entra, entra.RunWithLocalDeveloperCertificateCredential());
        var credential = Assert.IsType<EntraIdLocalDeveloperCertificateCredential>(Assert.Single(entra.Resource.ClientCredentials));
        using var app = BuildApplication(builder, new CertificateTestTimeProvider());
        Assert.False(Directory.Exists(Path.Combine(app.Services.GetRequiredService<IAspireStore>().BasePath, "dev-certs")));
        var template = entra.Resource.GetBicepTemplateString();
        var certificate = await credential.GetCertificateAsync(app.Services, entra.Resource, TestContext.Current.CancellationToken);
        await Verify(template, "bicep").AddScrubber(text => text
            .Replace(certificate.PublicCertificate, "<public DER certificate>")
            .Replace(certificate.Thumbprint, "<certificate thumbprint>"));
    }
 
    [Fact]
    public void RunWithLocalDeveloperCertificateCredential_RejectsExistingApplicationsInBothFluentOrders()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var tenant = Guid.NewGuid().ToString();
        var client = Guid.NewGuid().ToString();
        var existing = builder.AddExistingEntraApplication("existing", tenant, client);
        Assert.Throws<InvalidOperationException>(existing.RunWithLocalDeveloperCertificateCredential);
        var created = builder.AddEntraIdApplication("created").RunWithLocalDeveloperCertificateCredential();
        Assert.Throws<InvalidOperationException>(() => created.AsExistingApplication(tenant, client));
        Assert.Throws<InvalidOperationException>(() => created.AsExistingApplicationForExport(tenant, client));
        Assert.Null(created.Resource.TenantId);
        Assert.Null(created.Resource.ClientId);
        var tenantParameter = builder.AddParameter("tenant-id", tenant);
        var clientParameter = builder.AddParameter("client-id", client);
        Assert.Throws<InvalidOperationException>(() => created.AsExistingApplication(tenantParameter, clientParameter));
        Assert.Null(created.Resource.TenantIdParameter);
        Assert.Null(created.Resource.ClientIdParameter);
        Assert.False(created.Resource.IsExistingApplication);
    }
 
    [Fact]
    public async Task Publish_DoesNotRegisterCertificateServicesOrPublishCertificateConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var entra = builder.AddEntraIdApplication("entra-web").RunWithLocalDeveloperCertificateCredential();
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
        var template = entra.Resource.GetBicepTemplateString();
        Assert.Empty(entra.Resource.ClientCredentials);
        Assert.All(builder.Services, s => Assert.NotEqual(typeof(LocalDeveloperCertificateStore), s.ServiceType));
        await Verify(template, "bicep");
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(web.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
        await Verify(environment).UseMethodName($"{nameof(Publish_DoesNotRegisterCertificateServicesOrPublishCertificateConfiguration)}_Environment");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WithReference_UsesMatchingSecretBase64PfxAndMicrosoftIdentityWebCanLoadItsPrivateKey(bool container)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .RunWithLocalDeveloperCertificateCredential()
            .PublishWithFederatedManagedIdentityCredential();
        IResourceBuilder<IResourceWithEnvironment> web = container
            ? builder.AddContainer("web", "test-image").WithReference(entra)
            : builder.AddResource(new ProjectResource("web")).WithReference(entra);
        var executable = builder.AddExecutable("sensitivity-check", "unused", ".");
        using var app = builder.Build();
        entra.Resource.Outputs["tenantId"] = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
        entra.Resource.Outputs["clientId"] = "00001111-aaaa-2222-bbbb-3333cccc4444";
        entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
 
        var result = await ExecutionConfigurationBuilder.Create(web.Resource).WithEnvironmentVariablesConfig()
            .BuildAsync(builder.ExecutionContext, NullLogger.Instance, TestContext.Current.CancellationToken);
        Assert.Null(result.Exception);
        var environment = result.EnvironmentVariables.ToDictionary();
        var configuration = new ConfigurationBuilder().AddInMemoryCollection(container
            // Non-.NET resources retain the integration's existing upper-snake-case environment convention.
            ? new Dictionary<string, string?>
            {
                ["AzureAd:ClientCredentials:0:SourceType"] = environment["ENTRA_WEB_CLIENT_CREDENTIALS_0_SOURCE_TYPE"],
                ["AzureAd:ClientCredentials:0:Base64EncodedValue"] = environment["ENTRA_WEB_CLIENT_CREDENTIALS_0_BASE64_ENCODED_VALUE"]
            }
            : environment.Select(e => new KeyValuePair<string, string?>(e.Key.Replace("__", ":", StringComparison.Ordinal), e.Value))).Build();
        var options = configuration.GetSection("AzureAd").Get<MicrosoftEntraApplicationOptions>(b => b.ErrorOnUnknownConfiguration = true);
        var description = Assert.Single(Assert.IsType<MicrosoftEntraApplicationOptions>(options).ClientCredentials!);
        Assert.Equal(CredentialSource.Base64Encoded, description.SourceType);
        var selected = await Assert.IsType<EntraIdLocalDeveloperCertificateCredential>(Assert.Single(entra.Resource.ClientCredentials))
            .GetCertificateAsync(app.Services, entra.Resource, TestContext.Current.CancellationToken);
        Assert.Equal(selected.Base64EncodedValue, description.Base64EncodedValue);
        await new DefaultCredentialsLoader().LoadCredentialsIfNeededAsync(description);
        using var certificate = description.Certificate;
        Assert.NotNull(certificate);
        using var key = certificate.GetRSAPrivateKey();
        Assert.NotNull(key);
        using var publicCertificate = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(selected.PublicCertificate));
        using var publicKey = publicCertificate.GetRSAPublicKey();
        var signature = key.SignData([1, 2, 3], HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        Assert.True(publicKey!.VerifyData(new byte[] { 1, 2, 3 }, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
 
        var secret = Assert.IsType<EntraIdLocalDeveloperCertificateCredential>(entra.Resource.ClientCredentials[0]).Secret;
        Assert.True(secret.Secret);
        var dependencies = await web.Resource.GetResourceDependenciesAsync(builder.ExecutionContext, cancellationToken: TestContext.Current.CancellationToken);
        Assert.Contains(secret, dependencies);
        var valueKey = container ? "ENTRA_WEB_CLIENT_CREDENTIALS_0_BASE64_ENCODED_VALUE" : "AzureAd__ClientCredentials__0__Base64EncodedValue";
        Assert.Same(secret, result.EnvironmentVariablesWithUnprocessed.Single(e => e.Key == valueKey).Value.Unprocessed);
        executable.WithArgs(secret);
        var args = await ExecutionConfigurationBuilder.Create(executable.Resource).WithArgumentsConfig()
            .BuildAsync(builder.ExecutionContext, NullLogger.Instance, TestContext.Current.CancellationToken);
        Assert.Null(args.Exception);
        Assert.True(Assert.Single(args.Arguments).IsSensitive);
    }
 
    [Fact]
    public async Task Credential_KeepsOneSelectionForTheRunAndResolvesWithoutProvisioning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clock = new CertificateTestTimeProvider();
        var entra = builder.AddEntraIdApplication("entra-web").RunWithLocalDeveloperCertificateCredential();
        using var app = BuildApplication(builder, clock);
        var credential = Assert.IsType<EntraIdLocalDeveloperCertificateCredential>(entra.Resource.ClientCredentials[0]);
        var first = await credential.GetCertificateAsync(app.Services, entra.Resource, TestContext.Current.CancellationToken);
        clock.UtcNow = clock.UtcNow.AddDays(1);
        var second = await credential.GetCertificateAsync(app.Services, entra.Resource, TestContext.Current.CancellationToken);
        Assert.Same(first, second);
 
        var env = new Dictionary<string, object>();
        credential.EmitEnvironmentVariables(env, "AzureAd__ClientCredentials__0", "__", entra.Resource, new ProjectResource("web"), builder.ExecutionContext);
        Assert.Same(credential.Secret, env["AzureAd__ClientCredentials__0__Base64EncodedValue"]);
        Assert.False(entra.Resource.ProvisioningTaskCompletionSource!.Task.IsCompleted);
        Assert.Equal(first.Base64EncodedValue, await credential.Secret.GetValueAsync(TestContext.Current.CancellationToken));
        Assert.False(entra.Resource.ProvisioningTaskCompletionSource.Task.IsCompleted);
    }
 
    private static DistributedApplication BuildApplication(IDistributedApplicationTestingBuilder builder, CertificateTestTimeProvider clock)
    {
        builder.Services.AddSingleton<TimeProvider>(clock);
        builder.Services.AddSingleton<LocalDeveloperCertificateStore>();
        return builder.Build();
    }
}