Featured image of post Better Azure Identity authentication support and performance during local development with .NET Aspire

Better Azure Identity authentication support and performance during local development with .NET Aspire

Automatically reduce DefaultAzureCredential execution time from 10 seconds to 1 and allow Azure CLI credentials to be used in containerized applications for all services orchestrated by .NET Aspire.

In Azure, accessing protected resources is often achieved via connection strings or access keys. While these provide a level of protection, they may not be sufficient for some organizations. Another option is to use Azure identities. Combined with role-based access control (RBAC), Azure identity authentication ensures that only specific individuals or systems can access particular resources. Azure identity is typically conveyed using an access token.

During local development of applications that consume Azure resources requiring Azure identity authentication, developers usually rely on their Azure identity, specifically their Microsoft account. Azure CLI is one way for developers to authenticate on their machines, and the Azure Identity client libraries can consume the local identity supplied by Azure CLI.

Authenticating with Azure CLI allows apps to consume the developer’s identity

However, there are two significant pain points when using Azure Identity in local development with Azure CLI. First, the DefaultAzureCredential included in Azure Identity can take up to 10 seconds to load the identity supplied by Azure CLI. This is particularly frustrating when frequently restarting the application, such as when using dotnet watch.

Using DefaultAzureCredential with Azure CLI during local development is slow

Second, developers cannot locally use a containerized application leveraging Azure Identity without modifying the image to include Azure CLI - a dependency exceeding 1 GB in size. Even if Azure CLI is included, developers must authenticate within the container by sharing a cache from the host machine, which is incompatible between Windows and Linux. I’ve also written about this issue and proposed a workaround, but the solution requires manual steps.

This is how a containerized .NET application crashes when an Azure identity isn’t available

In this article, I will introduce a solution that addresses both issues, leveraging the extensibility of .NET Aspire.

Reminder on how DefaultAzureCredential works in Azure Identity

In a previous post, I explained how the implementation of DefaultAzureCredential across different runtimes like .NET, Java, and Python is designed to locate an Azure identity by trying multiple mechanisms in the following order:

.NET DefaultAzureCredential credential pipeline implementation

It can take up to 10 seconds to detect the Azure identity provided by Azure CLI. The workaround I proposed is to prioritize Azure CLI using new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()). However, expecting developers to remember this trick isn’t realistic. Moreover, some libraries use DefaultAzureCredential by default without the developers even realizing it.

The consequence for a containerized application is usually a failure to authenticate with Azure in a local environment.

Hacking around DefaultAzureCredential

ManagedIdentityCredential is among the first credentials evaluated by DefaultAzureCredential. It is typically used by applications deployed on Azure. When inspecting part of its source code, we notice a part of it relies on the presence of two environment variables: IDENTITY_ENDPOINT and IMDS_ENDPOINT.

IDENTITY_ENDPOINT represents an HTTP endpoint that, if set, can be used to retrieve an access token. Knowing this, we can create our own HTTP endpoint that forwards an access token generated by Azure CLI. Let’s do this with an ASP.NET Core minimal API:

var credential = new AzureCliCredential();
var builder = WebApplication.CreateBuilder();
var app = builder.Build();

// You can find a more complete implementation of this proxy here:
// https://github.com/gsoft-inc/azure-cli-credentials-proxy/blob/1.1.0/Program.cs
app.MapGet("/token", async (HttpContext context, string resource) =>
{
    var accessToken = await credential.GetTokenAsync(new TokenRequestContext([resource]), context.RequestAborted);

    context.Response.Headers.ContentType = MediaTypeNames.Application.Json;

    return new Dictionary<string, string>
    {
        ["access_token"] = accessToken.Token,
        ["expiresOn"] = accessToken.ExpiresOn.ToString("O", CultureInfo.InvariantCulture),
        ["expires_on"] = accessToken.ExpiresOn.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
        ["tokenType"] = "Bearer",
        ["resource"] = resource
    };
});

app.Run();

If we set the IDENTITY_ENDPOINT and IMDS_ENDPOINT environment variables to the URL of our proxy, DefaultAzureCredential will be able to retrieve an access token generated by Azure CLI much faster. Additionally, this proxy would be accessible to containerized applications without requiring Azure CLI to be installed.

Designing a .NET Aspire custom resource for the proxy

We will now integrate this proxy into a .NET Aspire app host so that all services can benefit from accelerated Azure CLI authentication. Here is the high-level design:

  1. Host the proxy application in the .NET Aspire app host.
  2. Create a custom resource representing the proxy to publish logs and the proxy URL to the .NET Aspire dashboard.
  3. Synchronize the resource status with the proxy application state (starting, running, failed to start, etc.).
  4. Define the IDENTITY_ENDPOINT and IMDS_ENDPOINT environment variables for every service requiring the proxy.
  5. Ensure the proxy starts before the dependent services.
  6. Allow developers to customize the proxy resource name and specify a port. If omitted, an available port will be assigned automatically.

The implementation involves several advanced .NET Aspire concepts already introduced in previous articles:

  • Custom resource creation.
  • Fluent methods on IDistributedApplicationBuilder and IResourceBuilder<> that fits well with the official APIs.
  • IDistributedApplicationLifecycleHook implementation to orchestrate the ASP.NET Core proxy.
  • ResourceNotificationService to publish resource status changes and define visible properties in the dashboard.
  • ResourceLoggerService to report logs in the .NET Aspire dashboard.
  • Using CancellationTokenSource with IAsyncDisposable in the distributed lifecycle hook to stop the proxy gracefully.

An example usage of this implementation would be:

var builder = DistributedApplication.CreateBuilder(args);

var proxy = builder.AddAzureCliCredentialProxy(); // Declare the proxy resource

builder.AddProject<Projects.api>("api-dotnet")
    .WithReference(proxy) // Inject the proxy endpoints through environment variables
    .WaitFor(proxy); // Wait for the proxy to be ready first

builder.AddContainer("api-docker", "demo-api")
    .WithEndpoint(targetPort: 8080)
    .WithOtlpExporter()
    .WithReference(proxy) // Same thing as above
    .WaitFor(proxy);

builder.Build().Run();

Integrating the proxy with .NET Aspire

Open your .NET Aspire 9+ app host project and add the Azure Identity NuGet package to the project:

dotnet add package Azure.Identity

Then, create a new file AzureCliCredentialProxyResource.cs and add the following code:

using System.Globalization;
using System.Net;
using System.Net.Mime;
using System.Net.Sockets;
using Aspire.Hosting.Lifecycle;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting;

public sealed class AzureCliCredentialProxyResource(string name, int port) : Resource(name)
{
    internal string RootUrl => $"http://localhost:{port}";
    internal string TokenHostUrl => $"http://localhost:{port}/token";
    internal string TokenContainerUrl => $"http://host.docker.internal:{port}/token";
}

public static class AzureCliCredentialExtensions
{
    public static IResourceBuilder<AzureCliCredentialProxyResource> AddAzureCliCredentialProxy(this IDistributedApplicationBuilder builder, [ResourceName] string? name = null, int? port = null)
    {
        if (builder.Resources.OfType<AzureCliCredentialProxyResource>().Any())
        {
            throw new InvalidOperationException("Only one instance of Azure CLI credential proxy resource can be added.");
        }

        name ??= "az-cli-proxy";
        port ??= GetRandomAvailablePort();

        builder.Services.TryAddLifecycleHook<AzureCliCredentialProxyLifecycleHook>();
        return builder.AddResource(new AzureCliCredentialProxyResource(name, port.Value)).ExcludeFromManifest();
    }

    private static int GetRandomAvailablePort()
    {
        // https://stackoverflow.com/a/150974/825695
        using var listener = new TcpListener(IPAddress.Loopback, port: 0);
        listener.Start();
        return ((IPEndPoint)listener.LocalEndpoint).Port;
    }

    public static IResourceBuilder<T> WithReference<T>(this IResourceBuilder<T> resourceBuilder, IResourceBuilder<AzureCliCredentialProxyResource> proxyBuilder)
        where T : IResourceWithEnvironment
    {
        var tokenUrl = resourceBuilder.Resource is ContainerResource ? proxyBuilder.Resource.TokenContainerUrl : proxyBuilder.Resource.TokenHostUrl;

        return resourceBuilder
            .WithEnvironment("IDENTITY_ENDPOINT", tokenUrl)
            .WithEnvironment("IMDS_ENDPOINT", "dummy_required_value"); // Required but unused by ManagedIdentityCredential
    }

    private sealed class AzureCliCredentialProxyLifecycleHook(
        DistributedApplicationExecutionContext executionContext,
        ResourceNotificationService notificationService,
        ResourceLoggerService loggerService,
        ILogger<AzureCliCredentialProxyLifecycleHook> logger)
        : IDistributedApplicationLifecycleHook, IAsyncDisposable
    {
        private readonly AzureCliCredential _tokenCredential = new();
        private readonly CancellationTokenSource _cts = new();
        private WebApplication? _proxyApp;
        private Task? _proxyRunTask;

        public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
        {
            if (executionContext.IsPublishMode)
            {
                return Task.CompletedTask; // Run only during local development
            }

            var proxyResource = appModel.Resources.OfType<AzureCliCredentialProxyResource>().SingleOrDefault();
            if (proxyResource != null)
            {
                // Starting the proxy in the background allows to show the dashboard faster, but other resources have to wait for it
                this._proxyRunTask = this.RunProxyAsync(proxyResource, this._cts.Token);
            }

            return Task.CompletedTask;
        }

        private async Task RunProxyAsync(AzureCliCredentialProxyResource proxyResource, CancellationToken cancellationToken)
        {
            try
            {
                await notificationService.PublishUpdateAsync(proxyResource, snapshot => snapshot with
                {
                    ResourceType = "Proxy",
                    State = KnownResourceStates.Starting,
                    CreationTimeStamp = DateTime.Now,
                });

                var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
                {
                    EnvironmentName = Environments.Production
                });

                builder.Logging.ClearProviders();
                builder.Logging.AddProvider(new ResourceLoggerProvider(loggerService.GetLogger(proxyResource)));

                this._proxyApp = builder.Build();
                this._proxyApp.Urls.Add(proxyResource.RootUrl);

                // Can be consumed by ManagedIdentityCredential by specifying IDENTITY_ENDPOINT and IMDS_ENDPOINT environment variables to this action URL
                // See https://github.com/Azure/azure-sdk-for-net/blob/Azure.Identity_1.8.0/sdk/identity/Azure.Identity/src/AzureArcManagedIdentitySource.cs
                this._proxyApp.MapGet("/token", async (HttpContext context, string resource) =>
                {
                    var accessToken = await this._tokenCredential.GetTokenAsync(new TokenRequestContext([resource]), context.RequestAborted);

                    context.Response.Headers.ContentType = MediaTypeNames.Application.Json;

                    return new Dictionary<string, string>
                    {
                        ["access_token"] = accessToken.Token,
                        ["expiresOn"] = accessToken.ExpiresOn.ToString("O", CultureInfo.InvariantCulture),
                        ["expires_on"] = accessToken.ExpiresOn.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
                        ["tokenType"] = "Bearer",
                        ["resource"] = resource
                    };
                });

                await this._proxyApp.StartAsync(cancellationToken);

                await notificationService.PublishUpdateAsync(proxyResource, snapshot => snapshot with
                {
                    State = KnownResourceStates.Running,
                    StartTimeStamp = DateTime.Now,
                    Urls = [new UrlSnapshot("http", proxyResource.TokenHostUrl, IsInternal: false)]
                });
            }
            catch (OperationCanceledException)
            {
                // Application is shutting down
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "An error occurred while starting Azure CLI credential proxy");

                await notificationService.PublishUpdateAsync(proxyResource, snapshot => snapshot with
                {
                    State = KnownResourceStates.FailedToStart,
                    StopTimeStamp = DateTime.Now,
                });
            }
        }

        public async ValueTask DisposeAsync()
        {
            await this._cts.CancelAsync();

            if (this._proxyApp != null)
            {
                await this._proxyApp.DisposeAsync();
            }

            if (this._proxyRunTask != null)
            {
                try
                {
                    await this._proxyRunTask;
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "An error occurred while stopping Azure CLI credential proxy");
                }
            }

            this._cts.Dispose();
        }
    }

    private sealed class ResourceLoggerProvider(ILogger underlyingLogger) : ILoggerProvider, ILogger
    {
        public ILogger CreateLogger(string categoryName)
        {
            return this;
        }

        public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        {
            return underlyingLogger.BeginScope(state);
        }

        public bool IsEnabled(LogLevel logLevel)
        {
            return underlyingLogger.IsEnabled(logLevel);
        }

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        {
            underlyingLogger.Log(logLevel, eventId, state, exception, formatter);
        }

        public void Dispose()
        {
        }
    }
}

Results

Let’s create a .NET project meant to authenticate with Azure Storage using Azure Identity and orchestrated by .NET Aspire, based on the code provided earlier:

var tokenCredential = new DefaultAzureCredential();
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

var app = builder.Build();

app.MapGet("/test", async (CancellationToken cancellationToken) =>
{
    // Mimic a call to authenticate against Azure Storage using Azure identity / RBAC
    await tokenCredential.GetTokenAsync(new TokenRequestContext(["https://storage.azure.com"]), cancellationToken);
});

app.MapDefaultEndpoints();
app.Run();

Now, run the application with the proxy as a .NET project and also as a container:

Our service running with the proxy

When accessing the /test URL, we can observe Azure Identity communicating with the proxy and successfully retrieving an access token.

It takes ~1 second to get an access token with the proxy

It also works with Docker:

It takes ~1 second to get an access token in a container

The token is cached by Azure Identity, making subsequent calls to /test faster.

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee