From d4ace7826dbfa971ca37b581d145d87006cdef9c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 3 Apr 2024 08:04:53 +0800 Subject: [PATCH 1/4] Add token auth to dashboard frontend --- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 15 ++ .../OtlpApiKeyAuthenticationHandler.cs | 52 +---- .../Components/Controls/AspireLogo.razor | 95 ++++++++++ .../Components/Dialogs/SettingsDialog.razor | 3 +- .../Dialogs/SettingsDialog.razor.cs | 2 - .../Components/Layout/EmptyLayout.razor | 3 + .../Components/Layout/NavMenu.razor | 2 +- .../Components/Pages/Login.razor | 49 +++++ .../Components/Pages/Login.razor.cs | 109 +++++++++++ .../Components/Pages/Login.razor.css | 97 ++++++++++ .../Components/Pages/Login.razor.js | 9 + src/Aspire.Dashboard/Components/Routes.razor | 6 +- .../Components/_Imports.razor | 1 + .../Configuration/DashboardOptions.cs | 9 + .../Configuration/FrontendAuthMode.cs | 5 +- .../PostConfigureDashboardOptions.cs | 4 + .../Configuration/ValidateDashboardOptions.cs | 30 ++- .../DashboardWebApplication.cs | 125 +++++++++---- src/Aspire.Dashboard/Model/TokenFormModel.cs | 13 ++ .../Model/ValidateTokenMiddleware.cs | 89 +++++++++ .../Resources/Login.Designer.cs | 162 ++++++++++++++++ src/Aspire.Dashboard/Resources/Login.resx | 155 +++++++++++++++ .../Resources/xlf/Login.cs.xlf | 62 ++++++ .../Resources/xlf/Login.de.xlf | 62 ++++++ .../Resources/xlf/Login.es.xlf | 62 ++++++ .../Resources/xlf/Login.fr.xlf | 62 ++++++ .../Resources/xlf/Login.it.xlf | 62 ++++++ .../Resources/xlf/Login.ja.xlf | 62 ++++++ .../Resources/xlf/Login.ko.xlf | 62 ++++++ .../Resources/xlf/Login.pl.xlf | 62 ++++++ .../Resources/xlf/Login.pt-BR.xlf | 62 ++++++ .../Resources/xlf/Login.ru.xlf | 62 ++++++ .../Resources/xlf/Login.tr.xlf | 62 ++++++ .../Resources/xlf/Login.zh-Hans.xlf | 62 ++++++ .../Resources/xlf/Login.zh-Hant.xlf | 62 ++++++ .../Resources/xlf/Token.cs.xlf | 62 ++++++ .../Resources/xlf/Token.de.xlf | 62 ++++++ .../Resources/xlf/Token.es.xlf | 62 ++++++ .../Resources/xlf/Token.fr.xlf | 62 ++++++ .../Resources/xlf/Token.it.xlf | 62 ++++++ .../Resources/xlf/Token.ja.xlf | 62 ++++++ .../Resources/xlf/Token.ko.xlf | 62 ++++++ .../Resources/xlf/Token.pl.xlf | 62 ++++++ .../Resources/xlf/Token.pt-BR.xlf | 62 ++++++ .../Resources/xlf/Token.ru.xlf | 62 ++++++ .../Resources/xlf/Token.tr.xlf | 62 ++++++ .../Resources/xlf/Token.zh-Hans.xlf | 62 ++++++ .../Resources/xlf/Token.zh-Hant.xlf | 62 ++++++ src/Aspire.Dashboard/Utils/CompareHelpers.cs | 42 +++++ src/Aspire.Dashboard/Utils/DashboardUrls.cs | 15 ++ src/Aspire.Dashboard/Utils/VersionHelpers.cs | 11 ++ .../wwwroot/img/TokenExample.png | Bin 0 -> 9284 bytes src/Aspire.Hosting/Aspire.Hosting.csproj | 3 + src/Aspire.Hosting/Dcp/ApplicationExecutor.cs | 13 +- .../DistributedApplicationBuilder.cs | 47 +++-- .../DistributedApplicationLifecycle.cs | 11 +- src/Shared/DashboardConfigNames.cs | 1 + src/Shared/KnownConfigNames.cs | 10 +- src/Shared/LoggingHelpers.cs | 23 +++ .../Utils => Shared}/StringUtils.cs | 2 +- src/Shared/TokenGenerator.cs | 38 ++++ .../Integration/FrontendAuthTests.cs | 177 ++++++++++++++++++ .../Integration/OtlpServiceTests.cs | 2 +- .../Integration/StartupTests.cs | 13 +- .../Dcp/ApplicationExecutorTests.cs | 52 ++++- .../DistributedApplicationTests.cs | 93 ++++++++- .../ProjectResourceTests.cs | 30 ++- 67 files changed, 3080 insertions(+), 150 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/AspireLogo.razor create mode 100644 src/Aspire.Dashboard/Components/Layout/EmptyLayout.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/Login.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/Login.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Pages/Login.razor.css create mode 100644 src/Aspire.Dashboard/Components/Pages/Login.razor.js create mode 100644 src/Aspire.Dashboard/Model/TokenFormModel.cs create mode 100644 src/Aspire.Dashboard/Model/ValidateTokenMiddleware.cs create mode 100644 src/Aspire.Dashboard/Resources/Login.Designer.cs create mode 100644 src/Aspire.Dashboard/Resources/Login.resx create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.de.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.es.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.it.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.cs.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.de.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.es.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.fr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.it.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.ja.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.ko.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.pl.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.pt-BR.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.ru.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.tr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.zh-Hans.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/Token.zh-Hant.xlf create mode 100644 src/Aspire.Dashboard/Utils/CompareHelpers.cs create mode 100644 src/Aspire.Dashboard/Utils/VersionHelpers.cs create mode 100644 src/Aspire.Dashboard/wwwroot/img/TokenExample.png create mode 100644 src/Shared/LoggingHelpers.cs rename src/{Aspire.Hosting/Utils => Shared}/StringUtils.cs (80%) create mode 100644 src/Shared/TokenGenerator.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/FrontendAuthTests.cs diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index c77c8d8b5b..8591b5297e 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -70,6 +70,11 @@ True Resources.resx + + Login.resx + True + True + True True @@ -142,6 +147,13 @@ PublicResXFileCodeGenerator Resources.Designer.cs + + EmbeddedResource + Designer + Login.Designer.cs + Resx + PublicResXFileCodeGenerator + Resx EmbeddedResource @@ -186,5 +198,8 @@ + + + diff --git a/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs index 4df17897fc..29a7697f86 100644 --- a/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs @@ -1,11 +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 Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -34,9 +32,9 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail($"Multiple '{ApiKeyHeaderName}' headers in request.")); } - if (!CompareApiKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString())) + if (!CompareHelpers.CompareKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString())) { - if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareApiKey(secondaryBytes, apiKey.ToString())) + if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareHelpers.CompareKey(secondaryBytes, apiKey.ToString())) { return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key.")); } @@ -49,50 +47,6 @@ protected override Task 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.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.Shared.Rent(byteCount); - Array.Clear(array); - return array; - } - } } public static class OtlpApiKeyAuthenticationDefaults diff --git a/src/Aspire.Dashboard/Components/Controls/AspireLogo.razor b/src/Aspire.Dashboard/Components/Controls/AspireLogo.razor new file mode 100644 index 0000000000..3b95611971 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/AspireLogo.razor @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + [Parameter] + public int Height { get; set; } = 24; + + [Parameter] + public int Width { get; set; } = 24; +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor index f34a1b83cc..01ff165bb8 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor @@ -1,4 +1,5 @@ @using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils @inject IStringLocalizer Loc @@ -8,5 +9,5 @@ @Loc[nameof(Dialogs.SettingsDialogDarkTheme)]
-
@string.Format(Loc[nameof(Dialogs.SettingsDialogVersion)], s_version)
+
@string.Format(Loc[nameof(Dialogs.SettingsDialogVersion)], VersionHelpers.DashboardDisplayVersion)
diff --git a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs index 285830c671..a08069643c 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -13,7 +12,6 @@ namespace Aspire.Dashboard.Components.Dialogs; public partial class SettingsDialog : IDialogContentComponent, IAsyncDisposable { private string _currentSetting = ThemeManager.ThemeSettingSystem; - private static readonly string? s_version = typeof(SettingsDialog).Assembly.GetDisplayVersion(); private IJSObjectReference? _jsModule; private IDisposable? _themeChangedSubscription; diff --git a/src/Aspire.Dashboard/Components/Layout/EmptyLayout.razor b/src/Aspire.Dashboard/Components/Layout/EmptyLayout.razor new file mode 100644 index 0000000000..f86fbac564 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/EmptyLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase +@Body + diff --git a/src/Aspire.Dashboard/Components/Layout/NavMenu.razor b/src/Aspire.Dashboard/Components/Layout/NavMenu.razor index c72e238f37..f522da1d15 100644 --- a/src/Aspire.Dashboard/Components/Layout/NavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/NavMenu.razor @@ -13,7 +13,7 @@ IconRest="ResourcesIcon()" IconActive="ResourcesIcon(active: true)" Text="@Loc[nameof(Layout.NavMenuResourcesTab)]" /> - diff --git a/src/Aspire.Dashboard/Components/Pages/Login.razor b/src/Aspire.Dashboard/Components/Pages/Login.razor new file mode 100644 index 0000000000..61b22e11cb --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Login.razor @@ -0,0 +1,49 @@ +@page "/login" +@using Aspire.Dashboard.Utils +@layout EmptyLayout +@attribute [AllowAnonymous] +@inject IStringLocalizer Loc + + + +
+ + +
+ +
+
+

+
+
+ +
+
+ +
+ +
+
+
+
+ @VersionHelpers.DashboardDisplayVersion +
+
diff --git a/src/Aspire.Dashboard/Components/Pages/Login.razor.cs b/src/Aspire.Dashboard/Components/Pages/Login.razor.cs new file mode 100644 index 0000000000..066f568f47 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Login.razor.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Aspire.Dashboard.Components.Pages; + +public partial class Login : IAsyncDisposable +{ + private IJSObjectReference? _jsModule; + private FluentTextField? _tokenTextField; + private ValidationMessageStore? _messageStore; + + private TokenFormModel _formModel = default!; + public EditContext EditContext { get; private set; } = default!; + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required IJSRuntime JS { get; set; } + + [Inject] + public required ILogger Logger { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? ReturnUrl { get; set; } + + [CascadingParameter] + public Task? AuthenticationState { get; set; } + + protected override async Task OnInitializedAsync() + { + // If the browser is already authenticated then redirect to the app. + if (AuthenticationState is { } authStateTask) + { + var state = await authStateTask; + if (state.User.Identity?.IsAuthenticated ?? false) + { + NavigationManager.NavigateTo(GetRedirectUrl(), forceLoad: true); + return; + } + } + + _formModel = new TokenFormModel(); + EditContext = new EditContext(_formModel); + _messageStore = new(EditContext); + EditContext.OnValidationRequested += (s, e) => _messageStore.Clear(); + EditContext.OnFieldChanged += (s, e) => _messageStore.Clear(e.FieldIdentifier); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _jsModule = await JS.InvokeAsync("import", "/Components/Pages/Login.razor.js"); + + _tokenTextField?.FocusAsync(); + } + } + + private async Task SubmitAsync() + { + if (_jsModule is null) + { + return; + } + + // Invoke a JS function to validate the token. This is required because a cookie can't be set from a SignalR connection. + // The JS function calls an API back on the server to validate the token and that API call sets the cookie. + // Because the browser made the API call the cookie is set in the browser. + var result = await _jsModule.InvokeAsync("validateToken", _formModel.Token); + + if (bool.TryParse(result, out var success)) + { + if (success) + { + NavigationManager.NavigateTo(GetRedirectUrl(), forceLoad: true); + return; + } + else + { + _messageStore?.Add(() => _formModel.Token!, Loc[nameof(Dashboard.Resources.Login.InvalidTokenErrorMessage)]); + } + } + else + { + Logger.LogWarning("Unexpected result from validateToken: {Result}", result); + _messageStore?.Add(() => _formModel.Token!, Loc[nameof(Dashboard.Resources.Login.UnexpectedValidationError)]); + } + } + + private string GetRedirectUrl() + { + return ReturnUrl ?? DashboardUrls.ResourcesUrl(); + } + + public async ValueTask DisposeAsync() + { + await JSInteropHelpers.SafeDisposeAsync(_jsModule); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/Login.razor.css b/src/Aspire.Dashboard/Components/Pages/Login.razor.css new file mode 100644 index 0000000000..c4668942fa --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Login.razor.css @@ -0,0 +1,97 @@ +.token-backdrop { + background-color: var(--neutral-layer-4); + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + width: 100vw; +} + +[data-theme="dark"] .token-backdrop { + background-color: var(--neutral-layer-3); +} + +.token-form-container { + --error: #FF8181; + background-color: var(--neutral-layer-1); + padding: calc((var(--design-unit) * 5px)); + border-radius: calc(var(--design-unit) * 2.5px); + display: grid; + grid-column-gap: calc(var(--design-unit) * 5px); + grid-template-columns: auto 25em; + grid-template-rows: auto; + grid-template-areas: + "logo entry"; + box-shadow: 0px 0px 15px 0px rgba(0,0,0,0.75); +} + +.token-logo { + grid-area: logo; + display: flex; + align-items: center; + justify-content: center; +} + +.token-entry-container { + grid-area: entry; + display: flex; + flex-direction: column; + justify-content: center; + gap: calc(var(--design-unit) * 1px); + +} + +.token-entry-header { + color: var(--accent-foreground-rest); +} + +.token-entry-header h1 { + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + margin-bottom: 1rem; +} + +.token-entry { + grid-area: entry; +} + +::deep .token-entry-text { + width: 100%; +} + +.token-entry-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.token-validation { + min-height: var(--type-ramp-base-line-height); +} + +/* For the validation failed message that is not inside the FluentValidationMessage component */ +.token-validation > .validation-message { + color: var(--error); + font-size: var(--type-ramp-minus-1-font-size); + display: flex; + align-items: center; + column-gap: 4px; +} + +.token-help-container { + display: flex; + flex-direction: column; + gap: calc(var(--design-unit) * 2px); + margin: calc(var(--design-unit) * 2px); +} + +.token-help-container fluent-anchor { + width: fit-content; +} + +.version-info { + position: fixed; + right: calc(var(--design-unit) * 4px); + bottom: calc(var(--design-unit) * 3px); +} diff --git a/src/Aspire.Dashboard/Components/Pages/Login.razor.js b/src/Aspire.Dashboard/Components/Pages/Login.razor.js new file mode 100644 index 0000000000..38b1eda212 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Login.razor.js @@ -0,0 +1,9 @@ +export async function validateToken(token) { + try { + var url = `/api/validatetoken?token=${encodeURIComponent(token)}`; + var response = await fetch(url, { method: 'POST' }); + return response.text(); + } catch (ex) { + return `Error validating token: ${ex}`; + } +} diff --git a/src/Aspire.Dashboard/Components/Routes.razor b/src/Aspire.Dashboard/Components/Routes.razor index 9681b6a2ea..8123ab66fd 100644 --- a/src/Aspire.Dashboard/Components/Routes.razor +++ b/src/Aspire.Dashboard/Components/Routes.razor @@ -1,8 +1,10 @@ -@inject IStringLocalizer Loc +@using Microsoft.AspNetCore.Components.Authorization +@inject IStringLocalizer Loc - + + @Loc[nameof(Resources.Routes.NotFoundPageTitle)] diff --git a/src/Aspire.Dashboard/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index 9b80535598..4940736b97 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -12,6 +12,7 @@ @using Aspire.Dashboard @using Aspire.Dashboard.Components @using Aspire.Dashboard.Components.Controls +@using Aspire.Dashboard.Components.Layout @using Microsoft.Extensions.Localization @* Require authorization for all pages of the web app *@ diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 5f3c8b307f..08bf664796 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -17,6 +17,7 @@ public sealed class DashboardOptions public TelemetryLimitOptions TelemetryLimits { get; set; } = new TelemetryLimitOptions(); } +// Don't set values after validating/parsing options. public sealed class ResourceServiceClientOptions { private Uri? _parsedUrl; @@ -53,6 +54,7 @@ public sealed class ResourceServiceClientCertificateOptions public StoreLocation? Location { get; set; } } +// Don't set values after validating/parsing options. public sealed class OtlpOptions { private Uri? _parsedEndpointUrl; @@ -102,12 +104,17 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) } } +// Don't set values after validating/parsing options. public sealed class FrontendOptions { private List? _parsedEndpointUrls; + private byte[]? _browserTokenBytes; public string? EndpointUrls { get; set; } public FrontendAuthMode? AuthMode { get; set; } + public string? BrowserToken { get; set; } + + public byte[]? GetBrowserTokenBytes() => _browserTokenBytes; public IReadOnlyList GetEndpointUris() { @@ -139,6 +146,8 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) _parsedEndpointUrls = uris; } + _browserTokenBytes = BrowserToken != null ? Encoding.UTF8.GetBytes(BrowserToken) : null; + errorMessage = null; return true; } diff --git a/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs b/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs index 32e893ce57..efaf246515 100644 --- a/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs +++ b/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Aspire.Dashboard.Configuration; @@ -6,5 +6,6 @@ namespace Aspire.Dashboard.Configuration; public enum FrontendAuthMode { Unsecured, - OpenIdConnect + OpenIdConnect, + BrowserToken } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index c63e33747c..c24626dea9 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -35,5 +35,9 @@ public void PostConfigure(string? name, DashboardOptions options) options.Frontend.AuthMode = FrontendAuthMode.Unsecured; options.Otlp.AuthMode = OtlpAuthMode.Unsecured; } + if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken && string.IsNullOrEmpty(options.Frontend.BrowserToken)) + { + options.Frontend.BrowserToken = TokenGenerator.GenerateToken(); + } } } diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index f11128e2ff..961d8395d8 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -17,14 +17,29 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) errorMessages.Add(frontendParseErrorMessage); } - if (!options.Otlp.TryParseOptions(out var otlpParseErrorMessage)) + switch (options.Frontend.AuthMode) { - errorMessages.Add(otlpParseErrorMessage); + case FrontendAuthMode.Unsecured: + break; + case FrontendAuthMode.OpenIdConnect: + break; + case FrontendAuthMode.BrowserToken: + if (string.IsNullOrEmpty(options.Frontend.BrowserToken)) + { + errorMessages.Add($"BrowserToken is required when frontend authentication mode is browser token. Specify a {DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey} value."); + } + break; + case null: + errorMessages.Add($"Frontend endpoint authentication is not configured. Either specify {DashboardConfigNames.DashboardUnsecuredAllowAnonymousName.ConfigKey}=true, or specify {DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey}. Possible values: {string.Join(", ", typeof(FrontendAuthMode).GetEnumNames())}"); + break; + default: + errorMessages.Add($"Unexpected frontend authentication mode: {options.Otlp.AuthMode}"); + break; } - if (!options.ResourceServiceClient.TryParseOptions(out var resourceServiceClientParseErrorMessage)) + if (!options.Otlp.TryParseOptions(out var otlpParseErrorMessage)) { - errorMessages.Add(resourceServiceClientParseErrorMessage); + errorMessages.Add(otlpParseErrorMessage); } switch (options.Otlp.AuthMode) @@ -40,13 +55,18 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) case OtlpAuthMode.ClientCertificate: break; case null: - errorMessages.Add($"OTLP endpoint authentication is not configured. Either specify {DashboardConfigNames.DashboardUnsecuredAllowAnonymousName.ConfigKey} with a value of true, or specify ${DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey}. Possible values: {string.Join(", ", typeof(OtlpAuthMode).GetEnumNames())}"); + errorMessages.Add($"OTLP endpoint authentication is not configured. Either specify {DashboardConfigNames.DashboardUnsecuredAllowAnonymousName.ConfigKey}=true, or specify {DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey}. Possible values: {string.Join(", ", typeof(OtlpAuthMode).GetEnumNames())}"); break; default: errorMessages.Add($"Unexpected OTLP authentication mode: {options.Otlp.AuthMode}"); break; } + if (!options.ResourceServiceClient.TryParseOptions(out var resourceServiceClientParseErrorMessage)) + { + errorMessages.Add(resourceServiceClientParseErrorMessage); + } + if (options.ResourceServiceClient.GetUri() != null) { switch (options.ResourceServiceClient.AuthMode) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index f01ede5ce0..c5eeb3ea31 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -34,12 +34,12 @@ public sealed class DashboardWebApplication : IAsyncDisposable private readonly WebApplication _app; private readonly IOptionsMonitor _dashboardOptionsMonitor; - private Func? _browserEndPointAccessor; + private Func? _frontendEndPointAccessor; private Func? _otlpServiceEndPointAccessor; - public Func BrowserEndPointAccessor + public Func FrontendEndPointAccessor { - get => _browserEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet."); + get => _frontendEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet."); } public Func OtlpServiceEndPointAccessor @@ -96,6 +96,7 @@ public DashboardWebApplication(Action? configureBuilder = // Add services to the container. builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + builder.Services.AddCascadingAuthenticationState(); // Data from the server. builder.Services.AddScoped(); @@ -146,10 +147,16 @@ public DashboardWebApplication(Action? configureBuilder = _app.Lifetime.ApplicationStarted.Register(() => { - if (_browserEndPointAccessor != null) + if (_frontendEndPointAccessor != null) { - // dotnet watch needs the trailing slash removed. See https://github.com/dotnet/sdk/issues/36709 - logger.LogInformation("Now listening on: {DashboardUri}", GetEndpointUrl(_browserEndPointAccessor())); + var url = GetEndpointUrl(_frontendEndPointAccessor()); + logger.LogInformation("Now listening on: {DashboardUri}", url); + + var options = _app.Services.GetRequiredService>().Value; + if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken) + { + LoggingHelpers.WriteDashboardUrl(logger, url, options.Frontend.BrowserToken); + } } if (_otlpServiceEndPointAccessor != null) @@ -178,6 +185,8 @@ public DashboardWebApplication(Action? configureBuilder = await next(context).ConfigureAwait(false); }); + _app.UseMiddleware(); + // Configure the HTTP request pipeline. if (_app.Environment.IsDevelopment()) { @@ -222,6 +231,25 @@ public DashboardWebApplication(Action? configureBuilder = _app.MapGrpcService(); _app.MapGrpcService(); _app.MapGrpcService(); + + if (dashboardOptions.Frontend.AuthMode == FrontendAuthMode.BrowserToken) + { + _app.MapPost("/api/validatetoken", async (string token, HttpContext httpContext, IOptionsMonitor dashboardOptions) => + { + return await ValidateTokenMiddleware.TryAuthenticateAsync(token, httpContext, dashboardOptions).ConfigureAwait(false); + }); + +#if DEBUG + // Available in local debug for testing. + _app.MapGet("/api/signout", async (HttpContext httpContext) => + { + await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.SignOutAsync( + httpContext, + CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false); + httpContext.Response.Redirect("/"); + }); +#endif + } } /// @@ -311,7 +339,7 @@ static void AddEndpointConfiguration(Dictionary values, string { // Only the last endpoint is accessible. Tests should only need one but // this will need to be improved if that changes. - _browserEndPointAccessor = CreateEndPointAccessor(endpointConfiguration.ListenOptions, endpointConfiguration.IsHttps); + _frontendEndPointAccessor = CreateEndPointAccessor(endpointConfiguration.ListenOptions, endpointConfiguration.IsHttps); }); } @@ -329,7 +357,7 @@ static void AddEndpointConfiguration(Dictionary values, string "The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy."); } - _browserEndPointAccessor = _otlpServiceEndPointAccessor; + _frontendEndPointAccessor = _otlpServiceEndPointAccessor; } endpointConfiguration.ListenOptions.UseOtlpConnection(); @@ -359,7 +387,7 @@ static Func CreateEndPointAccessor(ListenOptions options, bool isH private static void ConfigureAuthentication(WebApplicationBuilder builder, DashboardOptions dashboardOptions) { var authentication = builder.Services - .AddAuthentication() + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddScheme(OtlpCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpApiKeyAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpConnectionAuthenticationDefaults.AuthenticationScheme, o => { }) @@ -390,42 +418,57 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb }; }); - if (dashboardOptions.Frontend.AuthMode == FrontendAuthMode.OpenIdConnect) + switch (dashboardOptions.Frontend.AuthMode) { - authentication.AddPolicyScheme(FrontendAuthenticationDefaults.AuthenticationScheme, displayName: FrontendAuthenticationDefaults.AuthenticationScheme, o => - { - // The frontend authentication scheme just redirects to OpenIdConnect and Cookie schemes, as appropriate. - o.ForwardDefault = CookieAuthenticationDefaults.AuthenticationScheme; - o.ForwardChallenge = OpenIdConnectDefaults.AuthenticationScheme; - }); + case FrontendAuthMode.OpenIdConnect: + authentication.AddPolicyScheme(FrontendAuthenticationDefaults.AuthenticationScheme, displayName: FrontendAuthenticationDefaults.AuthenticationScheme, o => + { + // The frontend authentication scheme just redirects to OpenIdConnect and Cookie schemes, as appropriate. + o.ForwardDefault = CookieAuthenticationDefaults.AuthenticationScheme; + o.ForwardChallenge = OpenIdConnectDefaults.AuthenticationScheme; + }); - authentication.AddCookie(); + authentication.AddCookie(); - authentication.AddOpenIdConnect(options => - { - // Use authorization code flow so clients don't see access tokens. - options.ResponseType = OpenIdConnectResponseType.Code; + authentication.AddOpenIdConnect(options => + { + // Use authorization code flow so clients don't see access tokens. + options.ResponseType = OpenIdConnectResponseType.Code; - options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; - // Scopes "openid" and "profile" are added by default, but need to be re-added - // in case configuration exists for Authentication:Schemes:OpenIdConnect:Scope. - if (!options.Scope.Contains(OpenIdConnectScope.OpenId)) - { - options.Scope.Add(OpenIdConnectScope.OpenId); - } + // Scopes "openid" and "profile" are added by default, but need to be re-added + // in case configuration exists for Authentication:Schemes:OpenIdConnect:Scope. + if (!options.Scope.Contains(OpenIdConnectScope.OpenId)) + { + options.Scope.Add(OpenIdConnectScope.OpenId); + } - if (!options.Scope.Contains("profile")) - { - options.Scope.Add("profile"); - } + if (!options.Scope.Contains("profile")) + { + options.Scope.Add("profile"); + } - // Redirect to resources upon sign-in. - options.CallbackPath = TargetLocationInterceptor.ResourcesPath; + // Redirect to resources upon sign-in. + options.CallbackPath = TargetLocationInterceptor.ResourcesPath; - // Avoid "message.State is null or empty" due to use of CallbackPath above. - options.SkipUnrecognizedRequests = true; - }); + // Avoid "message.State is null or empty" due to use of CallbackPath above. + options.SkipUnrecognizedRequests = true; + }); + break; + case FrontendAuthMode.BrowserToken: + authentication.AddPolicyScheme(FrontendAuthenticationDefaults.AuthenticationScheme, displayName: FrontendAuthenticationDefaults.AuthenticationScheme, o => + { + o.ForwardDefault = CookieAuthenticationDefaults.AuthenticationScheme; + }); + + authentication.AddCookie(options => + { + options.LoginPath = "/login"; + options.ReturnUrlParameter = "returnUrl"; + options.ExpireTimeSpan = TimeSpan.FromDays(1); + }); + break; } builder.Services.AddAuthorization(options => @@ -448,6 +491,14 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .RequireAuthenticatedUser() .Build()); break; + case FrontendAuthMode.BrowserToken: + options.AddPolicy( + name: FrontendAuthorizationDefaults.PolicyName, + policy: new AuthorizationPolicyBuilder( + FrontendAuthenticationDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .Build()); + break; case FrontendAuthMode.Unsecured: // Frontend is unsecured so our policy doesn't need any special handling. options.AddPolicy( diff --git a/src/Aspire.Dashboard/Model/TokenFormModel.cs b/src/Aspire.Dashboard/Model/TokenFormModel.cs new file mode 100644 index 0000000000..04e4f22a66 --- /dev/null +++ b/src/Aspire.Dashboard/Model/TokenFormModel.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using Aspire.Dashboard.Resources; + +namespace Aspire.Dashboard.Model; + +public class TokenFormModel +{ + [Required(ErrorMessageResourceType = typeof(Login), ErrorMessageResourceName = nameof(Login.TokenRequiredErrorMessage))] + public string? Token { get; set; } +} diff --git a/src/Aspire.Dashboard/Model/ValidateTokenMiddleware.cs b/src/Aspire.Dashboard/Model/ValidateTokenMiddleware.cs new file mode 100644 index 0000000000..c340f23998 --- /dev/null +++ b/src/Aspire.Dashboard/Model/ValidateTokenMiddleware.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Web; + +namespace Aspire.Dashboard.Model; + +internal sealed class ValidateTokenMiddleware +{ + private readonly RequestDelegate _next; + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + public ValidateTokenMiddleware(RequestDelegate next, IOptionsMonitor options, ILogger logger) + { + _next = next; + _options = options; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.Equals("/login", StringComparisons.UrlPath) && context.Request.Query.TryGetValue("t", out var value)) + { + if (_options.CurrentValue.Frontend.AuthMode == FrontendAuthMode.BrowserToken) + { + var dashboardOptions = context.RequestServices.GetRequiredService>(); + if (await TryAuthenticateAsync(value.ToString(), context, dashboardOptions).ConfigureAwait(false)) + { + // Success. Redirect to the app. + if (context.Request.Query.TryGetValue("returnUrl", out var returnUrl)) + { + context.Response.Redirect(returnUrl.ToString()); + } + else + { + context.Response.Redirect(DashboardUrls.ResourcesUrl()); + } + } + else + { + // Failure. + // The bad token in the query string could be confusing with the token in the text box. + // Remove it before the presenting the UI to the user. + var qs = HttpUtility.ParseQueryString(context.Request.QueryString.ToString()); + qs.Remove("t"); + + var newQuerystring = qs.ToString(); + context.Response.Redirect($"{context.Request.Path}?{newQuerystring}"); + } + + return; + } + else + { + _logger.LogDebug($"Request to validate token URL but auth mode isn't set to {FrontendAuthMode.BrowserToken}."); + } + } + + await _next(context).ConfigureAwait(false); + } + + public static async Task TryAuthenticateAsync(string incomingBrowserToken, HttpContext httpContext, IOptionsMonitor dashboardOptions) + { + if (string.IsNullOrEmpty(incomingBrowserToken) || dashboardOptions.CurrentValue.Frontend.GetBrowserTokenBytes() is not { } expectedBrowserTokenBytes) + { + return false; + } + + if (!CompareHelpers.CompareKey(expectedBrowserTokenBytes, incomingBrowserToken)) + { + return false; + } + + var claimsIdentity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, "Local")], + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme); + var claims = new ClaimsPrincipal(claimsIdentity); + + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claims).ConfigureAwait(false); + return true; + } +} diff --git a/src/Aspire.Dashboard/Resources/Login.Designer.cs b/src/Aspire.Dashboard/Resources/Login.Designer.cs new file mode 100644 index 0000000000..194cf38752 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/Login.Designer.cs @@ -0,0 +1,162 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Dashboard.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Login { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Login() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Dashboard.Resources.Login", typeof(Login).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to {0} dashboard. + /// + public static string Header { + get { + return ResourceManager.GetString("Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Look for the token in the console output:. + /// + public static string HelpPopupText { + get { + return ResourceManager.GetString("HelpPopupText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Screenshot of console output showing where to find the dashboard frontend token. + /// + public static string HelpScreenshotAltText { + get { + return ResourceManager.GetString("HelpScreenshotAltText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid token. Please try again. + /// + public static string InvalidTokenErrorMessage { + get { + return ResourceManager.GetString("InvalidTokenErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log in. + /// + public static string LogInButtonText { + get { + return ResourceManager.GetString("LogInButtonText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to More info. + /// + public static string MoreInfoLinkText { + get { + return ResourceManager.GetString("MoreInfoLinkText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} login. + /// + public static string PageTitle { + get { + return ResourceManager.GetString("PageTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter token to log in.... + /// + public static string TextFieldPlaceholder { + get { + return ResourceManager.GetString("TextFieldPlaceholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Token is required. + /// + public static string TokenRequiredErrorMessage { + get { + return ResourceManager.GetString("TokenRequiredErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected error when validating the token. + /// + public static string UnexpectedValidationError { + get { + return ResourceManager.GetString("UnexpectedValidationError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Where do I find the token?. + /// + public static string WhereIsMyTokenLinkText { + get { + return ResourceManager.GetString("WhereIsMyTokenLinkText", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Dashboard/Resources/Login.resx b/src/Aspire.Dashboard/Resources/Login.resx new file mode 100644 index 0000000000..529c2e9270 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/Login.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + + + Screenshot of console output showing where to find the dashboard frontend token + + + Invalid token. Please try again + + + Log in + + + More info + + + {0} login + {0} is an application name + + + Enter token to log in... + + + Token is required + + + Unexpected error when validating the token + + + Where do I find the token? + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf new file mode 100644 index 0000000000..d01a3992ec --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf new file mode 100644 index 0000000000..9d1ad51175 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf new file mode 100644 index 0000000000..d1ce0e325e --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf new file mode 100644 index 0000000000..98e20da8b3 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf new file mode 100644 index 0000000000..742154835e --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf new file mode 100644 index 0000000000..5199f6482c --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf new file mode 100644 index 0000000000..090f7ea606 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf new file mode 100644 index 0000000000..5e8c63ca0f --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf new file mode 100644 index 0000000000..21ce131cb3 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf new file mode 100644 index 0000000000..8c8c300f1d --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf new file mode 100644 index 0000000000..a5bdb9f800 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf new file mode 100644 index 0000000000..374cb05194 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf new file mode 100644 index 0000000000..ac3cf3162c --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.cs.xlf new file mode 100644 index 0000000000..ec140f6686 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.cs.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.de.xlf new file mode 100644 index 0000000000..4896e97464 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.de.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.es.xlf new file mode 100644 index 0000000000..32be4e2d23 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.es.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.fr.xlf new file mode 100644 index 0000000000..81363242ad --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.fr.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.it.xlf new file mode 100644 index 0000000000..f23bbdc036 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.it.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.ja.xlf new file mode 100644 index 0000000000..530daf9b7c --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.ja.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.ko.xlf new file mode 100644 index 0000000000..ed83e0e109 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.ko.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.pl.xlf new file mode 100644 index 0000000000..1f6c00abe6 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.pl.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.pt-BR.xlf new file mode 100644 index 0000000000..e2d713432d --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.pt-BR.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.ru.xlf new file mode 100644 index 0000000000..e69a07b5fd --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.ru.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.tr.xlf new file mode 100644 index 0000000000..7eee5dc0cd --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.tr.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hans.xlf new file mode 100644 index 0000000000..c312722651 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hans.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hant.xlf new file mode 100644 index 0000000000..fd0db965c2 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/Token.zh-Hant.xlf @@ -0,0 +1,62 @@ + + + + + + {0} dashboard + {0} dashboard + {0} is an application name + + + Look for the token in the console output: + Look for the token in the console output: + + + + Screenshot of console output showing where to find the dashboard frontend token + Screenshot of console output showing where to find the dashboard frontend token + + + + Invalid token. Please try again + Invalid token. Please try again + + + + Log in + Log in + + + + More info + More info + + + + {0} login + {0} login + {0} is an application name + + + Enter token to log in... + Enter token to log in... + + + + Token is required + Token is required + + + + Unexpected error when validating the token + Unexpected error when validating the token + + + + Where do I find the token? + Where do I find the token? + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Utils/CompareHelpers.cs b/src/Aspire.Dashboard/Utils/CompareHelpers.cs new file mode 100644 index 0000000000..56b325daf7 --- /dev/null +++ b/src/Aspire.Dashboard/Utils/CompareHelpers.cs @@ -0,0 +1,42 @@ +// 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.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace Aspire.Dashboard.Utils; + +internal static class CompareHelpers +{ + // This method is used to compare two keys in a way that avoids timing attacks. + public static bool CompareKey(byte[] expectedKeyBytes, string requestKey) + { + const int StackAllocThreshold = 256; + + var requestByteCount = Encoding.UTF8.GetByteCount(requestKey); + + // A rented array could have previous data. However, we're trimming it to the exact byte count we need. + // That means all used bytes are overwritten by the following Encoding.GetBytes call. + byte[]? requestPooled = null; + var requestBytesSpan = (requestByteCount <= StackAllocThreshold ? + stackalloc byte[StackAllocThreshold] : + (requestPooled = ArrayPool.Shared.Rent(requestByteCount))).Slice(0, requestByteCount); + + try + { + var encodedByteCount = Encoding.UTF8.GetBytes(requestKey, requestBytesSpan); + Debug.Assert(encodedByteCount == requestBytesSpan.Length, "Should match because span was previously trimmed to byte count value."); + + return CryptographicOperations.FixedTimeEquals(expectedKeyBytes, requestBytesSpan); + } + finally + { + if (requestPooled != null) + { + ArrayPool.Shared.Return(requestPooled); + } + } + } +} diff --git a/src/Aspire.Dashboard/Utils/DashboardUrls.cs b/src/Aspire.Dashboard/Utils/DashboardUrls.cs index 0719c5fa75..b51655dca8 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUrls.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUrls.cs @@ -100,4 +100,19 @@ public static string TraceDetailUrl(string traceId) { return $"/{TracesBasePath}/detail/{Uri.EscapeDataString(traceId)}"; } + + public static string LoginUrl(string? returnUrl = null, string? token = null) + { + var url = "/login"; + if (returnUrl != null) + { + url = QueryHelpers.AddQueryString(url, "returnUrl", returnUrl); + } + if (token != null) + { + url = QueryHelpers.AddQueryString(url, "t", token); + } + + return url; + } } diff --git a/src/Aspire.Dashboard/Utils/VersionHelpers.cs b/src/Aspire.Dashboard/Utils/VersionHelpers.cs new file mode 100644 index 0000000000..7f6712e551 --- /dev/null +++ b/src/Aspire.Dashboard/Utils/VersionHelpers.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Extensions; + +namespace Aspire.Dashboard.Utils; + +public static class VersionHelpers +{ + public static string? DashboardDisplayVersion { get; } = typeof(VersionHelpers).Assembly.GetDisplayVersion(); +} diff --git a/src/Aspire.Dashboard/wwwroot/img/TokenExample.png b/src/Aspire.Dashboard/wwwroot/img/TokenExample.png new file mode 100644 index 0000000000000000000000000000000000000000..f2a445d69efde79512ffc7bc6277820c8a5cde49 GIT binary patch literal 9284 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 TEAL{Tr~zoI>Z+iWY@+@b1i+2n literal 0 HcmV?d00001 diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 35c3800315..650e108c10 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -24,6 +24,9 @@ + + + diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index f9f6d395db..14ed51a8bb 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -783,10 +783,19 @@ private async Task>> GetDashboardEnvironmentVa KeyValuePair.Create(DashboardConfigNames.ResourceServiceUrlName.EnvVarName, resourceServiceUrl), KeyValuePair.Create(DashboardConfigNames.DashboardOtlpUrlName.EnvVarName, otlpEndpointUrl), KeyValuePair.Create(DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName, "Unsecured"), - KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "Unsecured"), }; - if (configuration["AppHost:OtlpApiKey"] is { } otlpApiKey) + if (configuration["AppHost:BrowserToken"] is { Length: > 0 } browserToken) + { + env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "BrowserToken")); + env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName, browserToken)); + } + else + { + env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "Unsecured")); + } + + if (configuration["AppHost:OtlpApiKey"] is { Length: > 0 } otlpApiKey) { env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName, "ApiKey")); env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName, otlpApiKey)); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 086ada7038..e0859c8a01 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -28,8 +28,6 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder private const string BuilderConstructingEventName = "DistributedApplicationBuilderConstructing"; private const string BuilderConstructedEventName = "DistributedApplicationBuilderConstructed"; - private const string DisableOtlpApiKeyAuthKey = "DOTNET_DISABLE_OTLP_API_KEY_AUTH"; - private readonly HostApplicationBuilder _innerBuilder; /// @@ -80,12 +78,40 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) AppHostDirectory = options.ProjectDirectory ?? _innerBuilder.Environment.ContentRootPath; + // Set configuration + ConfigurePublishingOptions(options); _innerBuilder.Configuration.AddInMemoryCollection(new Dictionary { // Make the app host directory available to the application via configuration ["AppHost:Directory"] = AppHostDirectory }); + if (!options.DisableDashboard && !IsDashboardUnsecured(_innerBuilder.Configuration)) + { + // Set a random API key for the OTLP exporter. + // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. + _innerBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["AppHost:OtlpApiKey"] = TokenGenerator.GenerateToken() + } + ); + + if (_innerBuilder.Configuration[KnownConfigNames.DashboardFrontendBrowserToken] is not { Length: > 0 } browserToken) + { + browserToken = TokenGenerator.GenerateToken(); + } + + // Set a random API key for the OTLP exporter. + // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. + _innerBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["AppHost:BrowserToken"] = browserToken + } + ); + } + // Core things _innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources)); _innerBuilder.Services.AddHostedService(); @@ -112,7 +138,6 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); // Publishing support - ConfigurePublishingOptions(options); _innerBuilder.Services.AddLifecycleHook(); _innerBuilder.Services.AddKeyedSingleton("manifest"); _innerBuilder.Services.AddKeyedSingleton("dcp"); @@ -135,9 +160,9 @@ private void MapTransportOptionsFromCustomKeys(TransportOptions options) } } - private static bool IsOtlpApiKeyAuthDisabled(IConfiguration configuration) + private static bool IsDashboardUnsecured(IConfiguration configuration) { - return configuration.GetBool(DisableOtlpApiKeyAuthKey) ?? false; + return configuration.GetBool(KnownConfigNames.DashboardUnsecuredAllowAnonymous) ?? false; } private void ConfigurePublishingOptions(DistributedApplicationOptions options) @@ -179,18 +204,6 @@ public DistributedApplication Build() throw new DistributedApplicationException($"Multiple resources with the name '{duplicateResourceName}'. Resource names are case-insensitive."); } - if (!IsOtlpApiKeyAuthDisabled(_innerBuilder.Configuration)) - { - // Set a random API key for the OTLP exporter. - // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. - _innerBuilder.Configuration.AddInMemoryCollection( - new Dictionary - { - ["AppHost:OtlpApiKey"] = Guid.NewGuid().ToString() - } - ); - } - var application = new DistributedApplication(_innerBuilder.Build()); LogAppBuilt(application); return application; diff --git a/src/Aspire.Hosting/DistributedApplicationLifecycle.cs b/src/Aspire.Hosting/DistributedApplicationLifecycle.cs index b995678cd7..3292b81560 100644 --- a/src/Aspire.Hosting/DistributedApplicationLifecycle.cs +++ b/src/Aspire.Hosting/DistributedApplicationLifecycle.cs @@ -8,7 +8,11 @@ namespace Aspire.Hosting; -internal sealed class DistributedApplicationLifecycle(ILogger logger, IConfiguration configuration, DistributedApplicationExecutionContext executionContext) : IHostedLifecycleService +internal sealed class DistributedApplicationLifecycle( + ILogger logger, + IConfiguration configuration, + DistributedApplicationExecutionContext executionContext, + DistributedApplicationOptions distributedApplicationOptions) : IHostedLifecycleService { public Task StartAsync(CancellationToken cancellationToken) { @@ -17,6 +21,11 @@ public Task StartAsync(CancellationToken cancellationToken) public Task StartedAsync(CancellationToken cancellationToken) { + if (distributedApplicationOptions.DashboardEnabled && configuration["AppHost:BrowserToken"] is { Length: > 0 } browserToken) + { + LoggingHelpers.WriteDashboardUrl(logger, configuration["ASPNETCORE_URLS"], browserToken); + } + if (executionContext.IsRunMode) { logger.LogInformation("Distributed application started. Press Ctrl+C to shut down."); diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 743b3f3ee7..d0a8a278d1 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -15,6 +15,7 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY"); public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY"); public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE"); + public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN"); public static readonly ConfigName ResourceServiceAuthModeName = new("Dashboard:ResourceServiceClient:AuthMode", "DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE"); } diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 3e7efd5ac3..f18a929c34 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -5,8 +5,10 @@ namespace Aspire.Hosting; internal static class KnownConfigNames { - public static string AspNetCoreUrls = "ASPNETCORE_URLS"; - public static string AllowUnsecuredTransport = "ASPIRE_ALLOW_UNSECURED_TRANSPORT"; - public static string DashboardOtlpEndpointUrl = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; - public static string ResourceServiceEndpointUrl = "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL"; + public const string AspNetCoreUrls = "ASPNETCORE_URLS"; + public const string AllowUnsecuredTransport = "ASPIRE_ALLOW_UNSECURED_TRANSPORT"; + public const string DashboardOtlpEndpointUrl = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; + public const string DashboardFrontendBrowserToken = "DOTNET_DASHBOARD_FRONTEND_BROWSERTOKEN"; + public const string DashboardUnsecuredAllowAnonymous = "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; + public const string ResourceServiceEndpointUrl = "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL"; } diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs new file mode 100644 index 0000000000..686ad7cb7f --- /dev/null +++ b/src/Shared/LoggingHelpers.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal static class LoggingHelpers +{ + public static void WriteDashboardUrl(ILogger logger, string? dashboardUrls, string? token) + { + if (string.IsNullOrEmpty(token)) + { + throw new InvalidOperationException("Token must be provided."); + } + + if (StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) + { + logger.LogInformation("Login to the dashboard at {DashboardUrl}", $"{firstDashboardUrl.GetLeftPart(UriPartial.Authority)}/login?t={token}"); + } + } +} diff --git a/src/Aspire.Hosting/Utils/StringUtils.cs b/src/Shared/StringUtils.cs similarity index 80% rename from src/Aspire.Hosting/Utils/StringUtils.cs rename to src/Shared/StringUtils.cs index 097fe09ce4..aadb43bddf 100644 --- a/src/Aspire.Hosting/Utils/StringUtils.cs +++ b/src/Shared/StringUtils.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Utils; internal static class StringUtils { - public static bool TryGetUriFromDelimitedString(string input, string delimiter, [NotNullWhen(true)] out Uri? uri) + public static bool TryGetUriFromDelimitedString([NotNullWhen(true)] string? input, string delimiter, [NotNullWhen(true)] out Uri? uri) { if (!string.IsNullOrEmpty(input) && input.Split(delimiter) is { Length: > 0 } splitInput diff --git a/src/Shared/TokenGenerator.cs b/src/Shared/TokenGenerator.cs new file mode 100644 index 0000000000..474680f506 --- /dev/null +++ b/src/Shared/TokenGenerator.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace Aspire.Hosting; + +internal static class TokenGenerator +{ + public static string GenerateToken() + { + // Generate a 128-bit entropy token + var tokenBytes = GenerateEntropyToken(size: 16); // 16 bytes = 128 bits + + string tokenHex; +#if NET9_0_OR_GREATER + tokenHex = Convert.ToHexStringLower(tokenBytes); +#else + tokenHex = Convert.ToHexString(tokenBytes).ToLowerInvariant(); +#endif + + return tokenHex; + } + + private static byte[] GenerateEntropyToken(int size) + { +#if NET6_0_OR_GREATER + return RandomNumberGenerator.GetBytes(size); +#else + using (var rng = new RNGCryptoServiceProvider()) + { + byte[] token = new byte[size]; + rng.GetBytes(token); + return token; + } +#endif + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendAuthTests.cs new file mode 100644 index 0000000000..292a5e8a34 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendAuthTests.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http.Json; +using System.Web; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Utils; +using Aspire.Hosting; +using Microsoft.Extensions.Logging.Testing; +using Xunit; +using Xunit.Abstractions; + +namespace Aspire.Dashboard.Tests.Integration; + +public class FrontendAuthTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public FrontendAuthTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task Get_Unauthenticated_RedirectToLogin() + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + }); + await app.StartAsync(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendEndPointAccessor().EndPoint}") }; + + // Act + var response = await client.GetAsync("/"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.StructuredLogsUrl()), response.RequestMessage!.RequestUri!.PathAndQuery); + } + + [Fact] + public async Task Get_LoginPage_ValidToken_RedirectToApp() + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + }); + await app.StartAsync(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendEndPointAccessor().EndPoint}") }; + + // Act 1 + var response1 = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)); + + // Assert 1 + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + Assert.Equal(DashboardUrls.TracesUrl(), response1.RequestMessage!.RequestUri!.PathAndQuery); + + // Act 2 + var response2 = await client.GetAsync(DashboardUrls.StructuredLogsUrl()); + + // Assert 2 + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + Assert.Equal(DashboardUrls.StructuredLogsUrl(), response2.RequestMessage!.RequestUri!.PathAndQuery); + } + + [Fact] + public async Task Get_LoginPage_InvalidToken_RedirectToLoginWithoutToken() + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + }); + await app.StartAsync(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendEndPointAccessor().EndPoint}") }; + + // Act + var response = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: "Wrong!")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl()), response.RequestMessage!.RequestUri!.PathAndQuery, ignoreCase: true); + } + + [Theory] + [InlineData(FrontendAuthMode.BrowserToken, "TestKey123!", HttpStatusCode.OK, true)] + [InlineData(FrontendAuthMode.BrowserToken, "Wrong!", HttpStatusCode.OK, false)] + [InlineData(FrontendAuthMode.Unsecured, "Wrong!", HttpStatusCode.BadRequest, null)] + public async Task Post_ValidateTokenApi_AvailableBasedOnOptions(FrontendAuthMode authMode, string requestToken, HttpStatusCode statusCode, bool? result) + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = authMode.ToString(); + config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + }); + await app.StartAsync(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendEndPointAccessor().EndPoint}") }; + + // Act + var response = await client.PostAsync("/api/validatetoken?token=" + requestToken, content: null); + + // Assert + Assert.Equal(statusCode, response.StatusCode); + + if (result != null) + { + var actualResult = await response.Content.ReadFromJsonAsync(); + Assert.Equal(result, actualResult); + } + } + + [Fact] + public async Task LogOutput_NoToken_GeneratedTokenLogged() + { + // Arrange + var testSink = new TestSink(); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + }, testSink: testSink); + + // Act + await app.StartAsync(); + + // Assert + var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); + Assert.Collection(l, + w => + { + Assert.Equal("Aspire version: {Version}", GetValue(w.State, "{OriginalFormat}")); + }, + w => + { + Assert.Equal("Now listening on: {DashboardUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "DashboardUri")!); + Assert.NotEqual(0, uri.Port); + }, + w => + { + Assert.Equal("Login to the dashboard at {DashboardUrl}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "DashboardUrl")!, UriKind.Absolute); + var queryString = HttpUtility.ParseQueryString(uri.Query); + Assert.NotNull(queryString["t"]); + }, + w => + { + Assert.Equal("OTLP server running at: {OtlpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "OtlpEndpointUri")!); + Assert.NotEqual(0, uri.Port); + }); + + object? GetValue(object? values, string key) + { + var list = values as IReadOnlyList>; + return list?.SingleOrDefault(kvp => kvp.Key == key).Value; + } + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs index ea4bc382d7..de80c2e53a 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs @@ -230,7 +230,7 @@ public async Task CallService_BrowserEndPoint_Failure() await app.StartAsync(); using var channel = IntegrationTestHelpers.CreateGrpcChannel( - $"https://{app.BrowserEndPointAccessor().EndPoint}", + $"https://{app.FrontendEndPointAccessor().EndPoint}", _testOutputHelper, validationCallback: cert => { diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index b4496e5989..fe5cad0626 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -25,7 +25,7 @@ public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned() await app.StartAsync(); // Assert - AssertDynamicIPEndpoint(app.BrowserEndPointAccessor); + AssertDynamicIPEndpoint(app.FrontendEndPointAccessor); AssertDynamicIPEndpoint(app.OtlpServiceEndPointAccessor); } @@ -45,6 +45,7 @@ public async Task Configuration_NoExtraConfig_Error() // Assert Assert.Collection(ex.Failures, s => s.Contains("Dashboard:Frontend:EndpointUrls"), + s => s.Contains("Dashboard:Frontend:AuthMode"), s => s.Contains("Dashboard:Otlp:EndpointUrl"), s => s.Contains("Dashboard:Otlp:AuthMode")); } @@ -108,7 +109,7 @@ await ServerRetryHelper.BindPortsWithRetry(async port => // Assert Assert.NotNull(app); - Assert.Equal(app.BrowserEndPointAccessor().EndPoint.Port, app.OtlpServiceEndPointAccessor().EndPoint.Port); + Assert.Equal(app.FrontendEndPointAccessor().EndPoint.Port, app.OtlpServiceEndPointAccessor().EndPoint.Port); // Check browser access using var httpClient = new HttpClient(new HttpClientHandler @@ -119,14 +120,14 @@ await ServerRetryHelper.BindPortsWithRetry(async port => } }) { - BaseAddress = new Uri($"https://{app.BrowserEndPointAccessor().EndPoint}") + BaseAddress = new Uri($"https://{app.FrontendEndPointAccessor().EndPoint}") }; var request = new HttpRequestMessage(HttpMethod.Get, "/"); var response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); // Check OTLP service - using var channel = IntegrationTestHelpers.CreateGrpcChannel($"https://{app.BrowserEndPointAccessor().EndPoint}", testOutputHelper); + using var channel = IntegrationTestHelpers.CreateGrpcChannel($"https://{app.FrontendEndPointAccessor().EndPoint}", testOutputHelper); var client = new LogsService.LogsServiceClient(channel); var serviceResponse = await client.ExportAsync(new ExportLogsServiceRequest()); Assert.Equal(0, serviceResponse.PartialSuccess.RejectedLogRecords); @@ -221,7 +222,7 @@ public async Task Configuration_AllowAnonymous_NoError() await app.StartAsync(); // Assert - AssertDynamicIPEndpoint(app.BrowserEndPointAccessor); + AssertDynamicIPEndpoint(app.FrontendEndPointAccessor); AssertDynamicIPEndpoint(app.OtlpServiceEndPointAccessor); } @@ -273,7 +274,7 @@ public async Task EndPointAccessors_AppStarted_BrowserGet_Success() // Act await app.StartAsync(); - using var client = new HttpClient { BaseAddress = new Uri($"http://{app.BrowserEndPointAccessor().EndPoint}") }; + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendEndPointAccessor().EndPoint}") }; // Act var response = await client.GetAsync("/"); diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index 62783050d3..0ff69cd516 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -31,6 +31,54 @@ public async Task RunApplicationAsync_NoResources_DashboardStarted() Assert.Equal("aspire-dashboard", dashboard.Metadata.Name); } + [Fact] + public async Task RunApplicationAsync_AuthConfigured_EnvVarsPresent() + { + // Arrange + var distributedAppModel = new DistributedApplicationModel(new ResourceCollection()); + var kubernetesService = new MockKubernetesService(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + // Act + await appExecutor.RunApplicationAsync(); + + // Assert + var dashboard = Assert.IsType(Assert.Single(kubernetesService.CreatedResources)); + Assert.NotNull(dashboard.Spec.Env); + + Assert.Equal("BrowserToken", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); + Assert.Equal("TestBrowserToken!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName).Value); + + Assert.Equal("ApiKey", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); + Assert.Equal("TestOtlpApiKey!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName).Value); + } + + [Fact] + public async Task RunApplicationAsync_AuthRemoved_EnvVarsUnsecured() + { + // Arrange + var distributedAppModel = new DistributedApplicationModel(new ResourceCollection()); + var kubernetesService = new MockKubernetesService(); + var builder = new ConfigurationBuilder(); + builder.AddInMemoryCollection(new Dictionary + { + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" + }); + + var appExecutor = CreateAppExecutor(distributedAppModel, configuration: builder.Build(), kubernetesService: kubernetesService); + + // Act + await appExecutor.RunApplicationAsync(); + + // Assert + var dashboard = Assert.IsType(Assert.Single(kubernetesService.CreatedResources)); + Assert.NotNull(dashboard.Spec.Env); + + Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); + Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); + } + [Fact] public async Task ContainersArePassedOtelServiceName() { @@ -63,7 +111,9 @@ private static ApplicationExecutor CreateAppExecutor( var builder = new ConfigurationBuilder(); builder.AddInMemoryCollection(new Dictionary { - ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost" + ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost", + ["AppHost:BrowserToken"] = "TestBrowserToken!", + ["AppHost:OtlpApiKey"] = "TestOtlpApiKey!" }); configuration = builder.Build(); diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 8fc808fe11..13e59bea1c 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -272,12 +272,6 @@ public async Task SpecifyingEnvPortInEndpointFlowsToEnv() var nodeApp = await KubernetesHelper.GetResourceByNameAsync(kubernetes, "nodeapp", r => r.Status?.EffectiveEnv is not null, token); Assert.NotNull(nodeApp); - string? GetEnv(IEnumerable? envVars, string name) - { - Assert.NotNull(envVars); - return Assert.Single(envVars.Where(e => e.Name == name)).Value; - }; - Assert.Equal("redis:latest", redisContainer.Spec.Image); Assert.Equal("{{- portForServing \"redis0\" }}", GetEnv(redisContainer.Spec.Env, "REDIS_PORT")); Assert.Equal("6379", GetEnv(redisContainer.Status!.EffectiveEnv, "REDIS_PORT")); @@ -293,6 +287,89 @@ public async Task SpecifyingEnvPortInEndpointFlowsToEnv() Assert.NotEqual(0, int.Parse(nodeAppPortValue, CultureInfo.InvariantCulture)); await app.StopAsync(); + + static string? GetEnv(IEnumerable? envVars, string name) + { + Assert.NotNull(envVars); + return Assert.Single(envVars.Where(e => e.Name == name)).Value; + } + } + + [LocalOnlyFact("docker")] + public async Task StartAsync_DashboardAuthConfig_PassedToDashboardProcess() + { + var browserToken = "ThisIsATestToken"; + var args = new string[] { + "ASPNETCORE_URLS=http://localhost:0", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:0", + $"DOTNET_DASHBOARD_FRONTEND_BROWSERTOKEN={browserToken}" + }; + using var testProgram = CreateTestProgram(args: args, disableDashboard: false); + + testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper)); + + await using var app = testProgram.Build(); + + var kubernetes = app.Services.GetRequiredService(); + + await app.StartAsync(); + + using var cts = new CancellationTokenSource(Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(10)); + var token = cts.Token; + + var aspireDashboard = await KubernetesHelper.GetResourceByNameAsync(kubernetes, "aspire-dashboard", r => r.Status?.EffectiveEnv is not null, token); + Assert.NotNull(aspireDashboard); + + Assert.Equal("BrowserToken", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__AUTHMODE")); + Assert.Equal("ThisIsATestToken", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__BROWSERTOKEN")); + + Assert.Equal("ApiKey", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__AUTHMODE")); + var keyBytes = Convert.FromHexString(GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__PRIMARYAPIKEY")!); + Assert.Equal(16, keyBytes.Length); + + await app.StopAsync(); + + static string? GetEnv(IEnumerable? envVars, string name) + { + Assert.NotNull(envVars); + return Assert.Single(envVars.Where(e => e.Name == name)).Value; + } + } + + [LocalOnlyFact("docker")] + public async Task StartAsync_UnsecuredAllowAnonymous_PassedToDashboardProcess() + { + var args = new string[] { + "ASPNETCORE_URLS=http://localhost:0", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:0", + "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true" + }; + using var testProgram = CreateTestProgram(args: args, disableDashboard: false); + + testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper)); + + await using var app = testProgram.Build(); + + var kubernetes = app.Services.GetRequiredService(); + + await app.StartAsync(); + + using var cts = new CancellationTokenSource(Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(10)); + var token = cts.Token; + + var aspireDashboard = await KubernetesHelper.GetResourceByNameAsync(kubernetes, "aspire-dashboard", r => r.Status?.EffectiveEnv is not null, token); + Assert.NotNull(aspireDashboard); + + Assert.Equal("Unsecured", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__AUTHMODE")); + Assert.Equal("Unsecured", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__AUTHMODE")); + + await app.StopAsync(); + + static string? GetEnv(IEnumerable? envVars, string name) + { + Assert.NotNull(envVars); + return Assert.Single(envVars.Where(e => e.Name == name)).Value; + } } [LocalOnlyFact("docker")] @@ -716,6 +793,6 @@ public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appMode } } - private static TestProgram CreateTestProgram(string[]? args = null, bool includeIntegrationServices = false, bool includeNodeApp = false) => - TestProgram.Create(args, includeIntegrationServices: includeIntegrationServices, includeNodeApp: includeNodeApp); + private static TestProgram CreateTestProgram(string[]? args = null, bool includeIntegrationServices = false, bool includeNodeApp = false, bool disableDashboard = true) => + TestProgram.Create(args, includeIntegrationServices: includeIntegrationServices, includeNodeApp: includeNodeApp, disableDashboard: disableDashboard); } diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 29d0e2ba3c..74524d50bb 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -5,7 +5,6 @@ using Aspire.Hosting.Tests.Helpers; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -91,11 +90,7 @@ public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata() [InlineData(null, true)] public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata_OtlpAuthDisabledSetting(string? value, bool hasHeader) { - var appBuilder = CreateBuilder(); - appBuilder.Configuration.AddInMemoryCollection(new Dictionary - { - ["DOTNET_DISABLE_OTLP_API_KEY_AUTH"] = value - }); + var appBuilder = CreateBuilder(args: [$"DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS={value}"]); appBuilder.AddProject("projectName", launchProfileName: null); using var app = appBuilder.Build(); @@ -108,7 +103,14 @@ public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata_OtlpAuthD var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(resource); - Assert.Equal(hasHeader, config.ContainsKey("OTEL_EXPORTER_OTLP_HEADERS")); + if (hasHeader) + { + Assert.True(config.ContainsKey("OTEL_EXPORTER_OTLP_HEADERS"), "Config should have 'OTEL_EXPORTER_OTLP_HEADERS' header and doesn't."); + } + else + { + Assert.False(config.ContainsKey("OTEL_EXPORTER_OTLP_HEADERS"), "Config shouldn't have 'OTEL_EXPORTER_OTLP_HEADERS' header and does."); + } } [Fact] @@ -282,10 +284,18 @@ public async Task VerifyManifest(bool disableForwardedHeaders) Assert.Equal(expectedManifest, manifest.ToString()); } - private static IDistributedApplicationBuilder CreateBuilder(DistributedApplicationOperation operation = DistributedApplicationOperation.Publish) + private static IDistributedApplicationBuilder CreateBuilder(string[]? args = null, DistributedApplicationOperation operation = DistributedApplicationOperation.Publish) { - var args = operation == DistributedApplicationOperation.Publish ? new[] { "--publisher", "manifest" } : Array.Empty(); - var appBuilder = DistributedApplication.CreateBuilder(args); + var resolvedArgs = new List(); + if (args != null) + { + resolvedArgs.AddRange(args); + } + if (operation == DistributedApplicationOperation.Publish) + { + resolvedArgs.AddRange(["--publisher", "manifest"]); + } + var appBuilder = DistributedApplication.CreateBuilder(resolvedArgs.ToArray()); // Block DCP from actually starting anything up as we don't need it for this test. appBuilder.Services.AddKeyedSingleton("manifest"); From 1e6d2f95f502a99abe73befbf80aed2d55e1f3c9 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 3 Apr 2024 11:08:34 +0800 Subject: [PATCH 2/4] Require specific claim --- .../DashboardWebApplication.cs | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index c5eeb3ea31..26c8bde74c 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -467,6 +467,14 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb options.LoginPath = "/login"; options.ReturnUrlParameter = "returnUrl"; options.ExpireTimeSpan = TimeSpan.FromDays(1); + options.Events.OnSigningIn = context => + { + // Add claim when signing in with cookies from browser token. + // Authorization requires this claim. This prevents an identity from another auth scheme from being allow. + var claimsIdentity = (ClaimsIdentity)context.Principal!.Identity!; + claimsIdentity.AddClaim(new Claim(FrontendAuthorizationDefaults.BrowserTokenClaimName, bool.TrueString)); + return Task.CompletedTask; + }; }); break; } @@ -475,8 +483,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb { options.AddPolicy( name: OtlpAuthorization.PolicyName, - policy: new AuthorizationPolicyBuilder( - OtlpCompositeAuthenticationDefaults.AuthenticationScheme) + policy: new AuthorizationPolicyBuilder(OtlpCompositeAuthenticationDefaults.AuthenticationScheme) .RequireClaim(OtlpAuthorization.OtlpClaimName) .Build()); @@ -486,25 +493,26 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb // Frontend is secured with OIDC, so delegate to that authentication scheme. options.AddPolicy( name: FrontendAuthorizationDefaults.PolicyName, - policy: new AuthorizationPolicyBuilder( - FrontendAuthenticationDefaults.AuthenticationScheme) + policy: new AuthorizationPolicyBuilder(FrontendAuthenticationDefaults.AuthenticationScheme) .RequireAuthenticatedUser() .Build()); break; case FrontendAuthMode.BrowserToken: options.AddPolicy( name: FrontendAuthorizationDefaults.PolicyName, - policy: new AuthorizationPolicyBuilder( - FrontendAuthenticationDefaults.AuthenticationScheme) - .RequireAuthenticatedUser() + policy: new AuthorizationPolicyBuilder(FrontendAuthenticationDefaults.AuthenticationScheme) + .RequireClaim(FrontendAuthorizationDefaults.BrowserTokenClaimName) .Build()); break; case FrontendAuthMode.Unsecured: - // Frontend is unsecured so our policy doesn't need any special handling. options.AddPolicy( name: FrontendAuthorizationDefaults.PolicyName, policy: new AuthorizationPolicyBuilder() - .RequireAssertion(_ => true) + .RequireAssertion(_ => + { + // Frontend is unsecured so our policy doesn't require anything. + return true; + }) .Build()); break; default: @@ -537,4 +545,5 @@ public record EndpointInfo(IPEndPoint EndPoint, bool isHttps); public static class FrontendAuthorizationDefaults { public const string PolicyName = "Frontend"; + public const string BrowserTokenClaimName = "BrowserTokenClaim"; } From a05bb1b18f35c825ed165f828cade861daf0e158 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 4 Apr 2024 13:01:22 +0800 Subject: [PATCH 3/4] PR feedback --- src/Aspire.Dashboard/Utils/CompareHelpers.cs | 2 +- src/Shared/TokenGenerator.cs | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Aspire.Dashboard/Utils/CompareHelpers.cs b/src/Aspire.Dashboard/Utils/CompareHelpers.cs index 56b325daf7..b13d632c55 100644 --- a/src/Aspire.Dashboard/Utils/CompareHelpers.cs +++ b/src/Aspire.Dashboard/Utils/CompareHelpers.cs @@ -35,7 +35,7 @@ public static bool CompareKey(byte[] expectedKeyBytes, string requestKey) { if (requestPooled != null) { - ArrayPool.Shared.Return(requestPooled); + ArrayPool.Shared.Return(requestPooled, clearArray: true); } } } diff --git a/src/Shared/TokenGenerator.cs b/src/Shared/TokenGenerator.cs index 474680f506..88a8c94ea4 100644 --- a/src/Shared/TokenGenerator.cs +++ b/src/Shared/TokenGenerator.cs @@ -24,15 +24,6 @@ public static string GenerateToken() private static byte[] GenerateEntropyToken(int size) { -#if NET6_0_OR_GREATER return RandomNumberGenerator.GetBytes(size); -#else - using (var rng = new RNGCryptoServiceProvider()) - { - byte[] token = new byte[size]; - rng.GetBytes(token); - return token; - } -#endif } } From c213d86f7a26b870c6812e622fb7232e58781783 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 4 Apr 2024 13:03:58 +0800 Subject: [PATCH 4/4] Comment --- src/Aspire.Dashboard/Utils/CompareHelpers.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Dashboard/Utils/CompareHelpers.cs b/src/Aspire.Dashboard/Utils/CompareHelpers.cs index b13d632c55..ae9d61f91b 100644 --- a/src/Aspire.Dashboard/Utils/CompareHelpers.cs +++ b/src/Aspire.Dashboard/Utils/CompareHelpers.cs @@ -35,6 +35,7 @@ public static bool CompareKey(byte[] expectedKeyBytes, string requestKey) { if (requestPooled != null) { + // Data might be considered sensitive so clear array when returning it. ArrayPool.Shared.Return(requestPooled, clearArray: true); } }