< Summary

Class:Microsoft.Azure.ContainerRegistry.ContainerRegistryCredentials
Assembly:Microsoft.Azure.ContainerRegistry
File(s):C:\Git\azure-sdk-for-net\sdk\containerregistry\Microsoft.Azure.ContainerRegistry\src\Customizations\ContainerRegistryCredentials.cs
Covered lines:29
Uncovered lines:65
Coverable lines:94
Total lines:320
Line coverage:30.8% (29 of 94)
Covered branches:12
Total branches:54
Branch coverage:22.2% (12 of 54)

Metrics

MethodCyclomatic complexity Line coverage Branch coverage
get__authHeader()-100%100%
get__mode()-100%100%
get__loginServerUrl()-100%100%
get__username()-100%100%
get__password()-100%100%
get__requestCancellationToken()-0%100%
.ctor(...)-76.92%75%
.ctor(...)-0%100%
InitializeServiceClient(...)-50%37.5%
ProcessHttpRequestAsync()-55.56%50%
ProcessLoginUrl(...)-62.5%66.67%
GetAcrAccessToken(...)-0%0%
GetScope()-0%0%
ResolveScopeLocally(...)-0%0%
GetScopeFromHeaders(...)-0%0%
TrimDoubleQuotes(...)-0%0%

File(s)

C:\Git\azure-sdk-for-net\sdk\containerregistry\Microsoft.Azure.ContainerRegistry\src\Customizations\ContainerRegistryCredentials.cs

#LineLine coverage
 1using Microsoft.Rest;
 2using System;
 3using System.Collections.Generic;
 4using System.Net.Http;
 5using System.Net.Http.Headers;
 6using System.Threading;
 7using System.Threading.Tasks;
 8
 9namespace Microsoft.Azure.ContainerRegistry
 10{
 11
 12    /// <summary>
 13    /// Robust handling of Basic and OAUTH2 authentication flows for the Azure Container Registry Runtime .Net SDK.
 14    /// This class handles Basic Authentication as well as JWT token authentication using both username and password
 15    /// routes as well as through exchanging AAD tokens.
 16    /// </summary>
 17    public class ContainerRegistryCredentials : ServiceClientCredentials
 18    {
 19
 20        #region Definitions
 21
 22        /// <summary>
 23        /// Authentication type
 24        /// </summary>
 25        public enum LoginMode
 26        {
 27            /// <summary> Basic authentication </summary>
 28            Basic,
 29            /// <summary> Authentication using oauth2 with login and password </summary>
 30            TokenAuth,
 31            /// <summary> Authentication using an AAD access token.</summary>
 32            TokenAad
 33        }
 34
 35        #endregion
 36
 37        #region Instance Variables
 17438        private string _authHeader { get; set; }
 23439        private LoginMode _mode { get; set; }
 12040        private string _loginServerUrl { get; set; } // does not contain scheme prefix (e.g. "https://")
 12041        private string _username { get; set; }
 12042        private string _password { get; set; }
 043        private CancellationToken _requestCancellationToken { get; set; }
 44
 45        // Structure : Scope : Token
 46        // Key Scope retrieved from header from service which shouldn't change culture.
 047        private Dictionary<string, ContainerRegistryAccessToken> _acrAccessTokens = new Dictionary<string, ContainerRegi
 48
 49        // Structure : Method>Operation : Scope
 50        // Key contains operation url which could potentially change culture...
 051        private Dictionary<string, string> _acrScopes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 52
 53        // Internal simplified client for Token Acquisition
 54        private ContainerRegistryRefreshToken _acrRefresh;
 55        private AuthToken _aadAccess;
 56
 57        #endregion
 58
 59        #region Constructors
 60
 61        /// <summary>
 62        /// Construct a ContainerRegistryCredentials object from user credentials. Users may specify basic authenticatio
 63        /// <exception cref="Exception"> Throws an exception if LoginMode is set to TokenAad </exception>
 64        /// <paramref name="mode"/> The credential acquisition mode, one of Basic, TokenAuth, or TokenAad
 65        /// <paramref name="loginUrl"/> The url of the registry to be used
 66        /// <paramref name="username"/> The username for the registry
 67        /// <paramref name="password"/> The password for the registry
 68        /// </summary>
 6069        public ContainerRegistryCredentials(LoginMode mode, string loginUrl, string username, string password, Cancellat
 70        {
 6071            if (mode == LoginMode.TokenAad)
 72            {
 073                throw new ArgumentException("This constructor does not permit AAD Authentication. Please use an appropri
 74            }
 75
 6076            _mode = mode;
 6077            _loginServerUrl = ProcessLoginUrl(loginUrl);
 6078            _username = username;
 6079            _password = password;
 6080            _requestCancellationToken = cancellationToken;
 81
 6082            if (_mode == LoginMode.Basic) // Basic Authentication
 83            {
 6084                _authHeader = Helpers.EncodeTo64($"{_username}:{_password}");
 85            }
 6086        }
 87
 88        /// <summary>
 89        /// Construct a ContainerRegistryCredentials object from an AAD Token. A callback can be provided to renew the A
 90        /// <paramref name="aadAccessToken"/> The password for the registry
 91        /// <paramref name="loginUrl"/> The Azure active directory access token to be used
 92        /// <paramref name="tenant"/> The tenant of the aad access token (optional)
 93        /// <paramref name="acquireNewAad"/> Callback function to refresh the <paramref name="aadAccessToken">. Without 
 94        /// </summary>
 095        public ContainerRegistryCredentials(string aadAccessToken, string loginUrl, AuthToken.AcquireCallback acquireNew
 96        {
 097            _mode = LoginMode.TokenAad;
 098            _loginServerUrl = ProcessLoginUrl(loginUrl);
 099            _requestCancellationToken = cancellationToken;
 0100            _aadAccess = new AuthToken(aadAccessToken, acquireNewAad);
 0101            _acrRefresh = new ContainerRegistryRefreshToken(_aadAccess, _loginServerUrl);
 0102        }
 103
 104        #endregion
 105
 106        #region Overrides
 107
 108        /// <summary>
 109        /// Called on initialization of client. Sets the Client's LoginUri from the Credentials LoginUrl.
 110        /// </summary>
 111        public override void InitializeServiceClient<T>(ServiceClient<T> client)
 112        {
 60113            if (client == null)
 114            {
 0115                throw new ArgumentNullException(nameof(client));
 116            }
 117
 118            // if this is an ACRClient, add the loginUri that this credential was created for
 60119            if (client is AzureContainerRegistryClient acrClient)
 120            {
 60121                if (string.IsNullOrEmpty(acrClient.LoginUri))
 122                {
 60123                    acrClient.LoginUri = $"https://{this._loginServerUrl}";
 124                }
 125                // if the login uris don't match
 0126                else if (acrClient.LoginUri.ToLowerInvariant() != this._loginServerUrl.ToLowerInvariant())
 127                {
 0128                    throw new ValidationException($"\"{nameof(AzureContainerRegistryClient)}'s\" LoginUrl: '{acrClient.L
 129                }
 130            }
 0131        }
 132
 133        /// <summary>
 134        /// Apply the credentials to the HTTP request.
 135        /// </summary>
 136        public override async Task ProcessHttpRequestAsync(HttpRequestMessage request, CancellationToken cancellationTok
 137        {
 114138            if (request == null)
 139            {
 0140                throw new ArgumentNullException(nameof(request));
 141            }
 142
 114143            if (_mode == LoginMode.Basic)
 144            {
 114145                request.Headers.Authorization = new AuthenticationHeaderValue("Basic", _authHeader);
 146            }
 147            else
 148            {
 0149                string operation = $"https://{_loginServerUrl}{request.RequestUri.AbsolutePath}";
 0150                string scope = await GetScope(operation, request.Method.Method, request.RequestUri.AbsolutePath);
 151
 0152                request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {GetAcrAccessToken(scope)}");
 153            }
 154
 114155            await base.ProcessHttpRequestAsync(request, cancellationToken);
 114156        }
 157
 158        #endregion
 159
 160        #region Helpers
 161
 162        private static string ProcessLoginUrl(string loginUrl)
 163        {
 164            // in case passed in loginurl includes https start. We also don't want 'http://' to be in the url.
 60165            string[] schemes = new string[] { "https://", "http://" };
 360166            foreach (var scheme in schemes)
 167            {
 120168                if (loginUrl.ToLower().StartsWith(scheme))
 169                {
 0170                    loginUrl.Substring(scheme.Length);
 0171                    break; // strip at most once.
 172                }
 173            }
 174
 60175            if (loginUrl.EndsWith("/"))
 176            {
 0177                loginUrl.Substring(0, loginUrl.Length - 1);
 178            }
 179
 60180            return loginUrl;
 181        }
 182
 183        /// <summary>
 184        /// Acquires a new ACR access token if necessary. It can also acquire a cached access token in order to avoid ex
 185        /// the oauth2 endpoint improving efficiency.
 186        /// <param name='scope'> The scope for the particuar operation. Can be obtained from the Www-Authenticate header
 187        /// </summary>
 188        private string GetAcrAccessToken(string scope)
 189        {
 0190            if (_mode == LoginMode.Basic)
 191            {
 0192                throw new Exception("This Function cannot be invoked for requested Login Mode. Basic Authentication does
 193            }
 194
 195            // if token is stale, hit refresh
 0196            if (_acrAccessTokens.TryGetValue(scope, out ContainerRegistryAccessToken token))
 197            {
 0198                if (!token.CheckAndRefresh())
 199                {
 0200                    throw new Exception($"Access Token for scope {scope} expired and could not be refreshed");
 201                }
 202
 0203                return token.Value;
 204            }
 205
 0206            if (_mode == LoginMode.TokenAad)
 207            {
 0208                _acrAccessTokens[scope] = new ContainerRegistryAccessToken(_acrRefresh, scope, _loginServerUrl);
 209            }
 0210            else if (_mode == LoginMode.TokenAuth)
 211            {
 0212                _acrAccessTokens[scope] = new ContainerRegistryAccessToken(_username, _password, scope, _loginServerUrl)
 213            }
 214
 0215            return _acrAccessTokens[scope].Value;
 216        }
 217
 218        /// <summary>
 219        /// Acquires the required scope for a specific operation. This will be done by obtaining a challenge and parsing
 220        /// from the ww-Authenticate header. In the event of failure (Some endpoints do not seem to return the scope) it
 221        /// resolution through a local resolver <see cref="ResolveScopeLocally">.
 222        /// <param name='scope'> The scope for the particuar operation. Can be obtained from the Www-Authenticate header
 223        /// </summary>
 224
 225        private async Task<string> GetScope(string operation, string method, string path)
 226        {
 0227            string methodOperationKey = $"{method}>{operation}";
 228
 0229            if (_acrScopes.TryGetValue(methodOperationKey, out string result))
 230            {
 0231                return result;
 232            }
 233
 234            string scope;
 235            try
 236            {
 0237                HttpClient runtimeClient = new HttpClient();
 0238                HttpResponseMessage response = await runtimeClient.SendAsync(new HttpRequestMessage(new HttpMethod(metho
 0239                scope = GetScopeFromHeaders(response.Headers)?? ResolveScopeLocally(path);
 0240                _acrScopes[methodOperationKey] = scope;
 0241            }
 0242            catch (Exception e)
 243            {
 0244                throw new Exception($"Could not identify appropriate Token scope: {e.Message}");
 245            }
 0246            return scope;
 0247        }
 248
 249        /// <summary>
 250        /// Local resolver for endpoints that will often return no scope.
 251        /// <param name='operation'> Operation for which a scope is necessary
 252        /// </summary>
 253        private string ResolveScopeLocally(string operation)
 254        {
 255            const string v1Operation = "/acr/v1/_catalog";
 256            const string v2Operation = "/v2/";
 257            switch (operation)
 258            {
 259                case v1Operation:
 260                case v2Operation:
 0261                    return "registry:catalog:*";
 262                default:
 0263                    throw new Exception("Could not determine appropriate scope for the specified operation");
 264            }
 265        }
 266
 267        /// <summary>
 268        /// Parse value of scope key from the 'Www-Authenticate' challenge header. See RFC 7235 section 4.1 for more inf
 269        /// Ex challenge header value:
 270        ///  Bearer realm="https://test.azurecr.io/oauth2/token",service="test.azurecr.io",scope="repository:hello-txt:m
 271        /// Return null if it is not present
 272        /// </summary>
 273        private string GetScopeFromHeaders(HttpHeaders headers)
 274        {
 0275            string challengeHeader = "Www-Authenticate".ToLower();
 0276            string headerValue = "";
 277
 0278            foreach (var headerKVP in headers)
 279            {
 0280                if (headerKVP.Key.ToLower() == challengeHeader)
 281                {
 0282                    headerValue = string.Join(",", headerKVP.Value);
 0283                    break;
 284                }
 285            }
 286
 0287            foreach (string part in headerValue.Split(','))
 288            {
 0289                string[] keyValues = part.Split(new char[] { '=' }, 2);
 0290                if (keyValues.Length != 2)
 291                {
 0292                    throw new Exception($"{challengeHeader} has incorrect format, " +
 0293                        $"header key-value pair '{part}' does not have a value but in '{headerValue}'");
 294                }
 0295                if (keyValues[0].ToLower().Trim() == "scope")
 296                {
 0297                    return TrimDoubleQuotes(keyValues[1]);
 298                }
 299            }
 300
 0301            return null;
 302        }
 303
 304        /// <summary>
 305        /// Removes trailing whitespace or " characters.
 306        /// </summary>
 307        private string TrimDoubleQuotes(string toTrim)
 308        {
 0309            toTrim = toTrim.Trim();
 0310            if (toTrim.StartsWith("\"")) toTrim = toTrim.Substring(1);
 0311            if (toTrim.EndsWith("\"")) toTrim = toTrim.Substring(0, toTrim.Length - 1);
 0312            return toTrim;
 313        }
 314
 315        #endregion
 316    }
 317}
 318
 319
 320