diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs index c781063bd..c49395a51 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs @@ -17,6 +17,8 @@ public class AzureAppConfigurationKeyVaultOptions internal TokenCredential Credential; internal List SecretClients = new List(); internal Func> SecretResolver; + internal Dictionary SecretRefreshIntervals = new Dictionary(); + internal TimeSpan? DefaultSecretRefreshInterval = null; /// /// Sets the credentials used to authenticate to key vaults that have no registered . @@ -52,5 +54,33 @@ public AzureAppConfigurationKeyVaultOptions SetSecretResolver(Func + /// Sets the refresh interval for periodically reloading a secret from Key Vault. + /// Any refresh operation triggered using will not update the value for a Key Vault secret until the cached value for that secret has expired. + /// + /// Key of the Key Vault reference in Azure App Configuration. + /// Minimum time that must elapse before the secret is reloaded from Key Vault. + public AzureAppConfigurationKeyVaultOptions SetSecretRefreshInterval(string secretReferenceKey, TimeSpan refreshInterval) + { + if (string.IsNullOrEmpty(secretReferenceKey)) + { + throw new ArgumentNullException(nameof(secretReferenceKey)); + } + + SecretRefreshIntervals[secretReferenceKey] = refreshInterval; + return this; + } + + /// + /// Sets the refresh interval for periodically reloading all those secrets which do not have individual refresh intervals. + /// Any refresh operation triggered using will not update the value for a Key Vault secret until the cached value for that secret has expired. + /// + /// Minimum time that must elapse before the secrets are reloaded from Key Vault. + public AzureAppConfigurationKeyVaultOptions SetSecretRefreshInterval(TimeSpan refreshInterval) + { + DefaultSecretRefreshInterval = refreshInterval; + return this; + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 860900861..43e52d1f7 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -336,7 +336,7 @@ public AzureAppConfigurationOptions ConfigureKeyVault(Action a is AzureKeyVaultKeyValueAdapter); - _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions.Credential, keyVaultOptions.SecretClients, keyVaultOptions.SecretResolver))); + _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); return this; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 319ab865e..5bd9cb74f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -167,6 +167,7 @@ public async Task RefreshAsync() await RefreshIndividualKeyValues().ConfigureAwait(false); await RefreshKeyValueCollections().ConfigureAwait(false); + await RefreshKeyValueAdapters().ConfigureAwait(false); } finally { @@ -294,6 +295,12 @@ await CallWithRequestTracing(async () => if (data != null) { + // Invalidate all the cached KeyVault secrets + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.InvalidateCache(); + } + await SetData(data, ignoreFailures).ConfigureAwait(false); // Set the cache expiration time for all refresh registered settings @@ -429,11 +436,17 @@ await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Wa hasChanged = true; - // Add the key-value if it is not loaded, or update it if it was loaded with a different label - _applicationSettings[watchedKey] = watchedKv; - _watchedSettings[watchedKeyLabel] = watchedKv; + // Add the key-value if it is not loaded, or update it if it was loaded with a different label + _applicationSettings[watchedKey] = watchedKv; + _watchedSettings[watchedKeyLabel] = watchedKv; + + // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.InvalidateCache(watchedKv); + } + } } - } if (hasChanged) { @@ -448,6 +461,14 @@ await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Wa } } + private async Task RefreshKeyValueAdapters() + { + if (_options.Adapters.Any(adapter => adapter.NeedsRefresh())) + { + SetData(_applicationSettings); + } + } + private async Task RefreshKeyValueCollections() { foreach (KeyValueWatcher changeWatcher in _options.MultiKeyWatchers) @@ -581,6 +602,12 @@ private void ProcessChanges(IEnumerable changes) { _applicationSettings[change.Key] = change.Current; } + + // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.InvalidateCache(change.Current); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index 77a27c2e0..3fc84d833 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -50,7 +50,7 @@ public async Task>> ProcessKeyValue(Con try { - secret = await _secretProvider.GetSecretValue(secretUri, cancellationToken).ConfigureAwait(false); + secret = await _secretProvider.GetSecretValue(secretUri, setting.Key, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is UnauthorizedAccessException || (e.Source?.Equals(AzureIdentityAssemblyName, StringComparison.OrdinalIgnoreCase) ?? false)) { @@ -84,5 +84,22 @@ public bool CanProcess(ConfigurationSetting setting) string contentType = setting?.ContentType?.Split(';')[0].Trim(); return string.Equals(contentType, KeyVaultConstants.ContentType); } + + public void InvalidateCache(ConfigurationSetting setting = null) + { + if (setting == null) + { + _secretProvider.ClearCache(); + } + else + { + _secretProvider.RemoveSecretFromCache(setting.Key); + } + } + + public bool NeedsRefresh() + { + return _secretProvider.ShouldRefreshKeyVaultSecrets(); + } } } \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs index a1a8d7bbe..4fbfc627d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Azure.Core; using Azure.Security.KeyVault.Secrets; using System; using System.Collections.Generic; @@ -13,19 +12,21 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault { internal class AzureKeyVaultSecretProvider { + private readonly AzureAppConfigurationKeyVaultOptions _keyVaultOptions; private readonly IDictionary _secretClients; - private readonly TokenCredential _credential; - private readonly Func> _secretResolver; + private readonly Dictionary _cachedKeyVaultSecrets; + private string _nextRefreshKey; + private DateTimeOffset? _nextRefreshTime; - public AzureKeyVaultSecretProvider(TokenCredential credential = null, IEnumerable secretClients = null, Func> secretResolver = null) + public AzureKeyVaultSecretProvider(AzureAppConfigurationKeyVaultOptions keyVaultOptions = null) { - _credential = credential; + _keyVaultOptions = keyVaultOptions ?? new AzureAppConfigurationKeyVaultOptions(); + _cachedKeyVaultSecrets = new Dictionary(StringComparer.OrdinalIgnoreCase); _secretClients = new Dictionary(StringComparer.OrdinalIgnoreCase); - _secretResolver = secretResolver; - if (secretClients != null) + if (_keyVaultOptions.SecretClients != null) { - foreach (SecretClient client in secretClients) + foreach (SecretClient client in _keyVaultOptions.SecretClients) { string keyVaultId = client.VaultUri.Host; _secretClients[keyVaultId] = client; @@ -33,27 +34,30 @@ public AzureKeyVaultSecretProvider(TokenCredential credential = null, IEnumerabl } } - public async Task GetSecretValue(Uri secretUri, CancellationToken cancellationToken) + public async Task GetSecretValue(Uri secretUri, string key, CancellationToken cancellationToken) { - if (secretUri == null) - { - throw new ArgumentNullException(nameof(secretUri)); - } - string secretName = secretUri?.Segments?.ElementAtOrDefault(2)?.TrimEnd('/'); string secretVersion = secretUri?.Segments?.ElementAtOrDefault(3)?.TrimEnd('/'); string secretValue; SecretClient client = GetSecretClient(secretUri); - if (client != null) + if (_cachedKeyVaultSecrets.TryGetValue(key, out CachedKeyVaultSecret cachedSecret) && + (!cachedSecret.RefreshAt.HasValue || DateTimeOffset.UtcNow < cachedSecret.RefreshAt.Value)) { - KeyVaultSecret secret = await client.GetSecretAsync(secretName, secretVersion, cancellationToken).ConfigureAwait(false); + secretValue = cachedSecret.SecretValue; + } + else if (client != null) + { + KeyVaultSecret secret; + secret = await client.GetSecretAsync(secretName, secretVersion, cancellationToken).ConfigureAwait(false); secretValue = secret?.Value; + SetSecretInCache(key, secretValue); } - else if (_secretResolver != null) + else if (_keyVaultOptions.SecretResolver != null) { - secretValue = await _secretResolver(secretUri).ConfigureAwait(false); + secretValue = await _keyVaultOptions.SecretResolver(secretUri).ConfigureAwait(false); + SetSecretInCache(key, secretValue); } else { @@ -63,6 +67,28 @@ public async Task GetSecretValue(Uri secretUri, CancellationToken cancel return secretValue; } + public bool ShouldRefreshKeyVaultSecrets() + { + return _nextRefreshTime.HasValue && _nextRefreshTime.Value < DateTimeOffset.UtcNow; + } + + public void ClearCache() + { + _cachedKeyVaultSecrets.Clear(); + _nextRefreshKey = null; + _nextRefreshTime = null; + } + + public void RemoveSecretFromCache(string key) + { + _cachedKeyVaultSecrets.Remove(key); + + if (key == _nextRefreshKey) + { + UpdateNextRefreshableSecretFromCache(); + } + } + private SecretClient GetSecretClient(Uri secretUri) { string keyVaultId = secretUri.Host; @@ -72,14 +98,61 @@ private SecretClient GetSecretClient(Uri secretUri) return client; } - if (_credential == null) + if (_keyVaultOptions.Credential == null) { return null; } - client = new SecretClient(new Uri(secretUri.GetLeftPart(UriPartial.Authority)), _credential); + client = new SecretClient(new Uri(secretUri.GetLeftPart(UriPartial.Authority)), _keyVaultOptions.Credential); _secretClients.Add(keyVaultId, client); return client; } + + private void SetSecretInCache(string key, string secretValue) + { + DateTimeOffset? refreshSecretAt = null; + + if (_keyVaultOptions.SecretRefreshIntervals.TryGetValue(key, out TimeSpan refreshInterval)) + { + refreshSecretAt = DateTimeOffset.UtcNow.Add(refreshInterval); + } + else if (_keyVaultOptions.DefaultSecretRefreshInterval.HasValue) + { + refreshSecretAt = DateTimeOffset.UtcNow.Add(_keyVaultOptions.DefaultSecretRefreshInterval.Value); + } + + _cachedKeyVaultSecrets[key] = new CachedKeyVaultSecret(secretValue, refreshSecretAt); + + if (key == _nextRefreshKey) + { + UpdateNextRefreshableSecretFromCache(); + } + else if ((refreshSecretAt.HasValue && _nextRefreshTime.HasValue && refreshSecretAt.Value < _nextRefreshTime.Value) + || (refreshSecretAt.HasValue && !_nextRefreshTime.HasValue)) + { + _nextRefreshKey = key; + _nextRefreshTime = refreshSecretAt.Value; + } + } + + private void UpdateNextRefreshableSecretFromCache() + { + _nextRefreshKey = null; + _nextRefreshTime = DateTimeOffset.MaxValue; + + foreach (KeyValuePair secret in _cachedKeyVaultSecrets) + { + if (secret.Value.RefreshAt.HasValue && secret.Value.RefreshAt.Value < _nextRefreshTime) + { + _nextRefreshTime = secret.Value.RefreshAt; + _nextRefreshKey = secret.Key; + } + } + + if (_nextRefreshTime == DateTimeOffset.MaxValue) + { + _nextRefreshTime = null; + } + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/CachedKeyVaultSecret.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/CachedKeyVaultSecret.cs new file mode 100644 index 000000000..e5737120b --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/CachedKeyVaultSecret.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault +{ + internal class CachedKeyVaultSecret + { + ///// + ///// The value of the Key Vault secret. + ///// + public string SecretValue { get; set; } + + /// + /// The time when this secret should be reloaded from Key Vault. + /// + public DateTimeOffset? RefreshAt { get; set; } + + public CachedKeyVaultSecret(string secretValue, DateTimeOffset? refreshAt) + { + SecretValue = secretValue; + RefreshAt = refreshAt; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index e8aaf181b..61afbd986 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -89,5 +89,15 @@ public bool CanProcess(ConfigurationSetting setting) return string.Equals(contentType, FeatureManagementConstants.ContentType) || setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); } + + public void InvalidateCache(ConfigurationSetting setting = null) + { + return; + } + + public bool NeedsRefresh() + { + return false; + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs index 31839fe3d..c72cc7857 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs @@ -13,5 +13,9 @@ internal interface IKeyValueAdapter Task>> ProcessKeyValue(ConfigurationSetting setting, CancellationToken cancellationToken); bool CanProcess(ConfigurationSetting setting); + + void InvalidateCache(ConfigurationSetting setting = null); + + bool NeedsRefresh(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 176e202b0..f245dee97 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -89,5 +89,15 @@ public bool CanProcess(ConfigurationSetting setting) return false; } + + public void InvalidateCache(ConfigurationSetting setting = null) + { + return; + } + + public bool NeedsRefresh() + { + return false; + } } } diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index aaecd577b..e41f7f85f 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -12,6 +12,7 @@ using Moq; using System; using System.Collections.Generic; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -21,8 +22,11 @@ namespace Tests.AzureAppConfiguration public class KeyVaultReferenceTests { string _secretValue = "SecretValue from KeyVault"; + string _certValue = "Certificate Value from KeyVault"; string _secretUri = "https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"; + ConfigurationSetting sentinelKv = new ConfigurationSetting("Sentinel", "Value1"); + ConfigurationSetting _kv = ConfigurationModelFactory.ConfigurationSetting( key: "TestKey1", value: @" @@ -32,6 +36,15 @@ public class KeyVaultReferenceTests eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), contentType: KeyVaultConstants.ContentType + "; charset=utf-8"); + ConfigurationSetting _kvCertRef = ConfigurationModelFactory.ConfigurationSetting( + key: "TestCertificateKey", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/certificates/TestCertificate"" + }", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"); + ConfigurationSetting _kvNoUrl = ConfigurationModelFactory.ConfigurationSetting( key: "TestKey1", value: "Test", @@ -122,6 +135,56 @@ public void UseSecret() Assert.Equal(_secretValue, configuration[_kv.Key]); } + [Fact] + public void UseCertificate() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _kvCertRef })); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _certValue)))); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object)); + }) + .Build(); + + Assert.Equal(_certValue, configuration[_kvCertRef.Key]); + } + + [Fact] + public void UseNullSecretValueWhenSecretNotFound() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _kv })); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(null))); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object)); + }) + .Build(); + + Assert.Null(configuration[_kv.Key]); + } + [Fact] public void DisabledSecretIdentifier() { @@ -358,6 +421,7 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() .Returns(true); mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny())) .Throws(new KeyVaultReferenceException("Key vault error", null)); + mockKeyValueAdapter.Setup(adapter => adapter.InvalidateCache(null)); new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -506,5 +570,285 @@ public void DontUseSecretResolverCallbackWhenMatchingSecretClientIsPresent() Assert.Equal(_secretValue, configuration[_kv.Key]); } + + [Fact] + public void SecretIsReturnedFromCacheIfSecretCacheHasNotExpired() + { + IConfigurationRefresher refresher = null; + TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + + Response GetTestKey(string key, string label, CancellationToken cancellationToken) + { + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + } + + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + var unchanged = sentinelKv.Key == setting.Key && sentinelKv.Label == setting.Label && sentinelKv.Value == setting.Value; + var response = new MockResponse(unchanged ? 304 : 200); + return Response.FromValue(sentinelKv, response); + } + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _kv })); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + kv.SetSecretRefreshInterval(_kv.Key, TimeSpan.FromDays(1)); + }); + + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("Sentinel") + .SetCacheExpiration(cacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Value1", config["Sentinel"]); + Assert.Equal(_secretValue, config[_kv.Key]); + + // Update sentinel key-value + sentinelKv.Value = "Value2"; + Thread.Sleep(cacheExpirationTime); + refresher.RefreshAsync().Wait(); + + Assert.Equal("Value2", config["Sentinel"]); + Assert.Equal(_secretValue, config[_kv.Key]); + + // Validate that only 1 call was made to fetch secrets from KeyVault + // Since Key Vault refresh interval has not elapsed, the sentinel key change should fetch secret from Key Vault + mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void CachedSecretIsInvalidatedWhenRefreshAllIsTrue() + { + IConfigurationRefresher refresher = null; + TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + + Response GetTestKey(string key, string label, CancellationToken cancellationToken) + { + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + } + + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + var unchanged = sentinelKv.Key == setting.Key && sentinelKv.Label == setting.Label && sentinelKv.Value == setting.Value; + var response = new MockResponse(unchanged ? 304 : 200); + return Response.FromValue(sentinelKv, response); + } + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _kv })); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + kv.SetSecretRefreshInterval(_kv.Key, TimeSpan.FromDays(1)); + }); + + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("Sentinel", refreshAll: true) + .SetCacheExpiration(cacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Value1", config["Sentinel"]); + Assert.Equal(_secretValue, config[_kv.Key]); + + // Update sentinel key-value to trigger refresh operation + sentinelKv.Value = "Value2"; + Thread.Sleep(cacheExpirationTime); + refresher.RefreshAsync().Wait(); + + Assert.Equal("Value2", config["Sentinel"]); + Assert.Equal(_secretValue, config[_kv.Key]); + + // Validate that 2 calls were made to fetch secrets from KeyVault + // Even though Key Vault refresh interval has not elapsed, refreshAll trigger should fetch secret from Key Vault again + mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void SecretIsReloadedFromKeyVaultWhenCacheExpires() + { + IConfigurationRefresher refresher = null; + TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _kv })); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + kv.SetSecretRefreshInterval(_kv.Key, cacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal(_secretValue, config[_kv.Key]); + + // Sleep to let the secret cache expire + Thread.Sleep(cacheExpirationTime); + refresher.RefreshAsync().Wait(); + + Assert.Equal(_secretValue, config[_kv.Key]); + + // Validate that 2 calls were made to fetch secrets from KeyVault because the secret cache had expired. + mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void SecretsWithDefaultRefreshInterval() + { + IConfigurationRefresher refresher = null; + TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollectionPageOne)); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + kv.SetSecretRefreshInterval(shortCacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal(_secretValue, config["TK1"]); + Assert.Equal(_secretValue, config["TK2"]); + + // Sleep to let the secret cache expire for both secrets + Thread.Sleep(shortCacheExpirationTime); + refresher.RefreshAsync().Wait(); + + Assert.Equal(_secretValue, config["TK1"]); + Assert.Equal(_secretValue, config["TK2"]); + + // Validate that 4 calls were made to fetch secrets from KeyVault because the secret cache had expired for both secrets. + mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(4)); + } + + [Fact] + public void SecretsWithDifferentRefreshIntervals() + { + IConfigurationRefresher refresher = null; + TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan longCacheExpirationTime = TimeSpan.FromDays(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollectionPageOne)); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + kv.SetSecretRefreshInterval("TK1", shortCacheExpirationTime); + kv.SetSecretRefreshInterval(longCacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal(_secretValue, config["TK1"]); + Assert.Equal(_secretValue, config["TK2"]); + + // Sleep to let the secret cache expire for one secret + Thread.Sleep(shortCacheExpirationTime); + refresher.RefreshAsync().Wait(); + + Assert.Equal(_secretValue, config["TK1"]); + Assert.Equal(_secretValue, config["TK2"]); + + // Validate that 3 calls were made to fetch secrets from KeyVault because the secret cache had expired for only one secret. + mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); + } } }