File: AzureEntraIdDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Azure.Core;
using Xunit;
using static Aspire.Deployment.EndToEnd.Tests.Helpers.EntraDeploymentHelpers;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
public sealed class AzureEntraIdDeploymentTests(ITestOutputHelper output)
{
    private const string RedirectUri = "https://example.invalid/signin-oidc";
    private const string UpdatedRedirectUri = "https://example.invalid/updated-signin-oidc";
    private const string ParameterRedirectUris = "https://example.invalid/signout-oidc,https://example.invalid/callback";
    private const string UpdatedParameterRedirectUris = "https://example.invalid/updated-callback";
    private const string EndpointCallbackPath = "/endpoint-callback";
 
    [Fact]
    public Task DeployEntraRegistrationAndRedeployWithStableIds() =>
        RunAsync(
            output,
            nameof(DeployEntraRegistrationAndRedeployWithStableIds),
            "entra",
            suffix => [$"identity-{suffix}"],
            async (deployment, auto, counter, cancellationToken) =>
            {
                var registrationName = deployment.RegistrationNames[0];
                await auto.PrepareEnvironmentAsync(deployment.Workspace, counter);
                await auto.InstallAspireCliAsync(deployment.Strategy, counter, output);
                await auto.AspireNewAsync("EntraDeployment", counter, template: AspireTemplate.EmptyAppHost);
                await auto.RunCommandAsync("cd EntraDeployment", counter);
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers");
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.EntraId");
                await auto.RunCommandAsync("dotnet new web -n Consumer --no-restore", counter, TimeSpan.FromMinutes(2));
                var projectDirectory = Path.Combine(deployment.Workspace.Path, "EntraDeployment");
                WriteApplication(projectDirectory);
 
                await deployment.ConfigureAzureEnvironmentAsync(auto, counter);
 
                await auto.RunCommandAsync(
                    $"export Parameters__redirecturis={AspireCliShellCommandHelpers.QuoteBashArg(ParameterRedirectUris)}", counter);
 
                deployment.DeploymentAttempted = true;
                await DeployAsync(auto, counter);
                var firstIds = await VerifyDeploymentAsync(
                    deployment.Client, deployment.Credential, deployment.SubscriptionId, deployment.ResourceGroupName, registrationName, deployment.TenantId, [RedirectUri, .. ParameterRedirectUris.Split(',')], deployment.DeploymentUrls, cancellationToken);
 
                // Change actual Graph settings, including the deployment-supplied parameter, so the second deployment
                // must update the registration rather than only demonstrating that a cached deployment preserves its IDs.
                await auto.RunCommandAsync(
                    $"export Parameters__redirecturis={AspireCliShellCommandHelpers.QuoteBashArg(UpdatedParameterRedirectUris)}", counter);
                DotnetProjectDeploymentHelpers.ReplaceInFile(
                    Path.Combine(projectDirectory, "apphost.cs"), RedirectUri, UpdatedRedirectUri);
                await DeployAsync(auto, counter);
                var secondIds = await VerifyDeploymentAsync(
                    deployment.Client, deployment.Credential, deployment.SubscriptionId, deployment.ResourceGroupName, registrationName, deployment.TenantId, [UpdatedRedirectUri, .. UpdatedParameterRedirectUris.Split(',')], deployment.DeploymentUrls, cancellationToken);
                Assert.Equal(firstIds, secondIds);
            });
 
    private static void WriteApplication(string projectDirectory)
    {
        var appHostPath = Path.Combine(projectDirectory, "apphost.cs");
        var directives = File.ReadLines(appHostPath).Where(line => line.StartsWith("#:", StringComparison.Ordinal)).ToArray();
        Assert.NotEmpty(directives);
        File.WriteAllText(appHostPath, $$"""
            {{string.Join(Environment.NewLine, directives)}}
 
            #pragma warning disable ASPIREACAEXPRESS001
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddAzureContainerAppEnvironment("env").AsExpress();
            var identity = builder.AddEntraIdApplication("identity")
                .WithRedirectUris("{{RedirectUri}}")
                .WithRedirectUris(builder.AddParameter("redirecturis"));
 
            var consumer = builder.AddDotnetProject("consumer", "Consumer/Consumer.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithExternalHttpEndpoints()
                .WithReference(identity);
 
            // Derived from the consumer's deployed host name, which is only known from the environment outputs.
            identity.WithRedirectUris(consumer.GetEndpoint("http"), "{{EndpointCallbackPath}}");
 
            builder.Build().Run();
            """);
        File.Delete(Path.Combine(projectDirectory, "Consumer", "Properties", "launchSettings.json"));
        File.WriteAllText(Path.Combine(projectDirectory, "Consumer", "Program.cs"), """
            var builder = WebApplication.CreateBuilder(args);
            var tenantId = builder.Configuration["AzureAd:TenantId"]
                ?? throw new InvalidOperationException("Entra tenant ID is missing.");
            var clientId = builder.Configuration["AzureAd:ClientId"]
                ?? throw new InvalidOperationException("Entra client ID is missing.");
            var app = builder.Build();
            app.MapGet("/", () => $"{tenantId}/{clientId}");
            app.Run();
            """);
    }
 
    private static async Task<(Guid ObjectId, Guid ClientId, Guid ServicePrincipalId)> VerifyDeploymentAsync(
        HttpClient client, TokenCredential credential, string subscriptionId, string resourceGroupName,
        string registrationName, Guid tenantId, string[] redirectUris, Dictionary<string, string> deploymentUrls,
        CancellationToken cancellationToken)
    {
        var application = await WaitForResourceAsync(client, credential,
            $"https://graph.microsoft.com/v1.0/applications(uniqueName='{registrationName}')",
            resource =>
            {
                // Graph can reorder redirect URIs. The endpoint-derived URI's host name is verified below.
                var registered = resource.GetProperty("web").GetProperty("redirectUris").EnumerateArray()
                    .Select(uri => uri.GetString()).ToArray();
                return registered.Length == redirectUris.Length + 1
                    && redirectUris.All(uri => registered.Contains(uri, StringComparer.Ordinal))
                    && registered.Any(uri => uri is not null && uri.StartsWith("https://", StringComparison.Ordinal) && uri.EndsWith(EndpointCallbackPath, StringComparison.Ordinal));
            },
            cancellationToken);
        Assert.Equal(registrationName, application.GetProperty("displayName").GetString());
        Assert.Equal(registrationName, application.GetProperty("uniqueName").GetString());
        Assert.Equal("AzureADMyOrg", application.GetProperty("signInAudience").GetString());
        var clientId = application.GetProperty("appId").GetGuid();
        var principal = await WaitForResourceAsync(client, credential,
            $"https://graph.microsoft.com/v1.0/servicePrincipals(appId='{clientId:D}')",
            resource => resource.GetProperty("appId").GetGuid() == clientId
                && resource.GetProperty("tags").EnumerateArray()
                    .Any(tag => tag.GetString() == "WindowsAzureActiveDirectoryIntegratedApp"),
            cancellationToken);
 
        using var consumer = await ReadResourceAsync(client, credential,
            $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.App/containerApps/consumer?api-version=2026-03-02-preview",
            cancellationToken);
        Assert.NotNull(consumer);
        var properties = consumer.RootElement.GetProperty("properties");
        var container = Assert.Single(properties.GetProperty("template").GetProperty("containers").EnumerateArray());
        var environment = container.GetProperty("env").EnumerateArray().ToArray();
        Assert.Equal(tenantId.ToString("D"), Assert.Single(environment,
            entry => entry.GetProperty("name").GetString() == "AzureAd__TenantId").GetProperty("value").GetString());
        Assert.Equal(clientId.ToString("D"), Assert.Single(environment,
            entry => entry.GetProperty("name").GetString() == "AzureAd__ClientId").GetProperty("value").GetString());
        var ingress = properties.GetProperty("configuration").GetProperty("ingress");
        Assert.True(ingress.GetProperty("external").GetBoolean());
        Assert.False(ingress.GetProperty("allowInsecure").GetBoolean());
        var hostname = ingress.GetProperty("fqdn").GetString();
        Assert.Equal(UriHostNameType.Dns, Uri.CheckHostName(hostname));
        var registeredUris = application.GetProperty("web").GetProperty("redirectUris").EnumerateArray().Select(uri => uri.GetString()).ToArray();
        Assert.Equal(
            redirectUris.Append($"https://{hostname}{EndpointCallbackPath}").Order(StringComparer.Ordinal),
            registeredUris.Order(StringComparer.Ordinal));
        var url = $"https://{hostname}";
        await DotnetProjectDeploymentHelpers.VerifyResponseAsync(url, $"{tenantId:D}/{clientId:D}", cancellationToken);
        deploymentUrls["Consumer"] = url;
        return (application.GetProperty("id").GetGuid(), clientId, principal.GetProperty("id").GetGuid());
    }
}