File: AuthHandshakeMessageHandler.cs
Web Access
Project: src\src\sdk\src\Containers\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj (Microsoft.NET.Build.Containers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.NET.Build.Containers.Credentials;
using Microsoft.NET.Build.Containers.Resources;
using Valleysoft.DockerCredsProvider;

namespace Microsoft.NET.Build.Containers;

/// <summary>
/// A delegating handler that performs the Docker auth handshake as described <see href="https://docs.docker.com/registry/spec/auth/token/">in their docs</see> if a request isn't authenticated
/// </summary>
internal sealed partial class AuthHandshakeMessageHandler : DelegatingHandler
{
    private const int MaxRequestRetries = 5; // Arbitrary but seems to work ok for chunked uploads to ghcr.io

    /// <summary>
    /// Unique identifier that is used to tag requests from this library to external registries.
    /// </summary>
    /// <remarks>
    /// Valid characters for this clientID are in the unicode range <see href="https://wintelguy.com/unicode_character_lookup.pl/?str=20-7E">20-7E</see>
    /// </remarks>
    private const string ClientID = "netsdkcontainers";
    private const string BasicAuthScheme = "Basic";
    private const string BearerAuthScheme = "Bearer";

    private sealed record AuthInfo(string Realm, string? Service, string? Scope);

    private readonly string _registryName;
    private readonly bool _isInsecureRegistry;
    private readonly ILogger _logger;
    private readonly RegistryMode _registryMode;
    private static ConcurrentDictionary<string, AuthenticationHeaderValue?> _authenticationHeaders = new();

    /// <summary>
    /// IPv4 ranges considered unsafe to send token requests to. Loopback (127.0.0.0/8) is
    /// covered by <see cref="IPAddress.IsLoopback(IPAddress)"/>.
    /// </summary>
    private static readonly IPNetwork[] BlockedV4Networks =
    [
        IPNetwork.Parse("0.0.0.0/8"),      // "this network" / unspecified
        IPNetwork.Parse("10.0.0.0/8"),     // private (RFC 1918)
        IPNetwork.Parse("172.16.0.0/12"),  // private (RFC 1918)
        IPNetwork.Parse("192.168.0.0/16"), // private (RFC 1918)
        IPNetwork.Parse("169.254.0.0/16"), // link-local
        IPNetwork.Parse("224.0.0.0/24"),   // link-local multicast
    ];

    public AuthHandshakeMessageHandler(string registryName, bool isInsecureRegistry, HttpMessageHandler innerHandler, ILogger logger, RegistryMode mode) : base(innerHandler)
    {
        _registryName = registryName;
        _isInsecureRegistry = isInsecureRegistry;
        _logger = logger;
        _registryMode = mode;
    }

    /// <summary>
    /// the www-authenticate header must have realm, service, and scope information, so this method parses it into that shape if present
    /// </summary>
    /// <param name="msg"></param>
    /// <param name="bearerAuthInfo"></param>
    /// <returns></returns>
    private static bool TryParseAuthenticationInfo(HttpResponseMessage msg, [NotNullWhen(true)] out string? scheme, out AuthInfo? bearerAuthInfo)
    {
        bearerAuthInfo = null;
        scheme = null;

        var authenticateHeader = msg.Headers.WwwAuthenticate;
        if (!authenticateHeader.Any())
        {
            return false;
        }

        AuthenticationHeaderValue header = authenticateHeader.First();

        if (header.Scheme is not null)
        {
            scheme = header.Scheme;

            if (header.Scheme.Equals(BasicAuthScheme, StringComparison.OrdinalIgnoreCase))
            {
                bearerAuthInfo = null;
                return true;
            }
            else if (header.Scheme.Equals(BearerAuthScheme, StringComparison.OrdinalIgnoreCase))
            {
                var keyValues = ParseBearerArgs(header.Parameter);
                if (keyValues is null)
                {
                    return false;
                }
                return TryParseBearerAuthInfo(keyValues, out bearerAuthInfo);
            }
            else
            {
                return false;
            }
        }
        return false;

        static bool TryParseBearerAuthInfo(Dictionary<string, string> authValues, [NotNullWhen(true)] out AuthInfo? authInfo)
        {
            if (authValues.TryGetValue("realm", out string? realm))
            {
                string? service = null;
                authValues.TryGetValue("service", out service);
                string? scope = null;
                authValues.TryGetValue("scope", out scope);
                authInfo = new AuthInfo(realm, service, scope);
                return true;
            }
            else
            {
                authInfo = null;
                return false;
            }
        }

        static Dictionary<string, string>? ParseBearerArgs(string? bearerHeaderArgs)
        {
            if (bearerHeaderArgs is null)
            {
                return null;
            }
            Dictionary<string, string> keyValues = new();
            foreach (Match match in BearerParameterSplitter().Matches(bearerHeaderArgs))
            {
                keyValues.Add(match.Groups["key"].Value, match.Groups["value"].Value);
            }
            return keyValues;
        }
    }

    /// <summary>
    /// Response to a request to get a token using some auth.
    /// </summary>
    /// <remarks>
    /// <see href="https://docs.docker.com/registry/spec/auth/token/#token-response-fields"/>
    /// </remarks>
    private sealed record TokenResponse(string? token, string? access_token, int? expires_in, DateTimeOffset? issued_at)
    {
        public string ResolvedToken => token ?? access_token ?? throw new ArgumentException(Resource.GetString(nameof(Strings.InvalidTokenResponse)));
        public DateTimeOffset ResolvedExpiration
        {
            get
            {
                var issueTime = this.issued_at ?? DateTimeOffset.UtcNow; // per spec, if no issued_at use the current time
                var validityDuration = this.expires_in ?? 60; // per spec, if no expires_in use 60 seconds
                var expirationTime = issueTime.AddSeconds(validityDuration);
                return expirationTime;
            }
        }
    }

    /// <summary>
    /// Uses the authentication information from a 401 response to perform the authentication dance for a given registry.
    /// Credentials for the request are retrieved from the credential provider, then used to acquire a token.
    /// That token is cached for some duration determined by the authentication mechanism on a per-host basis.
    /// </summary>
    private async Task<(AuthenticationHeaderValue, DateTimeOffset)?> GetAuthenticationAsync(string registry, string scheme, AuthInfo? bearerAuthInfo, CancellationToken cancellationToken)
    {
        // For bearer auth, validate the realm URL before any credential lookup so that a malicious or
        // compromised registry response cannot trigger credential retrieval toward a hostile token
        // endpoint, and so that validation errors aren't masked by credential errors.
        Uri? validatedBearerRealm = null;
        if (scheme.Equals(BearerAuthScheme, StringComparison.OrdinalIgnoreCase))
        {
            Debug.Assert(bearerAuthInfo is not null);
            validatedBearerRealm = ValidateRealmUri(bearerAuthInfo.Realm, registry, _isInsecureRegistry);
        }

        DockerCredentials? privateRepoCreds;
        // Allow overrides for auth via environment variables
        if (GetDockerCredentialsFromEnvironment(_registryMode) is (string credU, string credP))
        {
            privateRepoCreds = new DockerCredentials(credU, credP);
        }
        else
        {
            privateRepoCreds = await GetLoginCredentials(registry).ConfigureAwait(false);
        }

        if (scheme.Equals(BasicAuthScheme, StringComparison.OrdinalIgnoreCase))
        {
            var authValue = new AuthenticationHeaderValue(BasicAuthScheme, Convert.ToBase64String(Encoding.ASCII.GetBytes($"{privateRepoCreds.Username}:{privateRepoCreds.Password}")));
            return new(authValue, DateTimeOffset.MaxValue);
        }
        else if (scheme.Equals(BearerAuthScheme, StringComparison.OrdinalIgnoreCase))
        {
            Debug.Assert(bearerAuthInfo is not null);
            Debug.Assert(validatedBearerRealm is not null);

            // Obtain a Bearer token, when the credentials are:
            // - an identity token: use it for OAuth
            // - a username/password: use them for Basic auth, and fall back to OAuth

            if (string.IsNullOrWhiteSpace(privateRepoCreds.IdentityToken))
            {
                var authenticationValueAndDuration = await TryTokenGetAsync(privateRepoCreds, bearerAuthInfo, validatedBearerRealm, cancellationToken).ConfigureAwait(false);
                if (authenticationValueAndDuration is not null)
                {
                    return authenticationValueAndDuration;
                }
            }

            return await TryOAuthPostAsync(privateRepoCreds, bearerAuthInfo, validatedBearerRealm, cancellationToken).ConfigureAwait(false);
        }
        else
        {
            return null;
        }
    }

    /// <summary>
    /// Validates the bearer realm URL returned in a WWW-Authenticate challenge before the client
    /// uses it to fetch a token.
    /// Exception for legitimate insecure dev/on-prem registries that use IP-literal
    /// hostnames and return realms pointing back at themselves: an IP-literal realm host is
    /// allowed when <paramref name="isInsecureRegistry"/> is true and the realm host matches the
    /// registry host (port-independent).
    /// </summary>
    internal static Uri ValidateRealmUri(string realm, string registryName, bool isInsecureRegistry)
    {
        if (!Uri.TryCreate(realm, UriKind.Absolute, out Uri? realmUri))
        {
            throw new InvalidAuthResponseException(
                registryName,
                Resource.FormatString(nameof(Strings.InvalidAuthResponse_RelativeOrUnparseableRealm), realm));
        }

        // Scheme allowlist. Always permit https; permit http only when the registry is insecure.
        // (Uri.Scheme is normalized to lowercase by Uri itself, so a plain comparison is fine.)
        bool schemeAllowed = realmUri.Scheme switch
        {
            "https" => true,
            "http" => isInsecureRegistry,
            _ => false,
        };
        if (!schemeAllowed)
        {
            throw new InvalidAuthResponseException(
                registryName,
                Resource.FormatString(nameof(Strings.InvalidAuthResponse_DisallowedScheme), realm, realmUri.Scheme));
        }

        // IP-literal guard. We use Uri.IdnHost (the ASCII/canonical host) rather than
        // Uri.Host so that Unicode-dot forms, which the runtime canonicalizes back to
        // 127.0.0.1 when the request is actually sent, cannot bypass the check by
        // appearing as a DNS name to Uri.HostNameType.
        string realmHost = TrimTrailingDot(realmUri.IdnHost);
        if (IPAddress.TryParse(realmHost, out IPAddress? realmIp)
            && IsBlockedIpLiteral(realmIp))
        {
            // Exception: allow IP-literal realm whose host matches the registry's host
            // when the registry is insecure. This supports legitimate private/on-prem dev
            // registries (e.g. 192.168.1.5:5000) whose realm points back to themselves.
            if (!(isInsecureRegistry && RegistryHostMatchesIp(registryName, realmIp)))
            {
                throw new InvalidAuthResponseException(
                    registryName,
                    Resource.FormatString(nameof(Strings.InvalidAuthResponse_PrivateIpLiteralRealm), realm, realmHost));
            }
        }
        else if (IsLoopbackDnsName(realmHost)
            && !(isInsecureRegistry && RegistryIsLoopbackEquivalent(registryName)))
        {
            // RFC 6761 reserves "localhost" and "*.localhost" for loopback resolution, so a
            // realm host of those names carries the same risk as a literal 127.0.0.1 - the
            // runtime resolves them to loopback regardless of /etc/hosts. Apply the same
            // exception model the IP-literal guard uses (insecure + registry is also loopback-equivalent).
            throw new InvalidAuthResponseException(
                registryName,
                Resource.FormatString(nameof(Strings.InvalidAuthResponse_PrivateIpLiteralRealm), realm, realmHost));
        }

        return realmUri;
    }

    /// <summary>
    /// Returns true when <paramref name="host"/> is one of the DNS names RFC 6761 reserves
    /// for loopback resolution.
    /// Callers must pass an already-trimmed host (see <see cref="TrimTrailingDot"/>).
    /// </summary>
    private static bool IsLoopbackDnsName(string host) =>
        host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
        || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);

    /// <summary>
    /// Strips a single FQDN root-zone trailing dot from <paramref name="host"/>. DNS treats
    /// "localhost" and "localhost." as equivalent, but Uri.IdnHost preserves the dot, so
    /// without this normalization a realm could bypass the IP-literal and loopback-name
    /// guards using forms like "127.0.0.1." or "localhost.".
    /// </summary>
    private static string TrimTrailingDot(string host) =>
        host.Length > 1 && host[^1] == '.' ? host[..^1] : host;

    /// <summary>
    /// Returns true when <paramref name="registryName"/> identifies the local machine via a
    /// loopback IP literal (<c>127.0.0.0</c> or <c>::1</c>) or an RFC 6761 loopback name
    /// (<c>localhost</c> / <c>*.localhost</c>).
    /// </summary>
    private static bool RegistryIsLoopbackEquivalent(string registryName)
    {
        if (!Uri.TryCreate($"https://{registryName}", UriKind.Absolute, out Uri? uri))
        {
            return false;
        }
        string host = TrimTrailingDot(uri.IdnHost);
        return IsLoopbackDnsName(host)
            || (IPAddress.TryParse(host, out IPAddress? ip) && IPAddress.IsLoopback(ip));
    }

    /// <summary>
    /// Returns true if the IP address is considered unsafe to send token requests to:
    /// loopback, link-local, private, or unspecified.
    /// </summary>
    private static bool IsBlockedIpLiteral(IPAddress ip)
    {
        if (IPAddress.IsLoopback(ip))
        {
            return true;
        }
        // IPv4-mapped IPv6: unwrap so we don't need a parallel set of IPv4-in-IPv6 CIDRs.
        if (ip.IsIPv4MappedToIPv6)
        {
            return IsBlockedIpLiteral(ip.MapToIPv4());
        }

        foreach (IPNetwork net in BlockedV4Networks)
        {
            if (net.Contains(ip))
            {
                return true;
            }
        }

        // IPv6-only properties; all return false for IPv4 so the family gate is implicit.
        // Multicast scope is read directly from the second byte.
        return ip.Equals(IPAddress.IPv6Any)
            || ip.IsIPv6LinkLocal
            || ip.IsIPv6SiteLocal
            || ip.IsIPv6UniqueLocal
            || (ip.IsIPv6Multicast && (ip.GetAddressBytes()[1] & 0x0f) == 0x02);
    }

    /// <summary>
    /// Returns true when the registry name's host portion (port stripped) refers to the same machine
    /// as <paramref name="ip"/>. Used to allow IP-literal realms whose host matches the registry host
    /// in insecure-registry scenarios.
    /// </summary>
    /// <remarks>
    /// Two forms of match are recognized: an IP-literal registry name whose address equals
    /// <paramref name="ip"/>, and a registry name of <c>localhost</c> (or any <c>*.localhost</c>
    /// subdomain) paired with any loopback IP.
    /// </remarks>
    private static bool RegistryHostMatchesIp(string registryName, IPAddress ip)
    {
        // Use Uri to handle "host[:port]" and bracketed IPv6 ("[::1]:5000") splitting.
        // The synthetic scheme is parser convenience — we never use the URL.
        if (!Uri.TryCreate($"https://{registryName}", UriKind.Absolute, out Uri? uri))
        {
            return false;
        }
        // IdnHost (vs. Host) gives us three things in one shot:
        //   1. Strips the "[" and "]" around IPv6 literals so the host feeds straight
        //      into IPAddress.TryParse.
        //   2. Canonicalizes Unicode/IDN host forms to ASCII (e.g. fullwidth-dot
        //      "127\uFF0E0\uFF0E0\uFF0E1" -> "127.0.0.1"), matching what HttpClient
        //      actually resolves.
        //   3. Matches the canonicalization ValidateRealmUri uses, so realm-vs-registry
        //      host comparisons stay consistent on both sides.
        string host = TrimTrailingDot(uri.IdnHost);

        // RFC 6761 reserves "localhost" (and "*.localhost") for loopback addresses, so a
        // localhost-named registry returning a 127.0.0.0/8 or ::1 realm is legitimate.
        if (IPAddress.IsLoopback(ip) && IsLoopbackDnsName(host))
        {
            return true;
        }

        return IPAddress.TryParse(host, out IPAddress? registryIp) && registryIp.Equals(ip);
    }

    internal static (string credU, string credP)? TryGetCredentialsFromEnvVars(string unameVar, string passwordVar)
    {
        var credU = Environment.GetEnvironmentVariable(unameVar);
        var credP = Environment.GetEnvironmentVariable(passwordVar);
        if (!string.IsNullOrEmpty(credU) && !string.IsNullOrEmpty(credP))
        {
            return (credU, credP);
        }
        else
        {
            return null;
        }
    }

    /// <summary>
    /// Gets docker credentials from the environment variables based on registry mode.
    /// </summary>
    internal static (string credU, string credP)? GetDockerCredentialsFromEnvironment(RegistryMode mode)
    {
        if (mode == RegistryMode.Push)
        {
            if (TryGetCredentialsFromEnvVars(ContainerHelpers.PushHostObjectUser, ContainerHelpers.PushHostObjectPass) is (string, string) pushCreds)
            {
                return pushCreds;
            }

            if (TryGetCredentialsFromEnvVars(ContainerHelpers.HostObjectUser, ContainerHelpers.HostObjectPass) is (string, string) genericCreds)
            {
                return genericCreds;
            }

            return TryGetCredentialsFromEnvVars(ContainerHelpers.HostObjectUserLegacy, ContainerHelpers.HostObjectPassLegacy);
        }
        else if (mode == RegistryMode.Pull)
        {
            return TryGetCredentialsFromEnvVars(ContainerHelpers.PullHostObjectUser, ContainerHelpers.PullHostObjectPass);
        }
        else if (mode == RegistryMode.PullFromOutput)
        {
            if (TryGetCredentialsFromEnvVars(ContainerHelpers.PullHostObjectUser, ContainerHelpers.PullHostObjectPass) is (string, string) pullCreds)
            {
                return pullCreds;
            }

            if (TryGetCredentialsFromEnvVars(ContainerHelpers.HostObjectUser, ContainerHelpers.HostObjectPass) is (string, string) genericCreds)
            {
                return genericCreds;
            }

            return TryGetCredentialsFromEnvVars(ContainerHelpers.HostObjectUserLegacy, ContainerHelpers.HostObjectPassLegacy);
        }
        else
        {
            throw new InvalidEnumArgumentException(nameof(mode), (int)mode, typeof(RegistryMode));
        }
    }

    /// <summary>
    /// Implements the Docker OAuth2 Authentication flow as documented at <see href="https://docs.docker.com/registry/spec/auth/oauth/"/>.
    /// </summary
    private async Task<(AuthenticationHeaderValue, DateTimeOffset)?> TryOAuthPostAsync(DockerCredentials privateRepoCreds, AuthInfo bearerAuthInfo, Uri realmUri, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        _logger.LogTrace("Attempting to authenticate on {uri} using POST.", realmUri);
        Dictionary<string, string?> parameters = new()
        {
            ["client_id"] = ClientID,
        };
        if (!string.IsNullOrWhiteSpace(privateRepoCreds.IdentityToken))
        {
            parameters["grant_type"] = "refresh_token";
            parameters["refresh_token"] = privateRepoCreds.IdentityToken;
        }
        else
        {
            parameters["grant_type"] = "password";
            parameters["username"] = privateRepoCreds.Username;
            parameters["password"] = privateRepoCreds.Password;
        }
        if (bearerAuthInfo.Service is not null)
        {
            parameters["service"] = bearerAuthInfo.Service;
        }
        if (bearerAuthInfo.Scope is not null)
        {
            parameters["scope"] = bearerAuthInfo.Scope;
        };
        HttpRequestMessage postMessage = new(HttpMethod.Post, realmUri)
        {
            Content = new FormUrlEncodedContent(parameters)
        };

        using HttpResponseMessage postResponse = await base.SendAsync(postMessage, cancellationToken).ConfigureAwait(false);
        if (!postResponse.IsSuccessStatusCode)
        {
            await postResponse.LogHttpResponseAsync(_logger, cancellationToken).ConfigureAwait(false);
            return null; // try next method
        }
        _logger.LogTrace("Received '{statuscode}'.", postResponse.StatusCode);
        TokenResponse? tokenResponse = JsonSerializer.Deserialize<TokenResponse>(postResponse.Content.ReadAsStream(cancellationToken));
        if (tokenResponse is { } tokenEnvelope)
        {
            var authValue = new AuthenticationHeaderValue(BearerAuthScheme, tokenResponse.ResolvedToken);
            return (authValue, tokenResponse.ResolvedExpiration);
        }
        else
        {
            _logger.LogTrace(Resource.GetString(nameof(Strings.CouldntDeserializeJsonToken)));
            return null; // try next method
        }
    }

    /// <summary>
    /// Implements the Docker Token Authentication flow as documented at <see href="https://docs.docker.com/registry/spec/auth/token/"/>
    /// </summary>
    private async Task<(AuthenticationHeaderValue, DateTimeOffset)?> TryTokenGetAsync(DockerCredentials privateRepoCreds, AuthInfo bearerAuthInfo, Uri realmUri, CancellationToken cancellationToken)
    {
        // this doesn't seem to be called out in the spec, but actual username/password auth information should be converted into Basic auth here,
        // even though the overall Scheme we're authenticating for is Bearer
        var header = new AuthenticationHeaderValue(BasicAuthScheme, Convert.ToBase64String(Encoding.ASCII.GetBytes($"{privateRepoCreds.Username}:{privateRepoCreds.Password}")));
        var builder = new UriBuilder(realmUri);

        _logger.LogTrace("Attempting to authenticate on {uri} using GET.", realmUri);
        var queryDict = System.Web.HttpUtility.ParseQueryString("");
        if (bearerAuthInfo.Service is string svc)
        {
            queryDict["service"] = svc;
        }
        if (bearerAuthInfo.Scope is string s)
        {
            queryDict["scope"] = s;
        }
        builder.Query = queryDict.ToString();
        var message = new HttpRequestMessage(HttpMethod.Get, builder.ToString());
        message.Headers.Authorization = header;

        using var tokenResponse = await base.SendAsync(message, cancellationToken).ConfigureAwait(false);
        if (!tokenResponse.IsSuccessStatusCode)
        {
            await tokenResponse.LogHttpResponseAsync(_logger, cancellationToken).ConfigureAwait(false);
            return null; // try next method
        }

        TokenResponse? token = JsonSerializer.Deserialize<TokenResponse>(tokenResponse.Content.ReadAsStream(cancellationToken));
        if (token is null)
        {
            throw new ArgumentException(Resource.GetString(nameof(Strings.CouldntDeserializeJsonToken)));
        }
        return (new AuthenticationHeaderValue(BearerAuthScheme, token.ResolvedToken), token.ResolvedExpiration);
    }

    private static async Task<DockerCredentials> GetLoginCredentials(string registry)
    {
        // For authentication with Docker Hub, 'docker login' uses 'https://index.docker.io/v1/' as the registry key.
        // And 'podman login docker.io' uses 'docker.io'.
        // Try the key used by 'docker' first, and then fall back to the regular case for 'podman'.
        if (registry == ContainerHelpers.DockerRegistryAlias)
        {
            try
            {
                return await CredsProvider.GetCredentialsAsync("https://index.docker.io/v1/").ConfigureAwait(false);
            }
            catch
            { }
        }

        try
        {
            return await CredsProvider.GetCredentialsAsync(registry).ConfigureAwait(false);
        }
        catch (Exception e)
        {
            throw new CredentialRetrievalException(registry, e);
        }
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.RequestUri is null)
        {
            throw new ArgumentException(Resource.GetString(nameof(Strings.NoRequestUriSpecified)), nameof(request));
        }

        if (_authenticationHeaders.TryGetValue(_registryName, out AuthenticationHeaderValue? header))
        {
            request.Headers.Authorization = header;
        }

        int retryCount = 0;
        List<Exception>? requestExceptions = null;

        while (retryCount < MaxRequestRetries)
        {
            try
            {
                var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
                if (response is { StatusCode: HttpStatusCode.OK })
                {
                    return response;
                }
                else if (response is { StatusCode: HttpStatusCode.Unauthorized } && TryParseAuthenticationInfo(response, out string? scheme, out AuthInfo? authInfo))
                {
                    // Load the reply so the HTTP connection becomes available to send the authentication request.
                    // Ideally we'd call LoadIntoBufferAsync, but it has no overload that accepts a CancellationToken so we call ReadAsByteArrayAsync instead.
                    _ = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);

                    if (await GetAuthenticationAsync(_registryName, scheme!, authInfo, cancellationToken).ConfigureAwait(false) is (AuthenticationHeaderValue authHeader, DateTimeOffset expirationTime))
                    {
                        _authenticationHeaders[_registryName] = authHeader;
                        request.Headers.Authorization = authHeader;
                        return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
                    }

                    throw new UnableToAccessRepositoryException(_registryName);
                }
                else
                {
                    return response;
                }
            }
            catch (HttpRequestException e) when (e.InnerException is IOException ioe && ioe.InnerException is SocketException se)
            {
                requestExceptions ??= new();
                requestExceptions.Add(e);

                retryCount += 1;
                _logger.LogInformation("Encountered a HttpRequestException {error} with message \"{message}\". Pausing before retry.", e.HttpRequestError, se.Message);
                _logger.LogTrace("Exception details: {ex}", se);
                await Task.Delay(TimeSpan.FromSeconds(1.0 * Math.Pow(2, retryCount)), cancellationToken).ConfigureAwait(false);

                // retry
                continue;
            }
        }

        throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)), new AggregateException(requestExceptions!));
    }

    [GeneratedRegex("(?<key>\\w+)=\"(?<value>[^\"]*)\"(?:,|$)")]
    private static partial Regex BearerParameterSplitter();
}