diff --git a/src/Config.Net/ConfigurationExtensions.cs b/src/Config.Net/ConfigurationExtensions.cs index e25819f..1d701a0 100644 --- a/src/Config.Net/ConfigurationExtensions.cs +++ b/src/Config.Net/ConfigurationExtensions.cs @@ -2,6 +2,8 @@ using Config.Net.Stores; using System.Collections.Generic; using Config.Net.Stores.Impl.CommandLine; +using System.Configuration; +using System.Net.Http; namespace Config.Net { /// @@ -144,5 +146,19 @@ public static ConfigurationBuilder UseJsonString(this Co return builder; } + /// + /// Pull Keyvault Values from a set of keys in the format @Keyvault(some_keyvault_key, secret_version) or @Keyvault(some_keyvault_key) + /// + /// + /// + /// + /// + /// + /// + public static ConfigurationBuilder UseKeyvault(this ConfigurationBuilder builder, Configuration bindingConfiguration, HttpClient httpClient, KeyvaultConfigStoreOptions keyvaultConfigStoreOptions) where TInterface : class { + builder.UseConfigStore(new KeyvaultConfigStore(bindingConfiguration, httpClient, keyvaultConfigStoreOptions)); + return builder; + } + } } \ No newline at end of file diff --git a/src/Config.Net/Stores/KeyvaultConfigStore.cs b/src/Config.Net/Stores/KeyvaultConfigStore.cs new file mode 100644 index 0000000..2a342ba --- /dev/null +++ b/src/Config.Net/Stores/KeyvaultConfigStore.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Config.Net.Stores; + +public class KeyvaultConfigStore : IConfigStore { + private readonly Configuration _configuration; + private readonly HttpClient _httpClient; + private readonly KeyvaultConfigStoreOptions _keyvaultConfigStoreOptions; + private AccessToken _cachedAccessToken; + public KeyvaultConfigStore( + Configuration bindingConfiguration, + HttpClient httpClient, + KeyvaultConfigStoreOptions keyvaultConfigStoreOptions + ) + { + _configuration = bindingConfiguration; + _httpClient = httpClient; + _keyvaultConfigStoreOptions = keyvaultConfigStoreOptions; + } + + public bool CanRead => true; + + public bool CanWrite => false; + private bool TokenExpired => _cachedAccessToken.ExpiresAt <= DateTime.UtcNow.AddSeconds(30) /*provide a 30 second buffer for the ensuing request to succeed*/; + + public void Dispose() { } + /// + /// Keys must be in the format @Keyvault(my_key_name) or @Keyvault(my_key_name, my_secret_version) + /// + /// + /// + public string? Read(string key) + { + KeyValueConfigurationElement keyvaultKey = _configuration.AppSettings.Settings[key]; + + if(keyvaultKey == null) + { + return null; + } + + // group 1 is the combination my_key_name, my_secret_version + // group 2 is the keyname if there is a version + // group 3 is the secret version + // group 4 is the solo keyname + Regex kvCheckRegex = new Regex(@"@Keyvault\(((.*),(.*)|(.*))\)"); + + Match match = kvCheckRegex.Match(keyvaultKey.Value); + + if (!match.Success) { return null; } + + ParsedKeyvaultSecret parsedKeyvaultSecret; + + if(string.IsNullOrEmpty(match.Groups.Values.ElementAtOrDefault(2)?.Value) || string.IsNullOrEmpty(match.Groups.Values.ElementAtOrDefault(3)?.Value)) + { + parsedKeyvaultSecret = new ParsedKeyvaultSecret() { + KeyvaultName = match.Groups.Values.ElementAt(4).Value.Trim() + }; + } + else + { + parsedKeyvaultSecret = new ParsedKeyvaultSecret() + { + KeyvaultName = match.Groups.Values.ElementAt(2).Value.Trim(), + Version = match.Groups.Values.ElementAtOrDefault(3)?.Value.Trim() + }; + } + + + + if(TokenExpired) + { + GetMicrosoftBearerToken().Wait(); + } + + _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _cachedAccessToken.Token); + + HttpResponseMessage responseMessage; + + if(parsedKeyvaultSecret.Version != null) + { + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{parsedKeyvaultSecret.KeyvaultName}/{parsedKeyvaultSecret.Version}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + } + else + { + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{parsedKeyvaultSecret.KeyvaultName}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + + } + + responseMessage.EnsureSuccessStatusCode(); + + string responseString = responseMessage.Content.ReadAsStringAsync().Result; + + SecretBundle secretBundle = JsonSerializer.Deserialize(responseString); + + _httpClient.DefaultRequestHeaders.Authorization = null; + + return secretBundle.Value; + } + public void Write(string key, string? value) => throw new System.NotImplementedException(); + + private async Task GetMicrosoftBearerToken() + { + HttpResponseMessage response = + await _httpClient.PostAsync($"https://login.microsoftonline.com/{_keyvaultConfigStoreOptions.TenantId}/oauth2/v2.0/token", + new FormUrlEncodedContent(new Dictionary() + { + { "grant_type", "client_credentials"}, + { "client_id", _keyvaultConfigStoreOptions.ClientId }, + { "client_secret", _keyvaultConfigStoreOptions.ClientSecret }, + { "scope", "https://vault.azure.net/.default" } + }) + ); + + string responseString = await response.Content.ReadAsStringAsync(); + + MsAccessToken msAccessToken = JsonSerializer.Deserialize(responseString); + + string rawToken = msAccessToken.AccessToken; + + string rawClaims = rawToken.Split('.')[1].Trim(); + + rawClaims = rawClaims.PadRight(rawClaims.Length + ((4 - (rawClaims.Length % 4)) % 4), '='); + + byte[] claims = Convert.FromBase64String(rawClaims); + + Dictionary claimMap = JsonSerializer.Deserialize>(claims) ?? throw new NullReferenceException("Failed To Deserialize Claims"); + + JsonElement? exp = claimMap["exp"]; + + if (exp == null) + { + throw new InvalidOperationException("Claims are missing Expiration"); + } + + _cachedAccessToken = new AccessToken() { + Token = rawToken, + ExpiresAt = new DateTime(exp.Value.GetInt64()) + }; + + } + + private struct AccessToken + { + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + } + private struct MsAccessToken + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + [JsonPropertyName("scope")] + public string Scope { get; set; } + } + private struct ParsedKeyvaultSecret + { + public string KeyvaultName { get; set; } + public string? Version { get; set; } + } + + private struct SecretBundle + { + [JsonPropertyName("attributes")] + public object Attributes { get; set; } + [JsonPropertyName("id")] + public string Id { get; set; } + [JsonPropertyName("kid")] + public string Kid { get; set; } + [JsonPropertyName("managed")] + public bool Managed { get; set; } + [JsonPropertyName("tags")] + public object Tags { get; set; } + [JsonPropertyName("value")] + public string Value { get; set; } + } + +} + +public class KeyvaultConfigStoreOptions +{ + public KeyvaultConfigStoreOptions(Uri keyvaultUri, string tenantId, string clientId, string clientSecret, string apiVersion = "7.4") { + KeyvaultUri = keyvaultUri; + TenantId = tenantId; + ClientId = clientId; + ClientSecret = clientSecret; + ApiVersion = apiVersion; + } + + public Uri KeyvaultUri { get; set; } + public string TenantId { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string ApiVersion { get; set; } +}