Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

VssConnection to VSTS always prompts for credentials

I am using the Visual Studio client tools for calling the VSTS REST APIs in a command line utility. This utility can be run several times for different commands (Copy, Delete, applying policies, etc.)

I'm creating the VssConnection like such

public static VssConnection CreateConnection(Uri url, VssCredentials credentials = null)
{
    credentials = credentials ?? new VssClientCredentials();            

    credentials.Storage = new VssClientCredentialStorage();           

    var connection = new VssConnection(url, credentials);

    connection.ConnectAsync().SyncResult(); 

    return connection;
}

According to the docs, this should be caching the credentials so that you won't get prompted again when running my command line tool. But I get prompted every time I run my command line utility and the VssConnection tries to connect.

Is there anyway to cache the credentials so that the user won't be prompted every time they run the command line?

Should be noted that if I don't dispose the VssConnection, it will not prompt the next time I run it.

UPDATE To be clear, the issue isn't caching the VssClientCredentials instance once the connection is created as that object is attached to the VssConnection object. The issue is caching the user token between execution of the program, i.e. on the local machine so that the next time the utility is executed from the command line the user doesn't have to once again type in their credentials. Similar to how you don't have to always log into Visual Studio each time you fire it up.

like image 556
Jim Avatar asked Nov 10 '17 20:11

Jim


1 Answers

So I found a working solution that seems to be exactly what I wanted. If there is a better solution, please feel free to post.

Solution: Since VssClientCredentials.Storage property is expecting a class that implements IVssCredentialStorage, I created a class that implements that interface by deriving from the stock VssClientCredentialStorage class.

It then overrides the methods around retrieving and removing tokens to manage them based on an expiration lease which is stored with the token in the registry.

If the token is retrieved and has an expired lease, the token is removed from the storage and null is returned and the VssConnection class display a UI forcing the user to enter their credentials. If the token isn't expired, the user is not prompted and the cached credential is used.

So now I can do the following:

  • Call my utility from the command line for the first time
  • Supply credentials to the VSTS client prompt
  • Run the utility again from the command line without being prompted!

Now I've built into my utility a standard lease expiration but the user can alter it with a command line option. Also the user can clear the cached credentials as well.

The key is in the RemoveToken override. The call to the base class is what removes it from the registry, so if you bypass that (in my case if the lease hasn't expired) then the registry entry will remain. This allows the VssConnection to use the cached credentials and not prompt the user each time the program is executed!

Example of the calling code:

    public static VssConnection CreateConnection(Uri url, VssCredentials credentials = null, double tokenLeaseInSeconds = VssClientCredentialCachingStorage.DefaultTokenLeaseInSeconds)
    {
        credentials = credentials ?? new VssClientCredentials();

        credentials.Storage = GetVssClientCredentialStorage(tokenLeaseInSeconds);

        var connection = new VssConnection(url, credentials);

        connection.ConnectAsync().SyncResult(); 

        return connection;
    }

    private static VssClientCredentialCachingStorage GetVssClientCredentialStorage(double tokenLeaseInSeconds)
    {
        return new VssClientCredentialCachingStorage("YourApp", "YourNamespace", tokenLeaseInSeconds);
    }

The derived storage class:

    /// <summary>
    /// Class to alter the credential storage behavior to allow the token to be cached between sessions.
    /// </summary>
    /// <seealso cref="Microsoft.VisualStudio.Services.Common.IVssCredentialStorage" />
    public class VssClientCredentialCachingStorage : VssClientCredentialStorage
    {
        #region [Private]

        private const string __tokenExpirationKey = "ExpirationDateTime";
        private double _tokenLeaseInSeconds;

        #endregion [Private]

        /// <summary>
        /// The default token lease in seconds
        /// </summary>
        public const double DefaultTokenLeaseInSeconds = 86400;// one day

        /// <summary>
        /// Initializes a new instance of the <see cref="VssClientCredentialCachingStorage"/> class.
        /// </summary>
        /// <param name="storageKind">Kind of the storage.</param>
        /// <param name="storageNamespace">The storage namespace.</param>
        /// <param name="tokenLeaseInSeconds">The token lease in seconds.</param>
        public VssClientCredentialCachingStorage(string storageKind = "VssApp", string storageNamespace = "VisualStudio", double tokenLeaseInSeconds = DefaultTokenLeaseInSeconds)
            : base(storageKind, storageNamespace)
        {
            this._tokenLeaseInSeconds = tokenLeaseInSeconds;
        }

        /// <summary>
        /// Removes the token.
        /// </summary>
        /// <param name="serverUrl">The server URL.</param>
        /// <param name="token">The token.</param>
        public override void RemoveToken(Uri serverUrl, IssuedToken token)
        {
            this.RemoveToken(serverUrl, token, false);
        }

        /// <summary>
        /// Removes the token.
        /// </summary>
        /// <param name="serverUrl">The server URL.</param>
        /// <param name="token">The token.</param>
        /// <param name="force">if set to <c>true</c> force the removal of the token.</param>
        public void RemoveToken(Uri serverUrl, IssuedToken token, bool force)
        {
            //////////////////////////////////////////////////////////
            // Bypassing this allows the token to be stored in local
            // cache. Token is removed if lease is expired.

            if (force || token != null && this.IsTokenExpired(token))
                base.RemoveToken(serverUrl, token);

            //////////////////////////////////////////////////////////
        }

        /// <summary>
        /// Retrieves the token.
        /// </summary>
        /// <param name="serverUrl">The server URL.</param>
        /// <param name="credentialsType">Type of the credentials.</param>
        /// <returns>The <see cref="IssuedToken"/></returns>
        public override IssuedToken RetrieveToken(Uri serverUrl, VssCredentialsType credentialsType)
        {
            var token = base.RetrieveToken(serverUrl, credentialsType);            

            if (token != null)
            {
                bool expireToken = this.IsTokenExpired(token);
                if (expireToken)
                {
                    base.RemoveToken(serverUrl, token);
                    token = null;
                }
                else
                {
                    // if retrieving the token before it is expired,
                    // refresh the lease period.
                    this.RefreshLeaseAndStoreToken(serverUrl, token);
                    token = base.RetrieveToken(serverUrl, credentialsType);
                }
            }

            return token;
        }

        /// <summary>
        /// Stores the token.
        /// </summary>
        /// <param name="serverUrl">The server URL.</param>
        /// <param name="token">The token.</param>
        public override void StoreToken(Uri serverUrl, IssuedToken token)
        {
            this.RefreshLeaseAndStoreToken(serverUrl, token);
        }

        /// <summary>
        /// Clears all tokens.
        /// </summary>
        /// <param name="url">The URL.</param>
        public void ClearAllTokens(Uri url = null)
        {
            IEnumerable<VssToken> tokens = this.TokenStorage.RetrieveAll(base.TokenKind).ToList();

            if (url != default(Uri))
                tokens = tokens.Where(t => StringComparer.InvariantCultureIgnoreCase.Compare(t.Resource, url.ToString().TrimEnd('/')) == 0);

            foreach(var token in tokens)
                this.TokenStorage.Remove(token);
        }

        private void RefreshLeaseAndStoreToken(Uri serverUrl, IssuedToken token)
        {
            if (token.Properties == null)
                token.Properties = new Dictionary<string, string>();

            token.Properties[__tokenExpirationKey] = JsonSerializer.SerializeObject(this.GetNewExpirationDateTime());

            base.StoreToken(serverUrl, token);
        }

        private DateTime GetNewExpirationDateTime()
        {
            var now = DateTime.Now;

            // Ensure we don't overflow the max DateTime value
            var lease = Math.Min((DateTime.MaxValue - now.Add(TimeSpan.FromSeconds(1))).TotalSeconds, this._tokenLeaseInSeconds);

            // ensure we don't have negative leases
            lease = Math.Max(lease, 0);

            return now.AddSeconds(lease);            
        }

        private bool IsTokenExpired(IssuedToken token)
        {
            bool expireToken = true;

            if (token != null && token.Properties.ContainsKey(__tokenExpirationKey))
            {
                string expirationDateTimeJson = token.Properties[__tokenExpirationKey];

                try
                {
                    DateTime expiration = JsonSerializer.DeserializeObject<DateTime>(expirationDateTimeJson);

                    expireToken = DateTime.Compare(DateTime.Now, expiration) >= 0;
                }
                catch { }
            }

            return expireToken;
        }
    }
like image 111
Jim Avatar answered Nov 14 '22 21:11

Jim