| File: AcrLoginService.cs | Web Access |
| Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure) |
// 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.Net; 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"; // Thirty attempts at a two-second cadence gives new registries about a minute // for DNS, data-plane, and RBAC propagation without blocking deployment for too long. private const int MaxLoginAttempts = 30; private static readonly TimeSpan s_loginRetryDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan s_maxLoginRetryDuration = TimeSpan.FromMinutes(1); private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; private readonly IHttpClientFactory _httpClientFactory; private readonly IContainerRuntimeResolver _containerRuntimeResolver; private readonly ILogger<AcrLoginService> _logger; private readonly TimeProvider _timeProvider; 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="containerRuntimeResolver">The container runtime resolver for performing registry login.</param> /// <param name="logger">The logger for diagnostic output.</param> /// <param name="timeProvider">The time provider used for retry delays.</param> public AcrLoginService( IHttpClientFactory httpClientFactory, IContainerRuntimeResolver containerRuntimeResolver, ILogger<AcrLoginService> logger, TimeProvider timeProvider) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } /// <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); var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); var retryStartTimestamp = _timeProvider.GetTimestamp(); for (var attempt = 1; ; attempt++) { try { // 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); return; } catch (HttpRequestException ex) when (ShouldRetryAcrLoginFailure(ex, attempt, retryStartTimestamp)) { // New registries can briefly fail DNS resolution or reject token exchange while // data-plane endpoint and RBAC propagation catch up with ARM deployment success. _logger.LogWarning( ex, "ACR login to {RegistryEndpoint} failed on attempt {Attempt} of {MaxAttempts}. Retrying in {RetryDelay}.", registryEndpoint, attempt, MaxLoginAttempts, s_loginRetryDelay); await Task.Delay(s_loginRetryDelay, _timeProvider, cancellationToken).ConfigureAwait(false); } } } private bool ShouldRetryAcrLoginFailure(HttpRequestException ex, int attempt, long retryStartTimestamp) { return attempt < MaxLoginAttempts && _timeProvider.GetElapsedTime(retryStartTimestamp) < s_maxLoginRetryDuration && IsRetryableAcrLoginFailure(ex); } private static bool IsRetryableAcrLoginFailure(HttpRequestException ex) { if (ex.StatusCode is null) { return true; } return ex.StatusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound || (int)ex.StatusCode >= 500; } 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); 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; } }