Skip to content

Commit

Permalink
Merge branch 'main' into jamesnk/browser-token
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK committed Mar 28, 2024
2 parents f88bbf8 + 39191ca commit ffa3428
Show file tree
Hide file tree
Showing 41 changed files with 641 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
var builder = DistributedApplication.CreateBuilder(args);

builder.AddAzureProvisioning();

var storage = builder.AddAzureStorage("storage").RunAsEmulator(container =>
{
container.WithDataBindMount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationT
{
foreach (var resource in appModel.Resources.OfType<TestResource>())
{
var states = new[] { "Starting", "Running", "Finished" };
var states = new[] { "Starting", "Running", "Finished", "Uploading", "Downloading", "Processing", "Provisioning" };
var stateStyles = new[] { "info", "success", "warning", "error" };

var logger = loggerService.GetLogger(resource);

Expand All @@ -55,10 +56,10 @@ await notificationService.PublishUpdateAsync(resource, state => state with
while (await timer.WaitForNextTickAsync(_tokenSource.Token))
{
var randomState = states[Random.Shared.Next(0, states.Length)];

var randomStyle = stateStyles[Random.Shared.Next(0, stateStyles.Length)];
await notificationService.PublishUpdateAsync(resource, state => state with
{
State = randomState
State = new(randomState, randomStyle)
});

logger.LogInformation("Test resource {ResourceName} is now in state {State}", resource.Name, randomState);
Expand Down
1 change: 1 addition & 0 deletions playground/TestShop/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
var rabbitMqPassword = builder.AddParameter("rabbitmq-password", secret: true);
var messaging = builder.AddRabbitMQ("messaging", password: rabbitMqPassword)
.WithDataVolume()
.WithManagementPlugin()
.PublishAsContainer();

var basketService = builder.AddProject("basketservice", @"..\BasketService\BasketService.csproj")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Aspire.Dashboard.Configuration;
using Microsoft.AspNetCore.Authentication;
Expand All @@ -23,16 +26,17 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var options = _dashboardOptions.CurrentValue.Otlp;

if (string.IsNullOrEmpty(options.PrimaryApiKey))
{
throw new InvalidOperationException("OTLP API key is not configured.");
}

if (Context.Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKey))
{
if (options.PrimaryApiKey != apiKey)
// There must be only one header with the API key.
if (apiKey.Count != 1)
{
return Task.FromResult(AuthenticateResult.Fail($"Multiple '{ApiKeyHeaderName}' headers in request."));
}

if (!CompareApiKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString()))
{
if (string.IsNullOrEmpty(options.SecondaryApiKey) || options.SecondaryApiKey != apiKey)
if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareApiKey(secondaryBytes, apiKey.ToString()))
{
return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key."));
}
Expand All @@ -45,6 +49,50 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()

return Task.FromResult(AuthenticateResult.NoResult());
}

// This method is used to compare two API keys in a way that avoids timing attacks.
private static bool CompareApiKey(byte[] expectedApiKeyBytes, string requestApiKey)
{
const int StackAllocThreshold = 256;

var requestByteCount = Encoding.UTF8.GetByteCount(requestApiKey);

// API key will never match if lengths are different. But still do all the work to avoid timing attacks.
var lengthsEqual = expectedApiKeyBytes.Length == requestByteCount;

var requestSpanLength = Math.Max(requestByteCount, expectedApiKeyBytes.Length);
byte[]? requestPooled = null;
var requestBytesSpan = (requestSpanLength <= StackAllocThreshold ?
stackalloc byte[StackAllocThreshold] :
(requestPooled = RentClearedArray(requestSpanLength))).Slice(0, requestSpanLength);

try
{
// Always succeeds because the byte span is always as big or bigger than required.
Encoding.UTF8.GetBytes(requestApiKey, requestBytesSpan);

// Trim request bytes to the same length as expected bytes. Need to be the same size for fixed time comparison.
var equals = CryptographicOperations.FixedTimeEquals(expectedApiKeyBytes, requestBytesSpan.Slice(0, expectedApiKeyBytes.Length));

return equals && lengthsEqual;
}
finally
{
if (requestPooled != null)
{
ArrayPool<byte>.Shared.Return(requestPooled);
}
}

static byte[] RentClearedArray(int byteCount)
{
// UTF8 bytes are copied into the array but remaining bytes are untouched.
// Because all bytes in the array are compared, clear the array to avoid comparing previous data.
var array = ArrayPool<byte>.Shared.Rent(byteCount);
Array.Clear(array);
return array;
}
}
}

public static class OtlpApiKeyAuthenticationDefaults
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ else if (Resource is { State: /* unknown */ null or { Length: 0 } })
Color="Color.Neutral"
Class="severity-icon" />
}
else if (!string.IsNullOrEmpty(Resource.StateStyle))
{
switch (Resource.StateStyle)
{
case "warning":
<FluentIcon Icon="Icons.Filled.Size16.Warning"
Color="Color.Warning"
Class="severity-icon" />
break;
case "error":
<FluentIcon Icon="Icons.Filled.Size16.ErrorCircle"
Color="Color.Error"
Class="severity-icon" />
break;
case "success":
<FluentIcon Icon="Icons.Filled.Size16.Circle"
Color="Color.Success"
Class="severity-icon" />
break;
case "info":
<FluentIcon Icon="Icons.Filled.Size16.Circle"
Color="Color.Info"
Class="severity-icon" />
break;
default:
<FluentIcon Icon="Icons.Filled.Size16.Circle"
Color="Color.Neutral"
Class="severity-icon" />
break;
}
}
else
{
<FluentIcon Icon="Icons.Filled.Size16.CheckmarkCircle"
Expand Down

This file was deleted.

14 changes: 14 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace Aspire.Dashboard.Configuration;

Expand Down Expand Up @@ -55,6 +56,8 @@ public sealed class ResourceServiceClientCertificateOptions
public sealed class OtlpOptions
{
private Uri? _parsedEndpointUrl;
private byte[]? _primaryApiKeyBytes;
private byte[]? _secondaryApiKeyBytes;

public string? PrimaryApiKey { get; set; }
public string? SecondaryApiKey { get; set; }
Expand All @@ -67,6 +70,14 @@ public Uri GetEndpointUri()
return _parsedEndpointUrl;
}

public byte[] GetPrimaryApiKeyBytes()
{
Debug.Assert(_primaryApiKeyBytes is not null, "Should have been parsed during validation.");
return _primaryApiKeyBytes;
}

public byte[]? GetSecondaryApiKeyBytes() => _secondaryApiKeyBytes;

internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
{
if (string.IsNullOrEmpty(EndpointUrl))
Expand All @@ -83,6 +94,9 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
}
}

_primaryApiKeyBytes = PrimaryApiKey != null ? Encoding.UTF8.GetBytes(PrimaryApiKey) : null;
_secondaryApiKeyBytes = SecondaryApiKey != null ? Encoding.UTF8.GetBytes(SecondaryApiKey) : null;

errorMessage = null;
return true;
}
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/ResourceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class ResourceViewModel
public required string DisplayName { get; init; }
public required string Uid { get; init; }
public required string? State { get; init; }
public required string? StateStyle { get; init; }
public required DateTime? CreationTimeStamp { get; init; }
public required ImmutableArray<EnvironmentVariableViewModel> Environment { get; init; }
public required ImmutableArray<UrlViewModel> Urls { get; init; }
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Protos/Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public ResourceViewModel ToViewModel()
Environment = GetEnvironment(),
Urls = GetUrls(),
State = HasState ? State : null,
StateStyle = HasStateStyle ? StateStyle : null,
Commands = GetCommands()
};

Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Resources/TraceDetail.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Resources/TraceDetail.resx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="TraceDetailPageTitle" xml:space="preserve">
<value>{0} Traces</value>
<value>{0} trace</value>
<comment>{0} is an application name</comment>
</data>
<data name="TraceDetailTraceStartHeader" xml:space="preserve">
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.pt-BR.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Resources/xlf/TraceDetail.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit ffa3428

Please sign in to comment.