File: AzureEntraIdAppRoleDeploymentTests.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 Xunit;
using static Aspire.Deployment.EndToEnd.Tests.Helpers.EntraDeploymentHelpers;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
public sealed class AzureEntraIdAppRoleDeploymentTests(ITestOutputHelper output)
{
    [Fact]
    public Task DeployDirectManagedIdentityRolesAndRedeploy() =>
        RunAsync(
            output,
            nameof(DeployDirectManagedIdentityRolesAndRedeploy),
            "entra-roles",
            suffix => [$"entra-api-{suffix}"],
            async (deployment, auto, counter, cancellationToken) =>
            {
                await auto.PrepareEnvironmentAsync(deployment.Workspace, counter);
                await auto.InstallAspireCliAsync(deployment.Strategy, counter, output);
                await auto.AspireNewAsync("EntraRoles", counter, template: AspireTemplate.EmptyAppHost);
                await auto.RunCommandAsync("cd EntraRoles", 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 Api --no-restore && dotnet new web -n Caller --no-restore", counter, TimeSpan.FromMinutes(2));
                await auto.RunCommandAsync("dotnet add Api/Api.csproj package Microsoft.Identity.Web --version 4.16.0 && dotnet add Caller/Caller.csproj package Azure.Identity --version 1.21.0", counter, TimeSpan.FromMinutes(3));
                var projectDirectory = Path.Combine(deployment.Workspace.Path, "EntraRoles");
                WriteApplication(projectDirectory);
                await deployment.ConfigureAzureEnvironmentAsync(auto, counter);
 
                deployment.DeploymentAttempted = true;
                await DeployAsync(auto, counter);
                var firstAssignments = await VerifyDeploymentAsync(deployment, cancellationToken);
 
                // Change a Graph property so that a second deployment really reapplies the assignment resources,
                // rather than merely returning cached outputs from a template whose checksum is unchanged.
                DotnetProjectDeploymentHelpers.ReplaceInFile(
                    Path.Combine(projectDirectory, "apphost.cs"), "Read orders.", "Read all orders.");
                await DeployAsync(auto, counter);
                Assert.Equal(firstAssignments, await VerifyDeploymentAsync(deployment, cancellationToken));
 
                // Incremental Graph provisioning retains omitted assignments. Prove that removing the calls does
                // not silently imply revocation; explicit directory cleanup is required.
                var appHost = Path.Combine(projectDirectory, "apphost.cs");
                DotnetProjectDeploymentHelpers.ReplaceInFile(appHost,
                    "entra.PublishWithAppRoleAssignment(assignedIdentity, \"Orders.Read\");", string.Empty);
                DotnetProjectDeploymentHelpers.ReplaceInFile(appHost,
                    "entra.PublishWithAppRoleAssignment(compute, \"Orders.Read\");", string.Empty);
                await DeployAsync(auto, counter);
                Assert.Equal(firstAssignments, await VerifyDeploymentAsync(deployment, cancellationToken));
            });
 
    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)}}
 
            using Aspire.Hosting.Azure;
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddAzureContainerAppEnvironment("env");
            var assignedIdentity = builder.AddAzureUserAssignedIdentity("assigned-identity");
            var unassignedIdentity = builder.AddAzureUserAssignedIdentity("unassigned-identity");
            var computeIdentity = builder.AddAzureUserAssignedIdentity("compute-identity");
            var entra = builder.AddEntraIdApplication("entra-api")
                .WithApplicationIdUri()
                .WithAllowWebApiToBeAuthorizedByACL()
                .WithAppRole(new EntraIdAppRole("Orders.Read", "Read orders", "Read orders.", ["Application"]));
            var api = builder.AddDotnetProject("api", "Api/Api.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithReference(entra);
            var assigned = builder.AddDotnetProject("assigned", "Caller/Caller.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithExternalHttpEndpoints()
                .WithAzureUserAssignedIdentity(assignedIdentity)
                .WithEnvironment("TargetUrl", api.GetEndpoint("http"))
                .WithEnvironment("TargetClientId", entra.Resource.ClientIdOutputReference)
                .WithEnvironment("ExpectedPrincipalId", assignedIdentity.Resource.PrincipalId)
                .WithEnvironment("ExpectedAccess", "assigned");
            var compute = builder.AddDotnetProject("compute", "Caller/Caller.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithExternalHttpEndpoints()
                .WithAzureUserAssignedIdentity(computeIdentity)
                .WithEnvironment("TargetUrl", api.GetEndpoint("http"))
                .WithEnvironment("TargetClientId", entra.Resource.ClientIdOutputReference)
                .WithEnvironment("ExpectedPrincipalId", computeIdentity.Resource.PrincipalId)
                .WithEnvironment("ExpectedAccess", "assigned");
            builder.AddDotnetProject("unassigned", "Caller/Caller.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithExternalHttpEndpoints()
                .WithAzureUserAssignedIdentity(unassignedIdentity)
                .WithEnvironment("TargetUrl", api.GetEndpoint("http"))
                .WithEnvironment("TargetClientId", entra.Resource.ClientIdOutputReference)
                .WithEnvironment("ExpectedAccess", "unassigned");
            entra.PublishWithAppRoleAssignment(assignedIdentity, "Orders.Read");
            entra.PublishWithAppRoleAssignment(compute, "Orders.Read");
            builder.Build().Run();
            """);
 
        File.Delete(Path.Combine(projectDirectory, "Api", "Properties", "launchSettings.json"));
        File.Delete(Path.Combine(projectDirectory, "Caller", "Properties", "launchSettings.json"));
        File.WriteAllText(Path.Combine(projectDirectory, "Api", "Program.cs"), """
            using System.Security.Claims;
            using Microsoft.AspNetCore.Authentication.JwtBearer;
            using Microsoft.Identity.Web;
 
            var builder = WebApplication.CreateBuilder(args);
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
            builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.MapInboundClaims = false;
                options.TokenValidationParameters.RoleClaimType = "roles";
            });
            builder.Services.AddAuthorization(options =>
                options.AddPolicy("orders", policy => policy.RequireAuthenticatedUser().RequireRole("Orders.Read")));
            var app = builder.Build();
            app.UseAuthentication();
            app.UseAuthorization();
            app.MapGet("/orders", (ClaimsPrincipal principal) => new
            {
                Audience = principal.FindFirstValue("aud"),
                PrincipalId = principal.FindFirstValue("oid"),
                ClientId = principal.FindFirstValue("azp"),
                Roles = principal.FindAll("roles").Select(claim => claim.Value).ToArray()
            }).RequireAuthorization("orders");
            app.Run();
            """);
        File.WriteAllText(Path.Combine(projectDirectory, "Caller", "Program.cs"), """
            using System.Net;
            using System.Net.Http.Headers;
            using System.Text.Json;
            using Azure.Core;
            using Azure.Identity;
 
            var builder = WebApplication.CreateBuilder(args);
            var configuration = builder.Configuration;
            var identityClientId = configuration["AZURE_CLIENT_ID"]
                ?? throw new InvalidOperationException("Managed identity client ID is missing.");
            var apiClientId = configuration["TargetClientId"]
                ?? throw new InvalidOperationException("Target API client ID is missing.");
            var targetUrl = configuration["TargetUrl"]
                ?? throw new InvalidOperationException("Target API URL is missing.");
            var assigned = configuration["ExpectedAccess"] == "assigned";
            var credential = new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(identityClientId));
            builder.Services.AddHttpClient();
            var app = builder.Build();
            app.MapGet("/", async (IHttpClientFactory clients, CancellationToken cancellationToken) =>
            {
                var client = clients.CreateClient();
                var token = await credential.GetTokenAsync(
                    new TokenRequestContext([$"api://{apiClientId}/.default"]), cancellationToken);
                using var request = new HttpRequestMessage(HttpMethod.Get, $"{targetUrl}/orders");
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
                using var response = await client.SendAsync(request, cancellationToken);
                if (!assigned)
                {
                    if (response.StatusCode != HttpStatusCode.Forbidden)
                    {
                        throw new InvalidOperationException($"Unassigned identity: expected 403, got {(int)response.StatusCode}.");
                    }
 
                    return "unassigned-denied";
                }
 
                response.EnsureSuccessStatusCode();
                using var claims = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
                var payload = claims.RootElement;
                if (payload.GetProperty("audience").GetString() != apiClientId ||
                    payload.GetProperty("principalId").GetString() != configuration["ExpectedPrincipalId"] ||
                    payload.GetProperty("clientId").GetString() != identityClientId ||
                    !payload.GetProperty("roles").EnumerateArray().Select(role => role.GetString()).SequenceEqual(["Orders.Read"]))
                {
                    throw new InvalidOperationException("The API did not validate the expected UAMI audience, object ID, client ID and role.");
                }
 
                using var anonymous = await client.GetAsync($"{targetUrl}/orders", cancellationToken);
                if (anonymous.StatusCode != HttpStatusCode.Unauthorized)
                {
                    throw new InvalidOperationException($"Anonymous request: expected 401, got {(int)anonymous.StatusCode}.");
                }
 
                var wrongAudience = await credential.GetTokenAsync(
                    new TokenRequestContext(["https://management.azure.com/.default"]), cancellationToken);
                using var wrongRequest = new HttpRequestMessage(HttpMethod.Get, $"{targetUrl}/orders");
                wrongRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudience.Token);
                using var wrongResponse = await client.SendAsync(wrongRequest, cancellationToken);
                if (wrongResponse.StatusCode != HttpStatusCode.Unauthorized)
                {
                    throw new InvalidOperationException($"Wrong-audience request: expected 401, got {(int)wrongResponse.StatusCode}.");
                }
 
                // Return only the verdict, never a bearer token. Claims are checked after API signature validation.
                return "roles-verified";
            });
            app.Run();
            """);
    }
 
    private static async Task<string[]> VerifyDeploymentAsync(EntraDeploymentContext deployment, CancellationToken cancellationToken)
    {
        var application = await WaitForResourceAsync(
            deployment.Client, deployment.Credential, GetApplicationUrl(deployment.RegistrationNames[0]),
            resource => resource.GetProperty("appRoles").GetArrayLength() == 1, cancellationToken);
        var applicationId = application.GetProperty("appId").GetGuid();
        var role = Assert.Single(application.GetProperty("appRoles").EnumerateArray());
        Assert.Equal("Orders.Read", role.GetProperty("value").GetString());
        Assert.Equal(["Application"], role.GetProperty("allowedMemberTypes").EnumerateArray().Select(value => value.GetString()));
        using var federated = await ReadResourceAsync(
            deployment.Client, deployment.Credential,
            $"https://graph.microsoft.com/v1.0/applications/{application.GetProperty("id").GetGuid():D}/federatedIdentityCredentials",
            cancellationToken);
        Assert.NotNull(federated);
        Assert.Empty(federated.RootElement.GetProperty("value").EnumerateArray());
        var principal = await WaitForResourceAsync(
            deployment.Client, deployment.Credential,
            $"https://graph.microsoft.com/v1.0/servicePrincipals(appId='{applicationId:D}')",
            resource => resource.GetProperty("appRoles").EnumerateArray().Any(r => r.GetProperty("id").GetGuid() == role.GetProperty("id").GetGuid()),
            cancellationToken);
        var principalId = principal.GetProperty("id").GetGuid();
        var assignments = await WaitForResourceAsync(
            deployment.Client, deployment.Credential,
            $"https://graph.microsoft.com/v1.0/servicePrincipals/{principalId:D}/appRoleAssignedTo",
            resource => resource.GetProperty("value").GetArrayLength() == 2, cancellationToken);
 
        var resourceGroup = $"https://management.azure.com/subscriptions/{deployment.SubscriptionId}/resourceGroups/{deployment.ResourceGroupName}";
        foreach (var name in new[] { "assigned", "compute", "unassigned" })
        {
            using var containerApp = await ReadResourceAsync(
                deployment.Client, deployment.Credential,
                $"{resourceGroup}/providers/Microsoft.App/containerApps/{name}?api-version=2026-03-02-preview", cancellationToken);
            Assert.NotNull(containerApp);
            var container = Assert.Single(containerApp.RootElement.GetProperty("properties").GetProperty("template").GetProperty("containers").EnumerateArray());
            var clientId = Assert.Single(container.GetProperty("env").EnumerateArray(),
                variable => variable.GetProperty("name").GetString() == "AZURE_CLIENT_ID").GetProperty("value").GetString();
            // ACA also attaches the environment's registry-pull identity. Select the workload identity used for tokens.
            var identity = Assert.Single(containerApp.RootElement.GetProperty("identity").GetProperty("userAssignedIdentities").EnumerateObject(),
                identity => identity.Value.GetProperty("clientId").GetString() == clientId).Value;
            var identityPrincipalId = identity.GetProperty("principalId").GetGuid();
            var matching = assignments.GetProperty("value").EnumerateArray()
                .Where(a => a.GetProperty("principalId").GetGuid() == identityPrincipalId).ToArray();
            if (name == "unassigned")
            {
                Assert.Empty(matching);
            }
            else
            {
                var assignment = Assert.Single(matching);
                Assert.Equal(role.GetProperty("id").GetGuid(), assignment.GetProperty("appRoleId").GetGuid());
                Assert.Equal(principalId, assignment.GetProperty("resourceId").GetGuid());
            }
 
            var host = containerApp.RootElement.GetProperty("properties").GetProperty("configuration").GetProperty("ingress").GetProperty("fqdn").GetString();
            var url = $"https://{host}";
            await DotnetProjectDeploymentHelpers.VerifyResponseAsync(
                url, name == "unassigned" ? "unassigned-denied" : "roles-verified", cancellationToken);
            deployment.DeploymentUrls[name] = url;
        }
 
        return assignments.GetProperty("value").EnumerateArray()
            .Select(a => $"{a.GetProperty("principalId").GetGuid():D}/{a.GetProperty("appRoleId").GetGuid():D}/{a.GetProperty("id").GetString()}")
            .Order(StringComparer.Ordinal).ToArray();
    }
}