From b7b2dd91a25cffb19f3e35ba030f25fd60735104 Mon Sep 17 00:00:00 2001 From: Mason Adams Date: Wed, 5 Mar 2025 09:33:22 -0800 Subject: [PATCH 1/5] initial commit --- src/Config.Net/ConfigurationExtensions.cs | 7 + src/Config.Net/KeyvaultSecretAttribute.cs | 10 ++ src/Config.Net/Stores/KeyvaultConfigStore.cs | 179 +++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/Config.Net/KeyvaultSecretAttribute.cs create mode 100644 src/Config.Net/Stores/KeyvaultConfigStore.cs diff --git a/src/Config.Net/ConfigurationExtensions.cs b/src/Config.Net/ConfigurationExtensions.cs index e25819f..b075956 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,10 @@ public static ConfigurationBuilder UseJsonString(this Co return builder; } + 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/KeyvaultSecretAttribute.cs b/src/Config.Net/KeyvaultSecretAttribute.cs new file mode 100644 index 0000000..6559db4 --- /dev/null +++ b/src/Config.Net/KeyvaultSecretAttribute.cs @@ -0,0 +1,10 @@ +using System; + +namespace Config.Net; +/// +/// Usage: Connects to an azure keyvault in order to pull the secret by the name provided as the field value. +/// +[AttributeUsage(AttributeTargets.Property)] +public class KeyvaultSecretAttribute : Attribute +{ +} diff --git a/src/Config.Net/Stores/KeyvaultConfigStore.cs b/src/Config.Net/Stores/KeyvaultConfigStore.cs new file mode 100644 index 0000000..8d82420 --- /dev/null +++ b/src/Config.Net/Stores/KeyvaultConfigStore.cs @@ -0,0 +1,179 @@ +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.AddSeconds(-30) /*provide a 30 second buffer for the ensuing request to succeed*/ <= DateTime.UtcNow; + + 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(key); + + if (!match.Success) { return null; } + + ParsedKeyvaultSecret parsedKeyvaultSecret = new ParsedKeyvaultSecret() + { + KeyvaultName = match.Groups.Values.ElementAtOrDefault(2)?.Value ?? match.Groups.Values.ElementAt(4).Value, + Version = match.Groups.Values.ElementAtOrDefault(3)?.Value + }; + + if(TokenExpired) + { + GetMicrosoftBearerToken().Wait(); + } + + HttpResponseMessage responseMessage; + + if(parsedKeyvaultSecret.Version != null) + { + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{keyvaultKey}/{parsedKeyvaultSecret.Version}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + } + else + { + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{keyvaultKey}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + + } + + responseMessage.EnsureSuccessStatusCode(); + + SecretBundle secretBundle = JsonSerializer.Deserialize(responseMessage.Content.ReadAsStringAsync().Result); + + 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://graph.microsoft.com/.default" } + }) + ); + + string responseString = await response.Content.ReadAsStringAsync(); + + MsAccessToken msAccessToken = JsonSerializer.Deserialize(responseString); + + string rawToken = msAccessToken.AccessToken; + + string rawClaims = rawToken.Split('.')[1]; + + byte[] claims = Convert.FromBase64String(rawClaims); + + Dictionary claimMap = JsonSerializer.Deserialize>(claims) ?? throw new NullReferenceException("Failed To Deserialize Claims"); + + object? exp = claimMap["exp"]; + + if (exp == null) + { + throw new InvalidOperationException("Claims are missing Expiration"); + } + + _cachedAccessToken = new AccessToken() { + Token = rawToken, + ExpiresAt = new DateTime((long)(exp)) + }; + + } + + 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 + { + public object SecretAttributes { get; set; } + public string ContentType { get; set; } + public string Id { get; set; } + public string Kid { get; set; } + public bool Managed { get; set; } + public object Tags { get; set; } + 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; } +} From bc4c6a7b4cf8a9de157b9baf13f0dfc1bb330b91 Mon Sep 17 00:00:00 2001 From: Mason Adams Date: Wed, 5 Mar 2025 09:33:49 -0800 Subject: [PATCH 2/5] remove dead setup. --- src/Config.Net/KeyvaultSecretAttribute.cs | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/Config.Net/KeyvaultSecretAttribute.cs diff --git a/src/Config.Net/KeyvaultSecretAttribute.cs b/src/Config.Net/KeyvaultSecretAttribute.cs deleted file mode 100644 index 6559db4..0000000 --- a/src/Config.Net/KeyvaultSecretAttribute.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace Config.Net; -/// -/// Usage: Connects to an azure keyvault in order to pull the secret by the name provided as the field value. -/// -[AttributeUsage(AttributeTargets.Property)] -public class KeyvaultSecretAttribute : Attribute -{ -} From 8045319efdad43cc7e6eee53d99c4433bbdb7781 Mon Sep 17 00:00:00 2001 From: Mason Adams Date: Wed, 5 Mar 2025 09:39:03 -0800 Subject: [PATCH 3/5] documentation --- src/Config.Net/ConfigurationExtensions.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Config.Net/ConfigurationExtensions.cs b/src/Config.Net/ConfigurationExtensions.cs index b075956..1d701a0 100644 --- a/src/Config.Net/ConfigurationExtensions.cs +++ b/src/Config.Net/ConfigurationExtensions.cs @@ -146,6 +146,15 @@ 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; From 31a4fe3ace78aa049d233d1d7070a02849231aa8 Mon Sep 17 00:00:00 2001 From: Mason Adams Date: Wed, 5 Mar 2025 10:53:47 -0800 Subject: [PATCH 4/5] bugfixes --- src/Config.Net/Stores/KeyvaultConfigStore.cs | 60 ++++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/Config.Net/Stores/KeyvaultConfigStore.cs b/src/Config.Net/Stores/KeyvaultConfigStore.cs index 8d82420..4dd12c2 100644 --- a/src/Config.Net/Stores/KeyvaultConfigStore.cs +++ b/src/Config.Net/Stores/KeyvaultConfigStore.cs @@ -29,7 +29,7 @@ KeyvaultConfigStoreOptions keyvaultConfigStoreOptions public bool CanRead => true; public bool CanWrite => false; - private bool TokenExpired => _cachedAccessToken.ExpiresAt.AddSeconds(-30) /*provide a 30 second buffer for the ensuing request to succeed*/ <= DateTime.UtcNow; + private bool TokenExpired => _cachedAccessToken.ExpiresAt <= DateTime.UtcNow.AddSeconds(30) /*provide a 30 second buffer for the ensuing request to succeed*/; public void Dispose() { } /// @@ -52,36 +52,55 @@ public void Dispose() { } // group 4 is the solo keyname Regex kvCheckRegex = new Regex(@"@Keyvault\(((.*),(.*)|(.*))\)"); - Match match = kvCheckRegex.Match(key); + Match match = kvCheckRegex.Match(keyvaultKey.Value); if (!match.Success) { return null; } - ParsedKeyvaultSecret parsedKeyvaultSecret = new ParsedKeyvaultSecret() - { - KeyvaultName = match.Groups.Values.ElementAtOrDefault(2)?.Value ?? match.Groups.Values.ElementAt(4).Value, - Version = match.Groups.Values.ElementAtOrDefault(3)?.Value - }; + 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 + }; + } + else + { + parsedKeyvaultSecret = new ParsedKeyvaultSecret() + { + KeyvaultName = match.Groups.Values.ElementAtOrDefault(2)?.Value ?? match.Groups.Values.ElementAt(4).Value, + Version = match.Groups.Values.ElementAtOrDefault(3)?.Value + }; + } + + 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/{keyvaultKey}/{parsedKeyvaultSecret.Version}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{parsedKeyvaultSecret.KeyvaultName}/{parsedKeyvaultSecret.Version}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; } else { - responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{keyvaultKey}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; + responseMessage = _httpClient.GetAsync($"{_keyvaultConfigStoreOptions.KeyvaultUri}secrets/{parsedKeyvaultSecret.KeyvaultName}?api-version={_keyvaultConfigStoreOptions.ApiVersion}").Result; } responseMessage.EnsureSuccessStatusCode(); - SecretBundle secretBundle = JsonSerializer.Deserialize(responseMessage.Content.ReadAsStringAsync().Result); + string responseString = responseMessage.Content.ReadAsStringAsync().Result; + + SecretBundle secretBundle = JsonSerializer.Deserialize(responseString); + + _httpClient.DefaultRequestHeaders.Authorization = null; return secretBundle.Value; } @@ -96,7 +115,7 @@ await _httpClient.PostAsync($"https://login.microsoftonline.com/{_keyvaultConfig { "grant_type", "client_credentials"}, { "client_id", _keyvaultConfigStoreOptions.ClientId }, { "client_secret", _keyvaultConfigStoreOptions.ClientSecret }, - { "scope", "https://graph.microsoft.com/.default" } + { "scope", "https://vault.azure.net/.default" } }) ); @@ -106,13 +125,15 @@ await _httpClient.PostAsync($"https://login.microsoftonline.com/{_keyvaultConfig string rawToken = msAccessToken.AccessToken; - string rawClaims = rawToken.Split('.')[1]; + 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"); + Dictionary claimMap = JsonSerializer.Deserialize>(claims) ?? throw new NullReferenceException("Failed To Deserialize Claims"); - object? exp = claimMap["exp"]; + JsonElement? exp = claimMap["exp"]; if (exp == null) { @@ -121,7 +142,7 @@ await _httpClient.PostAsync($"https://login.microsoftonline.com/{_keyvaultConfig _cachedAccessToken = new AccessToken() { Token = rawToken, - ExpiresAt = new DateTime((long)(exp)) + ExpiresAt = new DateTime(exp.Value.GetInt64()) }; } @@ -150,12 +171,17 @@ private struct ParsedKeyvaultSecret private struct SecretBundle { - public object SecretAttributes { get; set; } - public string ContentType { get; set; } + [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; } } From 7c10a06287f3326a3591c178fa60fbf79ba2ca08 Mon Sep 17 00:00:00 2001 From: Mason Adams Date: Wed, 5 Mar 2025 10:56:36 -0800 Subject: [PATCH 5/5] fix --- src/Config.Net/Stores/KeyvaultConfigStore.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Config.Net/Stores/KeyvaultConfigStore.cs b/src/Config.Net/Stores/KeyvaultConfigStore.cs index 4dd12c2..2a342ba 100644 --- a/src/Config.Net/Stores/KeyvaultConfigStore.cs +++ b/src/Config.Net/Stores/KeyvaultConfigStore.cs @@ -61,15 +61,15 @@ public void Dispose() { } 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 + KeyvaultName = match.Groups.Values.ElementAt(4).Value.Trim() }; } else { parsedKeyvaultSecret = new ParsedKeyvaultSecret() { - KeyvaultName = match.Groups.Values.ElementAtOrDefault(2)?.Value ?? match.Groups.Values.ElementAt(4).Value, - Version = match.Groups.Values.ElementAtOrDefault(3)?.Value + KeyvaultName = match.Groups.Values.ElementAt(2).Value.Trim(), + Version = match.Groups.Values.ElementAtOrDefault(3)?.Value.Trim() }; }