Azure KeyVault is a great service. With this you store secrets on a share
location and could use on several services. But it is not fun to use during
development.
The problem
Azure
KeyVault is
a service where you store your secrets, like connection strings, in a service in
Azure. Then when your application starts you could connect to this service and
it to your configuration. But it has a downside. It will take a couple of
seconds to download the secrets. Not a problem in production, but during
development this is a pain.
The solution
One solution is to not use Azure KeyVault when you do development. Instead, you
store your secrets locally. But then your application will behave quite
differently in production which may be a bad thing.
Another approach I have been using a while is to cache the configuration. And
then I refresh it every 12 hour or so. With this I get the best of both worlds.
I could use Azure KeyVault during development and still have good performance.
My solution is in the following code that is using
Azure.Security.KeyVault.Secrets
that is replacing the deprecated
Microsoft.Azure.KeyVault .
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 ));
}
}
}
The project file looks like this:
<Project Sdk= "Microsoft.NET.Sdk.Worker" >
<PropertyGroup>
<TargetFramework> netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include= "Azure.Extensions.AspNetCore.Configuration.Secrets" Version= "1.0.2" />
<PackageReference Include= "Azure.Identity" Version= "1.3.0" />
<PackageReference Include= "Azure.Security.KeyVault.Secrets" Version= "4.1.0" />
<PackageReference Include= "Microsoft.Extensions.Configuration.AzureAppConfiguration" Version= "4.1.0" />
<PackageReference Include= "Microsoft.Extensions.Hosting" Version= "5.0.0" />
</ItemGroup>
</Project>
Summary
This approach also works for Azure
AppConfiguration
(I use that too), but I have ignored this in the sample above to keep the code
simple. AppConfiguration is also a lot faster than KeyVault so the problem less
noticeable.