Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5f6bf0e
Initial plan
Copilot Nov 2, 2025
85a3e07
Refactor ACR authentication to use Azure SDK TokenCredential
Copilot Nov 2, 2025
2af559c
Fix test project builds - add InternalsVisibleTo and remove duplicate…
Copilot Nov 2, 2025
6b7230e
Address code review feedback - remove duplicate login implementation
Copilot Nov 2, 2025
f24e54f
Address security concerns from code review
Copilot Nov 2, 2025
2a80106
Make IContainerRuntime public and remove unnecessary InternalsVisibleTo
Copilot Nov 2, 2025
5deca0e
Make IContainerRuntime experimental and use shared source for process…
Copilot Nov 2, 2025
33c8aa7
Update Azure deployer tests to mock IContainerRuntime LoginToRegistry…
Copilot Nov 2, 2025
64c1053
Complete Azure deployer test updates - all tests now mock IContainerR…
Copilot Nov 2, 2025
8db746b
Fix DeployAsync_WithMultipleComputeEnvironments_Works test - pass fak…
Copilot Nov 2, 2025
4c1f43c
Fix ACR authentication scope - use .net instead of .com
Copilot Nov 2, 2025
3d4e7c3
Improve ACR login debugging and stdin handling
Copilot Nov 2, 2025
b26aafc
Fix ACR authentication to use correct OAuth2 token exchange
Copilot Nov 2, 2025
dacdad9
Refactor ACR authentication to use IAcrLoginService with IHttpClientF…
Copilot Nov 2, 2025
29e487a
Move IContainerRuntime to AcrLoginService constructor dependency
Copilot Nov 3, 2025
f4e87fd
Fix test failures - add FakeAcrLoginService to mock ACR OAuth2 calls
Copilot Nov 3, 2025
db3f0bb
Make tenantId required in IAcrLoginService.LoginAsync
Copilot Nov 3, 2025
6a317ed
Fix test failures - FakeAcrLoginService needs IContainerRuntime const…
Copilot Nov 3, 2025
db90153
Address PR feedback - optimize HTTP client usage and JSON deserializa…
Copilot Nov 4, 2025
2de6948
Fix compilation error and use singleton JsonSerializerOptions
Copilot Nov 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared\ResourceNameComparer.cs" />
<Compile Include="$(SharedDir)BicepFormattingHelpers.cs" LinkBase="Shared\BicepFormattingHelpers.cs" />
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared\ResourceNameComparer.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared\ResourceNameComparer.cs" />
<Compile Include="$(SharedDir)BicepFormattingHelpers.cs" LinkBase="Shared\BicepFormattingHelpers.cs" />
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared\ResourceNameComparer.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
133 changes: 133 additions & 0 deletions src/Aspire.Hosting.Azure/AcrLoginService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.Publishing;
using Azure.Core;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Azure;

/// <summary>
/// Default implementation of <see cref="IAcrLoginService"/> that handles ACR authentication
/// using Azure credentials and OAuth2 token exchange.
/// </summary>
internal sealed class AcrLoginService : IAcrLoginService
{
private const string AcrUsername = "00000000-0000-0000-0000-000000000000";
private const string AcrScope = "https://containerregistry.azure.net/.default";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to change if we are using sovereign clouds, like Mooncake?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might.


private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};

private readonly IHttpClientFactory _httpClientFactory;
private readonly IContainerRuntime _containerRuntime;
private readonly ILogger<AcrLoginService> _logger;

private sealed class AcrRefreshTokenResponse
{
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }

[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; set; }
}

/// <summary>
/// Initializes a new instance of the <see cref="AcrLoginService"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory for making OAuth2 exchange requests.</param>
/// <param name="containerRuntime">The container runtime for performing registry login.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntime containerRuntime, ILogger<AcrLoginService> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_containerRuntime = containerRuntime ?? throw new ArgumentNullException(nameof(containerRuntime));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <inheritdoc/>
public async Task LoginAsync(
string registryEndpoint,
string tenantId,
TokenCredential credential,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(registryEndpoint);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(credential);

// Step 1: Acquire AAD access token for ACR audience
var tokenRequestContext = new TokenRequestContext([AcrScope]);
var aadToken = await credential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false);

_logger.LogDebug("AAD access token acquired for ACR audience, registry: {RegistryEndpoint}, token length: {TokenLength}",
registryEndpoint, aadToken.Token.Length);

// Step 2: Exchange AAD token for ACR refresh token
var refreshToken = await ExchangeAadTokenForAcrRefreshTokenAsync(
registryEndpoint, tenantId, aadToken.Token, cancellationToken).ConfigureAwait(false);

_logger.LogDebug("ACR refresh token acquired, length: {TokenLength}", refreshToken.Length);

// Step 3: Login to the registry using container runtime
await _containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false);
}

private async Task<string> ExchangeAadTokenForAcrRefreshTokenAsync(
string registryEndpoint,
string tenantId,
string aadAccessToken,
CancellationToken cancellationToken)
{
// Use named HTTP client "AcrLogin" which can be configured for debug-level logging
// via configuration: "Logging": { "LogLevel": { "System.Net.Http.HttpClient.AcrLogin": "Debug" } }
var httpClient = _httpClientFactory.CreateClient("AcrLogin");
httpClient.Timeout = TimeSpan.FromSeconds(30);

// ACR OAuth2 exchange endpoint
var exchangeUrl = $"https://{registryEndpoint}/oauth2/exchange";

_logger.LogDebug("Exchanging AAD token for ACR refresh token at {ExchangeUrl} (tenant: {TenantId})",
exchangeUrl,
tenantId);

var formData = new Dictionary<string, string>
{
["grant_type"] = "access_token",
["service"] = registryEndpoint,
["tenant"] = tenantId,
["access_token"] = aadAccessToken
};

using var content = new FormUrlEncodedContent(formData);
var response = await httpClient.PostAsync(exchangeUrl, content, cancellationToken).ConfigureAwait(false);

// Read response body as string once
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot we don;t need to read the body twice as a string, we can use the JsonSerializer directly where we do the second read.


if (!response.IsSuccessStatusCode)
{
var truncatedBody = responseBody.Length <= 1000 ? responseBody : responseBody[..1000] + "…";
throw new HttpRequestException(
$"POST /oauth2/exchange failed {(int)response.StatusCode} {response.ReasonPhrase}. Body: {truncatedBody}",
null,
response.StatusCode);
}

// Deserialize from the string we already read
var tokenResponse = JsonSerializer.Deserialize<AcrRefreshTokenResponse>(responseBody, s_jsonOptions);

if (string.IsNullOrEmpty(tokenResponse?.RefreshToken))
{
throw new InvalidOperationException($"Response missing refresh_token.");
}

return tokenResponse.RefreshToken;
}
}
10 changes: 5 additions & 5 deletions src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessResult.cs" Link="Provisioning\Utils\ProcessResult.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessSpec.cs" Link="Provisioning\Utils\ProcessSpec.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessUtil.cs" Link="Provisioning\Utils\ProcessUtil.cs" />
<Compile Include="$(SharedDir)CustomResourceSnapshotExtensions.cs" Link="Provisioning\Utils\CustomResourceSnapshotExtensions.cs" />
<Compile Include="$(SharedDir)StringComparers.cs" Link="Provisioning\Utils\StringComparers.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessResult.cs" Link="Process\ProcessResult.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessSpec.cs" Link="Process\ProcessSpec.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessUtil.cs" Link="Process\ProcessUtil.cs" />
<Compile Include="$(SharedDir)CustomResourceSnapshotExtensions.cs" Link="Utils\CustomResourceSnapshotExtensions.cs" />
<Compile Include="$(SharedDir)StringComparers.cs" Link="Utils\StringComparers.cs" />
<Compile Include="$(SharedDir)Model\KnownRelationshipTypes.cs" />
</ItemGroup>

Expand Down
77 changes: 31 additions & 46 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResourceHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001
#pragma warning disable ASPIREAZURE001

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.Azure;
Expand All @@ -21,62 +21,47 @@ internal static class AzureEnvironmentResourceHelpers
{
public static async Task LoginToRegistryAsync(IContainerRegistry registry, PipelineStepContext context)
{
var processRunner = context.Services.GetRequiredService<IProcessRunner>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can IProcessRunner be deleted now?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe?

var configuration = context.Services.GetRequiredService<IConfiguration>();
var acrLoginService = context.Services.GetRequiredService<IAcrLoginService>();
var tokenCredentialProvider = context.Services.GetRequiredService<ITokenCredentialProvider>();

// Find the AzureEnvironmentResource from the application model
var azureEnvironment = context.Model.Resources.OfType<AzureEnvironmentResource>().FirstOrDefault();
if (azureEnvironment == null)
{
throw new InvalidOperationException("AzureEnvironmentResource must be present in the application model.");
}

var registryName = await registry.Name.GetValueAsync(context.CancellationToken).ConfigureAwait(false) ??
throw new InvalidOperationException("Failed to retrieve container registry information.");

var registryEndpoint = await registry.Endpoint.GetValueAsync(context.CancellationToken).ConfigureAwait(false) ??
throw new InvalidOperationException("Failed to retrieve container registry endpoint.");

var loginTask = await context.ReportingStep.CreateTaskAsync($"Logging in to **{registryName}**", context.CancellationToken).ConfigureAwait(false);
await using (loginTask.ConfigureAwait(false))
{
await AuthenticateToAcrHelper(loginTask, registryName, context.CancellationToken, processRunner, configuration).ConfigureAwait(false);
}
}

private static async Task AuthenticateToAcrHelper(IReportingTask loginTask, string registryName, CancellationToken cancellationToken, IProcessRunner processRunner, IConfiguration configuration)
{
var command = BicepCliCompiler.FindFullPathFromPath("az") ?? throw new InvalidOperationException("Failed to find 'az' command");
try
{
var loginSpec = new ProcessSpec(command)
{
Arguments = $"acr login --name {registryName}",
ThrowOnNonZeroReturnCode = false
};

// Set DOCKER_COMMAND environment variable if using podman
var containerRuntime = GetContainerRuntime(configuration);
if (string.Equals(containerRuntime, "podman", StringComparison.OrdinalIgnoreCase))
try
{
loginSpec.EnvironmentVariables["DOCKER_COMMAND"] = "podman";
// Get tenant ID from the provisioning context (always available from subscription)
var provisioningContext = await azureEnvironment.ProvisioningContextTask.Task.ConfigureAwait(false);
var tenantId = provisioningContext.Tenant.TenantId?.ToString()
?? throw new InvalidOperationException("Tenant ID is required for ACR authentication but was not available in provisioning context.");

// Use the ACR login service to perform authentication
await acrLoginService.LoginAsync(
registryEndpoint,
tenantId,
tokenCredentialProvider.TokenCredential,
context.CancellationToken).ConfigureAwait(false);

await loginTask.CompleteAsync($"Successfully logged in to **{registryEndpoint}**", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false);
}

var (pendingResult, processDisposable) = processRunner.Run(loginSpec);
await using (processDisposable.ConfigureAwait(false))
catch (Exception ex)
{
var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false);

if (result.ExitCode != 0)
{
await loginTask.FailAsync($"Login to ACR **{registryName}** failed with exit code {result.ExitCode}", cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await loginTask.CompleteAsync($"Successfully logged in to **{registryName}**", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
}
await loginTask.FailAsync($"Login to ACR **{registryEndpoint}** failed: {ex.Message}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
throw;
}
}
catch (Exception)
{
throw;
}
}

private static string? GetContainerRuntime(IConfiguration configuration)
{
// Fall back to known config names (primary and legacy)
return configuration["ASPIRE_CONTAINER_RUNTIME"] ?? configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"];
}

public static async Task PushImageToRegistryAsync(IContainerRegistry registry, IResource resource, PipelineStepContext context, IResourceContainerImageBuilder containerImageBuilder)
Expand Down
28 changes: 28 additions & 0 deletions src/Aspire.Hosting.Azure/IAcrLoginService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIRECONTAINERRUNTIME001

using Azure.Core;

namespace Aspire.Hosting.Azure;

/// <summary>
/// Service for handling Azure Container Registry (ACR) authentication.
/// </summary>
internal interface IAcrLoginService
{
/// <summary>
/// Logs into an Azure Container Registry using Azure credentials.
/// </summary>
/// <param name="registryEndpoint">The ACR endpoint (e.g., "myregistry.azurecr.io").</param>
/// <param name="tenantId">The Azure tenant ID.</param>
/// <param name="credential">The Azure credential to use for authentication.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when login succeeds.</returns>
Task LoginAsync(
string registryEndpoint,
string tenantId,
TokenCredential credential,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu

builder.Services.TryAddSingleton<ITokenCredentialProvider, DefaultTokenCredentialProvider>();

// Register ACR login service for container registry authentication
builder.Services.TryAddSingleton<IAcrLoginService, AcrLoginService>();

// Add named HTTP client for ACR OAuth2 exchange
// HTTP request logging can be controlled via logging configuration:
// "Logging": { "LogLevel": { "System.Net.Http.HttpClient.AcrLogin": "Debug" } }
builder.Services.AddHttpClient("AcrLogin");

builder.Services.AddHttpClient(); // Add default IHttpClientFactory

// Register BicepProvisioner via interface
builder.Services.TryAddSingleton<IBicepProvisioner, BicepProvisioner>();

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIRECONTAINERRUNTIME001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Dcp/Process/ProcessSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal sealed class ProcessSpec
public Action<int>? OnStop { get; init; }
public bool KillEntireProcessTree { get; init; } = true;
public bool ThrowOnNonZeroReturnCode { get; init; } = true;
public string? StandardInputContent { get; init; }

public ProcessSpec(string executablePath)
{
Expand Down
11 changes: 11 additions & 0 deletions src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static (Task<ProcessResult>, IAsyncDisposable) Run(ProcessSpec processSpe
Arguments = processSpec.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = processSpec.StandardInputContent != null,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
Expand Down Expand Up @@ -91,6 +92,16 @@ public static (Task<ProcessResult>, IAsyncDisposable) Run(ProcessSpec processSpe
#endif

process.Start();

// Write standard input if provided and ensure it's flushed before closing
if (processSpec.StandardInputContent != null)
{
var writer = process.StandardInput;
writer.WriteLine(processSpec.StandardInputContent);
writer.Flush();
writer.Close();
}

process.BeginOutputReadLine();
process.BeginErrorReadLine();
processSpec.OnStart?.Invoke(process.Id);
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES004
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Diagnostics;
using System.Reflection;
Expand Down
Loading
Loading