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.
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
.
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.
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:
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:
- Host the proxy application in the .NET Aspire app host.
- Create a custom resource representing the proxy to publish logs and the proxy URL to the .NET Aspire dashboard.
- Synchronize the resource status with the proxy application state (starting, running, failed to start, etc.).
- Define the
IDENTITY_ENDPOINT
andIMDS_ENDPOINT
environment variables for every service requiring the proxy. - Ensure the proxy starts before the dependent services.
- 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
andIResourceBuilder<>
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
withIAsyncDisposable
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:
When accessing the /test
URL, we can observe Azure Identity communicating with the proxy and successfully retrieving an access token.
It also works with Docker:
The token is cached by Azure Identity, making subsequent calls to /test
faster.