-
Notifications
You must be signed in to change notification settings - Fork 720
Refactor ACR authentication to use Azure SDK TokenCredential with OAuth2 token exchange #12608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5f6bf0e
85a3e07
2af559c
6b7230e
f24e54f
2a80106
5deca0e
33c8aa7
64c1053
8db746b
4c1f43c
3d4e7c3
b26aafc
dacdad9
29e487a
f4e87fd
db3f0bb
6a317ed
db90153
2de6948
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -21,62 +21,47 @@ internal static class AzureEnvironmentResourceHelpers | |
| { | ||
| public static async Task LoginToRegistryAsync(IContainerRegistry registry, PipelineStepContext context) | ||
| { | ||
| var processRunner = context.Services.GetRequiredService<IProcessRunner>(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can IProcessRunner be deleted now? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
| 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( | ||
davidfowl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| string registryEndpoint, | ||
| string tenantId, | ||
| TokenCredential credential, | ||
| CancellationToken cancellationToken = default); | ||
| } | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might.