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..26c8bde74c 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,50 +418,72 @@ 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); + 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; } builder.Services.AddAuthorization(options => { options.AddPolicy( name: OtlpAuthorization.PolicyName, - policy: new AuthorizationPolicyBuilder( - OtlpCompositeAuthenticationDefaults.AuthenticationScheme) + policy: new AuthorizationPolicyBuilder(OtlpCompositeAuthenticationDefaults.AuthenticationScheme) .RequireClaim(OtlpAuthorization.OtlpClaimName) .Build()); @@ -443,17 +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) + .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: @@ -486,4 +545,5 @@ public record EndpointInfo(IPEndPoint EndPoint, bool isHttps); public static class FrontendAuthorizationDefaults { public const string PolicyName = "Frontend"; + public const string BrowserTokenClaimName = "BrowserTokenClaim"; } 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..ae9d61f91b --- /dev/null +++ b/src/Aspire.Dashboard/Utils/CompareHelpers.cs @@ -0,0 +1,43 @@ +// 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) + { + // Data might be considered sensitive so clear array when returning it. + ArrayPool.Shared.Return(requestPooled, clearArray: true); + } + } + } +} 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 0000000000..f2a445d69e Binary files /dev/null and b/src/Aspire.Dashboard/wwwroot/img/TokenExample.png differ 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..88a8c94ea4 --- /dev/null +++ b/src/Shared/TokenGenerator.cs @@ -0,0 +1,29 @@ +// 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) + { + return RandomNumberGenerator.GetBytes(size); + } +} 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");