Skip to content

Commit

Permalink
feat: add import from azure app configuration (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
tnc1997 authored Apr 8, 2024
1 parent 4c3f35a commit e26018e
Show file tree
Hide file tree
Showing 17 changed files with 753 additions and 127 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;

namespace AzureAppConfigurationEmulator.Authentication.Hmac;

public class HmacAuthenticatingHttpMessageHandler(string credential, string secret) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(HmacAuthenticatingHttpMessageHandler)}.{nameof(SendAsync)}");

var contentHash = await ComputeContentHash(request, cancellationToken);
activity?.SetTag(Telemetry.ContentHash, contentHash);

var date = DateTimeOffset.UtcNow;
activity?.SetTag(Telemetry.Date, date);

request.Headers.Authorization = GetAuthenticationHeaderValue(request, contentHash, date);
request.Headers.Date = date;
request.Headers.Add("x-ms-content-sha256", contentHash);

return await base.SendAsync(request, cancellationToken);
}

private static async Task<string> ComputeContentHash(
HttpRequestMessage request,
CancellationToken cancellationToken = default)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(HmacAuthenticatingHttpMessageHandler)}.{nameof(ComputeContentHash)}");

using var stream = new MemoryStream();

if (request.Content is not null)
{
await request.Content.CopyToAsync(stream, cancellationToken);
}

using var sha256 = SHA256.Create();

return Convert.ToBase64String(await sha256.ComputeHashAsync(stream, cancellationToken));
}

private string ComputeHash(string value)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(HmacAuthenticatingHttpMessageHandler)}.{nameof(ComputeHash)}");

using var hmac = new HMACSHA256(Convert.FromBase64String(secret));

return Convert.ToBase64String(hmac.ComputeHash(Encoding.ASCII.GetBytes(value)));
}

private AuthenticationHeaderValue GetAuthenticationHeaderValue(
HttpRequestMessage request,
string contentHash,
DateTimeOffset date)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(HmacAuthenticatingHttpMessageHandler)}.{nameof(GetAuthenticationHeaderValue)}");

const string signedHeaders = "date;host;x-ms-content-sha256";

var stringToSign = $"{request.Method.Method.ToUpper()}\n{request.RequestUri?.PathAndQuery}\n{date:R};{request.RequestUri?.Authority};{contentHash}";
activity?.SetTag(Telemetry.StringToSign, stringToSign);

var signature = ComputeHash(stringToSign);
activity?.SetTag(Telemetry.Signature, signature);

return new AuthenticationHeaderValue("HMAC-SHA256", $"Credential={credential}&SignedHeaders={signedHeaders}&Signature={signature}");
}
}
15 changes: 15 additions & 0 deletions src/AzureAppConfigurationEmulator.Authentication.Hmac/Telemetry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Diagnostics;

namespace AzureAppConfigurationEmulator.Authentication.Hmac;

public static class Telemetry
{
public const string ContentHash = $"{Namespace}.content_hash";
public const string Date = $"{Namespace}.date";
public const string Signature = $"{Namespace}.signature";
public const string StringToSign = $"{Namespace}.string_to_sign";

private const string Namespace = "azure_app_configuration_emulator.authentication.hmac";

public static ActivitySource ActivitySource { get; } = new("AzureAppConfigurationEmulator.Authentication.Hmac");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using AzureAppConfigurationEmulator.Common.Constants;
using AzureAppConfigurationEmulator.Common.Models;

namespace AzureAppConfigurationEmulator.Common.Abstractions;

public interface IConfigurationClient
{
public IAsyncEnumerable<ConfigurationSetting> GetConfigurationSettings(
string key = KeyFilter.Any,
string label = LabelFilter.Any,
DateTimeOffset? moment = default,
CancellationToken cancellationToken = default);

public IAsyncEnumerable<string> GetKeys(
CancellationToken cancellationToken = default);

public IAsyncEnumerable<string?> GetLabels(
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AzureAppConfigurationEmulator.Common.Abstractions;
using AzureAppConfigurationEmulator.Common.Constants;
using AzureAppConfigurationEmulator.Common.Headers;
using AzureAppConfigurationEmulator.Common.Models;

namespace AzureAppConfigurationEmulator.Common.Clients;

public class ConfigurationClient(
HttpClient httpClient,
IConfigurationSettingFactory configurationSettingFactory) : IConfigurationClient
{
public async IAsyncEnumerable<ConfigurationSetting> GetConfigurationSettings(
string key = KeyFilter.Any,
string label = LabelFilter.Any,
DateTimeOffset? moment = default,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(ConfigurationClient)}.{nameof(GetConfigurationSettings)}");

LinkHeaderValue? link = null;

do
{
var uri = link?.Next?.SingleOrDefault() ??
new Uri($"/kv?key={key}&label={label}&api-version=1.0", UriKind.Relative);

using var request = new HttpRequestMessage(HttpMethod.Get, uri);

if (moment.HasValue)
{
request.Headers.Add("Accept-Datetime", moment.Value.ToString("R"));
}

using var response = await httpClient.SendAsync(request, cancellationToken);

response.EnsureSuccessStatusCode();

link = response.Headers.TryGetValues("Link", out var values)
? LinkHeaderValue.Parse(string.Join(", ", values))
: null;

await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);

foreach (var element in document.RootElement.GetProperty("items").EnumerateArray())
{
yield return configurationSettingFactory.Create(
element.GetProperty("etag").GetString()!,
element.GetProperty("key").GetString()!,
element.GetProperty("last_modified").GetDateTimeOffset(),
element.GetProperty("locked").GetBoolean(),
element.TryGetProperty("label", out var labelElement)
? labelElement.GetString()
: null,
element.TryGetProperty("content_type", out var contentTypeElement)
? contentTypeElement.GetString()
: null,
element.TryGetProperty("value", out var valueElement)
? valueElement.GetString()
: null,
element.TryGetProperty("tags", out var tagsElement)
? tagsElement.EnumerateObject().ToDictionary(
property => property.Name,
property => property.Value.GetString()!)
: null);
}
} while (link is { Next: not null });
}

public async IAsyncEnumerable<string> GetKeys(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(ConfigurationClient)}.{nameof(GetKeys)}");

LinkHeaderValue? link = null;

do
{
var uri = link?.Next?.SingleOrDefault() ??
new Uri("/keys?api-version=1.0", UriKind.Relative);

using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await httpClient.SendAsync(request, cancellationToken);

response.EnsureSuccessStatusCode();

link = response.Headers.TryGetValues("Link", out var values)
? LinkHeaderValue.Parse(string.Join(", ", values))
: null;

await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);

foreach (var element in document.RootElement.GetProperty("items").EnumerateArray())
{
yield return element.GetProperty("name").GetString()!;
}
} while (link is { Next: not null });
}

public async IAsyncEnumerable<string?> GetLabels(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var activity = Telemetry.ActivitySource.StartActivity($"{nameof(ConfigurationClient)}.{nameof(GetLabels)}");

LinkHeaderValue? link = null;

do
{
var uri = link?.Next?.SingleOrDefault() ??
new Uri("/labels?api-version=1.0", UriKind.Relative);

using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await httpClient.SendAsync(request, cancellationToken);

response.EnsureSuccessStatusCode();

link = response.Headers.TryGetValues("Link", out var values)
? LinkHeaderValue.Parse(string.Join(", ", values))
: null;

await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);

foreach (var element in document.RootElement.GetProperty("items").EnumerateArray())
{
yield return element.GetProperty("name").GetString();
}
} while (link is { Next: not null });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Text;
using System.Text.RegularExpressions;

namespace AzureAppConfigurationEmulator.Common.Headers;

public partial class LinkHeaderValue
{
private readonly IDictionary<string, ICollection<Uri>> _values;

private LinkHeaderValue(IDictionary<string, ICollection<Uri>> values)
{
_values = values;
}

public IEnumerable<Uri>? Next => _values.TryGetValue("next", out var uris) ? uris : null;

public IEnumerable<Uri>? Prev => _values.TryGetValue("prev", out var uris) ? uris : null;

public static LinkHeaderValue Parse(string input)
{
var values = new Dictionary<string, ICollection<Uri>>();

if (!string.IsNullOrEmpty(input))
{
if (input.Split(',') is { Length: > 0 } links)
{
foreach (var link in links)
{
if (RelRegex().Match(link) is { Success: true } rel &&
UriRegex().Match(link) is { Success: true } uri)
{
if (values.TryGetValue(rel.Value, out var uris))
{
uris.Add(new Uri(uri.Value, UriKind.RelativeOrAbsolute));
}
else
{
values.Add(rel.Value, [new Uri(uri.Value, UriKind.RelativeOrAbsolute)]);
}
}
}
}
}

return new LinkHeaderValue(values);
}

public override string ToString()
{
var builder = new StringBuilder();

foreach (var (rel, uris) in _values)
{
builder.AppendJoin(", ", uris.Select(uri => $"<{uri}>; rel=\"{rel}\""));
}

return builder.ToString();
}

[GeneratedRegex("(?<=rel=\").+?(?=\")")]
private static partial Regex RelRegex();

[GeneratedRegex("(?<=<).+?(?=>)")]
private static partial Regex UriRegex();
}
14 changes: 14 additions & 0 deletions src/AzureAppConfigurationEmulator/Components/AzureInputDate.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@typeparam TValue
@using System.Linq.Expressions

<InputDate @attributes="@AdditionalAttributes" class="px-2 pt-1 pb-1.5 w-full bg-white rounded-sm border border-storm-dust h-[24px] placeholder:text-storm-dust invalid:border-alizarin-crimson dark:bg-cod-grey dark:border-star-dust dark:placeholder:text-star-dust dark:disabled:bg-shark dark:disabled:border-natural-grey dark:disabled:text-natural-grey disabled:bg-concrete disabled:border-star-dust disabled:text-star-dust" Value="@Value" ValueChanged="@ValueChanged" ValueExpression="@ValueExpression"/>

@code {
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }

[Parameter] public TValue? Value { get; set; }

[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }

[Parameter] public Expression<Func<TValue?>>? ValueExpression { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

[Parameter] public TValue? Value { get; set; }

[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }

[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
[Parameter] public Expression<Func<TValue?>>? ValueExpression { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@typeparam TValue
@using System.Linq.Expressions

<InputSelect @attributes="@AdditionalAttributes" class="px-1 pt-0 pb-0.5 w-full bg-white rounded-sm border cursor-pointer disabled:cursor-default border-storm-dust h-[24px] invalid:border-alizarin-crimson dark:bg-cod-grey dark:border-star-dust dark:disabled:bg-shark dark:disabled:border-natural-grey dark:disabled:text-natural-grey disabled:bg-concrete disabled:border-star-dust disabled:text-star-dust" Value="@Value" ValueChanged="@ValueChanged" ValueExpression="@ValueExpression">
<InputSelect @attributes="@AdditionalAttributes" class="px-1 pt-0 pb-0.5 w-full bg-white rounded-sm border cursor-pointer disabled:cursor-default border-storm-dust [&:not(multiple)]:h-[24px] invalid:border-alizarin-crimson dark:bg-cod-grey dark:border-star-dust dark:disabled:bg-shark dark:disabled:border-natural-grey dark:disabled:text-natural-grey disabled:bg-concrete disabled:border-star-dust disabled:text-star-dust" Value="@Value" ValueChanged="@ValueChanged" ValueExpression="@ValueExpression">
@ChildContent
</InputSelect>

Expand All @@ -12,7 +12,7 @@

[Parameter] public TValue? Value { get; set; }

[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }

[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
[Parameter] public Expression<Func<TValue?>>? ValueExpression { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@

@code {
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }

[Parameter] public RenderFragment? ChildContent { get; set; }

[Parameter] public string? Value { get; set; }

[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }

[Parameter] public Expression<Func<string>>? ValueExpression { get; set; }
[Parameter] public Expression<Func<string?>>? ValueExpression { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@using System.Linq.Expressions
@using AzureAppConfigurationEmulator.Common.Constants

<AzureInputSelect AdditionalAttributes="@AdditionalAttributes" TValue="@string" Value="@Value" ValueChanged="@HandleValueChanged" ValueExpression="@ValueExpression">
<option checked="@(Value is null)" value="">(No content type)</option>
<option checked="@(Value is MediaType.SecretReference)" value="@MediaType.SecretReference">Key Vault Reference (@MediaType.SecretReference)</option>
<option checked="@(Value is MediaType.Json)" value="@MediaType.Json">JSON (@MediaType.Json)</option>
</AzureInputSelect>

@code {
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }

[Parameter] public string? Value { get; set; }

[Parameter] public EventCallback<string?> ValueChanged { get; set; }

[Parameter] public Expression<Func<string?>>? ValueExpression { get; set; }

private async Task HandleValueChanged(string? value)
{
await ValueChanged.InvokeAsync(!string.IsNullOrEmpty(value) ? value : null);
}

}
Loading

0 comments on commit e26018e

Please sign in to comment.