10

I’m having this very simple .NET Core application:

    static void Main(string[] args)
    {
        var builder = new ConfigurationBuilder()
           .SetBasePath(Directory.GetCurrentDirectory())
           .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);

        builder.AddAzureKeyVault("https://MyKeyVault.vault.azure.net");

        var stopwatch = new Stopwatch();
        stopwatch.Start(); 
        var configuration = builder.Build();
        var elapsed = stopwatch.Elapsed;

        Console.WriteLine($"Elapsed time: {elapsed.TotalSeconds}");
    }

The csproj-file looks like this:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Configuration" Version="2.1.1" />
  <PackageReference Include="Microsoft.Extensions.Configuration.AzureKeyVault" Version="2.1.1" />
  <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.1.1" />
  <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="2.1.1" />
  <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.1.1" />
</ItemGroup>

</Project>

My problem is that the application takes about 10 seconds to execute with a debugger attached (about 5 seconds without a debugger). If I remove the line with AddAzureKeyVault the application is executed in less than a second. I know that AddAzureKeyVault will make the application connect to Azure and read values from a key vault but I expected this to be a lot faster.

Is this an expected behaviour? Is there anything I could do to make this faster?

Joey Cai
  • 18,968
  • 1
  • 20
  • 30
PEK
  • 3,688
  • 2
  • 31
  • 49
  • this code will only be executed on startup so is it a real problem ? using the clientid and client cert you have to store these secrets in your app settings – Thomas Nov 01 '18 at 00:13
  • 4
    It’s not a problem in production. But if you are using KeyVault also when you are doing development those extra seconds are annoying. – PEK Nov 01 '18 at 06:21
  • so put your local secrets in your appsetting json file and just enable the addazurekeyvault in release mode ? – Thomas Nov 01 '18 at 07:36
  • 1
    I could do that but I’m trying to avoid storing any secrets locally. Also, having connection strings etc in one single place makes it easier to configure all applications I have in my solution. – PEK Nov 01 '18 at 16:20

3 Answers3

7

For the Microsoft.Azure.Services.AppAuthentication library, see the original answer. For the newer Azure.Identity library, see Update 2021-03-22.


Original Answer:

Yes, configure the AzureServiceTokenProvider explicitly to use the az cli for authentication. You can do this by setting an environment variable named AzureServicesAuthConnectionString.

Bash:

export AzureServicesAuthConnectionString="RunAs=Developer; DeveloperTool=AzureCli"

PowerShell:

$Env:AzureServicesAuthConnectionString = "RunAs=Developer; DeveloperTool=AzureCli"

Note that the environment variable needs to be set in whatever session you're running your app.

Explanation

The root of the problem is hinted at in MS docs on authentication, which state, "By default, AzureServiceTokenProvider uses multiple methods to retrieve a token."

Of the multiple methods used, az cli authentication is a ways down the list. So the AzureServiceTokenProvider takes some time to try other auth methods higher in the pecking order before finally using the az cli as the token source. Setting the connection string in the environment variable removes the time you spend waiting for other auth methods to fail.

This solution is preferable to hardcoding a clientId and clientSecret not only for convenience, but also because az cli auth doesn't require hardcoding your clientSecret or storing it in plaintext.


UPDATE (2021-03-22)

The Azure.Identity auth provider, compatible with newer Azure client SDKs (like Azure.Security.KeyVault.Secrets) has code-based options (instead of a connection string) to skip certain authentication methods. You can:

  1. set exclusions in the DefaultAzureCredential constructor, or

  2. generate a TokenCredential using more specific class type constructors (see also the auth provider migration chart here).

jschmitter
  • 1,669
  • 19
  • 29
  • 2
    Nice trick. To use this, you need first to run the command **az login** in a terminal, or if you are using Visual Studio use the connection string **RunAs=Developer; DeveloperTool=VisualStudio instead**. You could also setup the environment variables in **launchSettings.json**. All this said I find the clientid solution to be faster. – PEK Apr 13 '20 at 06:43
  • @PEK said "I find the clientid solution to be faster". Faster in terms of startup or implementation? Startup was taking about 3 seconds for me using this method. – jschmitter Apr 13 '20 at 14:25
  • Good question, @jschmitter. Without any changes I had a start-up time of 30 seconds. With the connection string solution I got 10 seconds. And with the client id solution I got 7 seconds. – PEK Apr 14 '20 at 19:14
  • @jschmitter and @PEK, I salute you. When I added `"AzureServicesAuthConnectionString": "RunAs=Developer; DeveloperTool=AzureCli"` under `EnvironmentVariables` in LaunchSettings.json, the delay disappeared almost completely. – Timo Oct 19 '20 at 15:28
  • This solution works for the packet library `Microsoft.Azure.KeyVault`. But with the new `Azure.Security.KeyVault.Secrets` do no support `AzureServicesAuthConnectionString` as I understand it. And in my measurements, it is no longer necessary. – PEK Mar 21 '21 at 12:50
  • 1
    @PEK I've updated my answer for options with the Azure.Identity library which has replacement options for AzureServicesAuthConnectionString. – jschmitter Mar 22 '21 at 18:16
  • Well done @jschmitter, I tried to exclude all but one for comparison. Just keeping SharedTokenCacheCredential was the fasted method for me. About 1-2 seconds faster than the default settings. – PEK Mar 24 '21 at 17:11
1

You could try to get azure keyvault with clientId and clientSecret and it may run faster.

builder.AddAzureKeyVault("https://yourkeyvaultname.vault.azure.net", clientId,clinetSecret);

And I test with it and it costs 3 seconds.

enter image description here

For more details, you could refer to this article.

Joey Cai
  • 18,968
  • 1
  • 20
  • 30
  • Thanks, this was major improvement for me. In a more complicated application I went from 25 seconds to 4 seconds. I found good instructions how to setup client id and secret on this page: https://learn.microsoft.com/sv-se/azure/key-vault/key-vault-get-started#register – PEK Oct 31 '18 at 07:10
  • 2
    This solution works for the deprecated packet `Microsoft.Azure.KeyVault`. But with the new `Azure.Security.KeyVault.Secrets` no longer needs this. In my measurements, using a clientId instead of the default has no significant difference. – PEK Mar 21 '21 at 12:49
1

The previously suggested solutions with clientId and AzureServiceTokenProvider do have an affect in the deprecated packet Microsoft.Azure.KeyVault. But with the new packet Azure.Security.KeyVault.Secrets these solutions are no longer necessary in my measurements.

My solution is to cache the configuration from Azure KeyVault and store that configuration locally. With this solution you will be able to use Azure KeyVault during development and still have a great performance. This following code shows how to do this:

using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;

namespace ConfigurationCache
{
    public class Program
    {
        private static readonly Stopwatch Stopwatch = new Stopwatch();

        public static void Main(string[] args)
        {
            Stopwatch.Start();
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((ctx, builder) =>
                {
                    builder.AddAzureConfigurationServices();
                })
                .ConfigureServices((hostContext, services) =>
                {
                    Stopwatch.Stop();

                    Console.WriteLine($"Start time: {Stopwatch.Elapsed}");
                    Console.WriteLine($"Config: {hostContext.Configuration.GetSection("ConnectionStrings:MyContext").Value}");

                    services.AddHostedService<Worker>();
                });
    }

    public static class AzureExtensions
    {
        public static IConfigurationBuilder AddAzureConfigurationServices(this IConfigurationBuilder builder)
        {
            // Build current configuration. This is later used to get environment variables.
            IConfiguration config = builder.Build();

#if DEBUG
            if (Debugger.IsAttached)
            {
                // If the debugger is attached, we use cached configuration instead of
                // configurations from Azure.
                AddCachedConfiguration(builder, config);

                return builder;
            }
#endif

            // Add the standard configuration services
            return AddAzureConfigurationServicesInternal(builder, config);
        }

        private static IConfigurationBuilder AddAzureConfigurationServicesInternal(IConfigurationBuilder builder, IConfiguration currentConfig)
        {
            // Get keyvault endpoint. This is normally an environment variable.
            string keyVaultEndpoint = currentConfig["KEYVAULT_ENDPOINT"];

            // Setup keyvault services
            SecretClient secretClient = new SecretClient(new Uri(keyVaultEndpoint), new DefaultAzureCredential());
            builder.AddAzureKeyVault(secretClient, new AzureKeyVaultConfigurationOptions());

            return builder;
        }

        private static void AddCachedConfiguration(IConfigurationBuilder builder, IConfiguration currentConfig)
        {
            //Setup full path to cached configuration file.
            string path = System.IO.Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                "MyApplication");
            string filename = System.IO.Path.Combine(path, $"configcache.dat");

            // If the file does not exists, or is more than 12 hours, update the cached configuration.
            if (!System.IO.File.Exists(filename) || System.IO.File.GetLastAccessTimeUtc(filename).AddHours(12) < DateTime.UtcNow)
            {
                System.IO.Directory.CreateDirectory(path);

                UpdateCacheConfiguration(filename, currentConfig);
            }

            // Read the file
            string encryptedFile = System.IO.File.ReadAllText(filename);

            // Decrypt the content
            string jsonString = Decrypt(encryptedFile);

            // Create key-value pairs
            var keyVaultPairs = JsonSerializer.Deserialize<Dictionary<string, string>>(jsonString);

            // Use the key-value pairs as configuration
            builder.AddInMemoryCollection(keyVaultPairs);
        }

        private static void UpdateCacheConfiguration(string filename, IConfiguration currentConfig)
        {
            // Create a configuration builder. We will just use this to get the
            // configuration from Azure.
            ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();

            // Add the services we want to use.
            AddAzureConfigurationServicesInternal(configurationBuilder, currentConfig);

            // Build the configuration
            IConfigurationRoot azureConfig = configurationBuilder.Build();

            // Serialize the configuration to a JSON-string.
            string jsonString = JsonSerializer.Serialize(
                azureConfig.AsEnumerable().ToDictionary(a => a.Key, a => a.Value),
                options: new JsonSerializerOptions()
                {
                    WriteIndented = true
                }
                );

            //Encrypt the string
            string encryptedString = Encrypt(jsonString);

            // Save the encrypted string.
            System.IO.File.WriteAllText(filename, encryptedString);
        }

        // Replace the following with your favorite encryption code.

        private static string Encrypt(string str)
        {
            return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(str));
        }

        private static string Decrypt(string str)
        {
            return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(str));
        }
    }
}
PEK
  • 3,688
  • 2
  • 31
  • 49