File: AzureEntraIdStarterDeploymentTests.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 AzureEntraIdStarterDeploymentTests(ITestOutputHelper output)
{
    /// <summary>
    /// Deploys the Aspire starter application with Entra added: a protected API and a web front end that declares permission to the
    /// API's delegated scope. The scope is declared, not granted: the scenario adds neither admin consent nor pre-authorization. The
    /// sign-in flow can't be automated, so this verifies the registrations, the declared permission between them and the configuration
    /// that each deployed app receives. The publish snapshot test in Aspire.Hosting.Azure.EntraId.Tests covers the same AppHost.
    /// </summary>
    [Fact]
    public Task DeployStarterWithEntraApiAndWebRegistrations() =>
        RunAsync(
            output,
            nameof(DeployStarterWithEntraApiAndWebRegistrations),
            "entra-starter",
            // The tenant resource suffix is appended to each registration's name, which is the uniqueName that Graph Bicep upserts by.
            suffix => [$"apiservice-entra-{suffix}", $"webfrontend-entra-{suffix}"],
            async (deployment, auto, counter, cancellationToken) =>
            {
                const string projectName = "EntraStarter";
                await auto.PrepareEnvironmentAsync(deployment.Workspace, counter);
                await auto.InstallAspireCliAsync(deployment.Strategy, counter, output);
                await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
                await auto.RunCommandAsync($"cd {projectName}", counter);
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers");
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.EntraId");
                WriteStarterWithEntra(Path.Combine(deployment.Workspace.Path, projectName), projectName);
                await auto.RunCommandAsync($"cd {projectName}.AppHost", counter);
                await deployment.ConfigureAzureEnvironmentAsync(auto, counter);
 
                deployment.DeploymentAttempted = true;
                await DeployAsync(auto, counter);
                await VerifyStarterDeploymentAsync(deployment, cancellationToken);
            });
 
    private static void WriteStarterWithEntra(string solutionDirectory, string projectName)
    {
        var appHostPath = Path.Combine(solutionDirectory, $"{projectName}.AppHost", "AppHost.cs");
        File.WriteAllText(appHostPath, $$"""
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddAzureContainerAppEnvironment("env");
 
            var apiEntra = builder.AddEntraIdApplication("apiservice-entra")
                .WithScope("access_as_user", "Access the weather API on behalf of the signed-in user");
 
            var apiService = builder.AddDotnetProject("apiservice", "../{{projectName}}.ApiService/{{projectName}}.ApiService.csproj")
                .WithReference(apiEntra)
                .WithHttpHealthCheck("/health");
 
            var webEntra = builder.AddEntraIdApplication("webfrontend-entra")
                .WithApiPermissions(apiEntra, "access_as_user")
                .PublishWithFederatedManagedIdentityCredential();
 
            var webFrontend = builder.AddDotnetProject("webfrontend", "../{{projectName}}.Web/{{projectName}}.Web.csproj")
                .WithExternalHttpEndpoints()
                .WithHttpHealthCheck("/health")
                .WithReference(webEntra, connectionName: null, downstreamApisPrefix: "MyDownstreamApis")
                .WithReference(apiService)
                .WaitFor(apiService);
 
            webEntra.WithRedirectUris(webFrontend.GetEndpoint("https"), "/signin-oidc", "/signout-callback-oidc");
 
            builder.Build().Run();
            """);
 
        // Neither app signs anyone in. Each reports the Entra settings that reached its configuration so the test can
        // prove the environment variables of the container app are read by the running application.
        DotnetProjectDeploymentHelpers.ReplaceInFile(Path.Combine(solutionDirectory, $"{projectName}.ApiService", "Program.cs"),
            "app.Run();", """
            app.MapGet("/entra-config", (IConfiguration configuration) =>
                $"{configuration["AzureAd:ClientId"]}|{configuration["AzureAd:Scopes"]}");
            app.Run();
            """);
        DotnetProjectDeploymentHelpers.ReplaceInFile(Path.Combine(solutionDirectory, $"{projectName}.Web", "Program.cs"),
            "app.Run();", """
            app.MapGet("/entra-config", async (IConfiguration configuration, IHttpClientFactory clients, CancellationToken cancellationToken) =>
                $"{configuration["AzureAd:ClientId"]}|{configuration["MyDownstreamApis:apiservice:Scopes:0"]}|" +
                await clients.CreateClient().GetStringAsync("https+http://apiservice/entra-config", cancellationToken));
            app.Run();
            """);
    }
 
    private static async Task VerifyStarterDeploymentAsync(EntraDeploymentContext deployment, CancellationToken cancellationToken)
    {
        var client = deployment.Client;
        var credential = deployment.Credential;
        var tenantId = deployment.TenantId;
        var apiUniqueName = deployment.RegistrationNames[0];
        var webUniqueName = deployment.RegistrationNames[1];
 
        var apiApplication = await WaitForResourceAsync(client, credential,
            GetApplicationUrl(apiUniqueName),
            resource => resource.GetProperty("identifierUris").GetArrayLength() == 1
                && resource.GetProperty("api").GetProperty("oauth2PermissionScopes").GetArrayLength() == 1,
            cancellationToken);
        var apiClientId = apiApplication.GetProperty("appId").GetGuid();
        // The Application ID URI defaults to api://{clientId}, which can only be set once Graph has assigned the client ID.
        Assert.Equal($"api://{apiClientId:D}", Assert.Single(apiApplication.GetProperty("identifierUris").EnumerateArray()).GetString());
        var scope = Assert.Single(apiApplication.GetProperty("api").GetProperty("oauth2PermissionScopes").EnumerateArray());
        Assert.Equal("access_as_user", scope.GetProperty("value").GetString());
        Assert.Equal("User", scope.GetProperty("type").GetString());
        Assert.True(scope.GetProperty("isEnabled").GetBoolean());
        var scopeId = scope.GetProperty("id").GetGuid();
        Assert.Equal(0, apiApplication.GetProperty("api").GetProperty("preAuthorizedApplications").GetArrayLength());
 
        // The API is deployed to its own container app, whose registration is only an identity: it has no redirect URIs.
        Assert.Equal(0, apiApplication.GetProperty("web").GetProperty("redirectUris").GetArrayLength());
 
        var webApplication = await WaitForResourceAsync(client, credential,
            GetApplicationUrl(webUniqueName),
            resource => resource.GetProperty("web").GetProperty("redirectUris").GetArrayLength() == 2
                && resource.GetProperty("requiredResourceAccess").GetArrayLength() == 1,
            cancellationToken);
        var webClientId = webApplication.GetProperty("appId").GetGuid();
        var webObjectId = webApplication.GetProperty("id").GetGuid();
        Assert.Equal(webUniqueName, webApplication.GetProperty("displayName").GetString());
        Assert.Equal(0, webApplication.GetProperty("identifierUris").GetArrayLength());
 
        // Calling the API on the user's behalf requires the delegated scope that the API exposes, identified by the ID Graph stores.
        var required = Assert.Single(webApplication.GetProperty("requiredResourceAccess").EnumerateArray());
        Assert.Equal(apiClientId, required.GetProperty("resourceAppId").GetGuid());
        var access = Assert.Single(required.GetProperty("resourceAccess").EnumerateArray());
        Assert.Equal(scopeId, access.GetProperty("id").GetGuid());
        Assert.Equal("Scope", access.GetProperty("type").GetString());
 
        foreach (var clientId in new[] { apiClientId, webClientId })
        {
            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);
        }
 
        var resourceGroupUrl = $"https://management.azure.com/subscriptions/{deployment.SubscriptionId}/resourceGroups/{deployment.ResourceGroupName}";
        using var identities = await ReadResourceAsync(client, credential,
            $"{resourceGroupUrl}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2023-01-31", cancellationToken);
        Assert.NotNull(identities);
        var identity = Assert.Single(identities.RootElement.GetProperty("value").EnumerateArray(),
            entry => entry.GetProperty("name").GetString()!.StartsWith("webfrontend_identity", StringComparison.Ordinal));
        var identityClientId = identity.GetProperty("properties").GetProperty("clientId").GetGuid();
        var identityPrincipalId = identity.GetProperty("properties").GetProperty("principalId").GetGuid();
 
        // The web app authenticates to Entra without a secret: a federated credential trusts the managed identity of its container app.
        using var credentials = await ReadResourceAsync(client, credential,
            $"https://graph.microsoft.com/v1.0/applications/{webObjectId:D}/federatedIdentityCredentials", cancellationToken);
        Assert.NotNull(credentials);
        var federated = Assert.Single(credentials.RootElement.GetProperty("value").EnumerateArray());
        Assert.Equal($"https://login.microsoftonline.com/{tenantId:D}/v2.0", federated.GetProperty("issuer").GetString());
        Assert.Equal(identityPrincipalId.ToString("D"), federated.GetProperty("subject").GetString());
        Assert.Equal("api://AzureADTokenExchange", Assert.Single(federated.GetProperty("audiences").EnumerateArray()).GetString());
 
        var (webFqdn, webEnvironment) = await GetContainerAppAsync(client, credential, resourceGroupUrl, "webfrontend", cancellationToken);
        Assert.Equal(UriHostNameType.Dns, Uri.CheckHostName(webFqdn));
        Assert.Equal(
            [$"https://{webFqdn}/signin-oidc", $"https://{webFqdn}/signout-callback-oidc"],
            webApplication.GetProperty("web").GetProperty("redirectUris").EnumerateArray().Select(uri => uri.GetString()!).Order(StringComparer.Ordinal));
 
        var apiScope = $"api://{apiClientId:D}/access_as_user";
        Assert.Equal(
            new Dictionary<string, string>
            {
                ["AzureAd__Instance"] = "https://login.microsoftonline.com/",
                ["AzureAd__TenantId"] = tenantId.ToString("D"),
                ["AzureAd__ClientId"] = webClientId.ToString("D"),
                ["AzureAd__ClientCredentials__0__SourceType"] = "SignedAssertionFromManagedIdentity",
                ["AzureAd__ClientCredentials__0__ManagedIdentityClientId"] = identityClientId.ToString("D"),
                ["MyDownstreamApis__apiservice__Scopes__0"] = apiScope
            },
            webEnvironment.Where(entry => entry.Key.StartsWith("AzureAd__", StringComparison.Ordinal) || entry.Key.StartsWith("MyDownstreamApis__", StringComparison.Ordinal))
                .ToDictionary(entry => entry.Key, entry => entry.Value));
 
        var (_, apiEnvironment) = await GetContainerAppAsync(client, credential, resourceGroupUrl, "apiservice", cancellationToken);
        Assert.Equal(
            new Dictionary<string, string>
            {
                ["AzureAd__Instance"] = "https://login.microsoftonline.com/",
                ["AzureAd__TenantId"] = tenantId.ToString("D"),
                ["AzureAd__ClientId"] = apiClientId.ToString("D"),
                ["AzureAd__Scopes"] = "access_as_user"
            },
            apiEnvironment.Where(entry => entry.Key.StartsWith("AzureAd__", StringComparison.Ordinal))
                .ToDictionary(entry => entry.Key, entry => entry.Value));
 
        // The running web app reads its own settings and the API's, which it reaches through service discovery.
        var url = $"https://{webFqdn}";
        await DotnetProjectDeploymentHelpers.VerifyResponseAsync(
            $"{url}/entra-config", $"{webClientId:D}|{apiScope}|{apiClientId:D}|access_as_user", cancellationToken);
        deployment.DeploymentUrls["webfrontend"] = url;
    }
 
    private static async Task<(string Fqdn, Dictionary<string, string> Environment)> GetContainerAppAsync(
        HttpClient client, TokenCredential credential, string resourceGroupUrl, string name, CancellationToken cancellationToken)
    {
        using var app = await ReadResourceAsync(client, credential,
            $"{resourceGroupUrl}/providers/Microsoft.App/containerApps/{name}?api-version=2026-03-02-preview", cancellationToken);
        Assert.NotNull(app);
        var properties = app.RootElement.GetProperty("properties");
        var container = Assert.Single(properties.GetProperty("template").GetProperty("containers").EnumerateArray());
        var environment = container.GetProperty("env").EnumerateArray()
            .Where(entry => entry.TryGetProperty("value", out _))
            .ToDictionary(entry => entry.GetProperty("name").GetString()!, entry => entry.GetProperty("value").GetString()!);
        return (properties.GetProperty("configuration").GetProperty("ingress").GetProperty("fqdn").GetString()!, environment);
    }
}