diff --git a/Directory.Packages.props b/Directory.Packages.props index a106025470..2f6ab4b179 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/eng/Versions.props b/eng/Versions.props index f55b70716b..a60cb2f4ca 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -34,6 +34,7 @@ 8.0.2 8.0.0 8.0.3 + 8.0.3 8.0.3 8.0.2 8.0.2 diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index bcdd934dbe..cfeb29ed9b 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs b/src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs index 14c9f5e2a3..e4a5f4f158 100644 --- a/src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs +++ b/src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs @@ -5,7 +5,7 @@ namespace Aspire.Dashboard.Authentication; public enum OtlpAuthMode { - None, + Unsecured, ApiKey, ClientCertificate } diff --git a/src/Aspire.Dashboard/Authentication/OtlpCompositeAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/OtlpCompositeAuthenticationHandler.cs index 949fbbc01c..4d01cef720 100644 --- a/src/Aspire.Dashboard/Authentication/OtlpCompositeAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/OtlpCompositeAuthenticationHandler.cs @@ -11,30 +11,18 @@ namespace Aspire.Dashboard.Authentication; -public sealed class OtlpCompositeAuthenticationHandler : AuthenticationHandler +public sealed class OtlpCompositeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) { - public OtlpCompositeAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) - { - } - protected override async Task HandleAuthenticateAsync() { - var connectionResult = await Context.AuthenticateAsync(OtlpConnectionAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false); - if (connectionResult.Failure != null) - { - return connectionResult; - } - - var scheme = Options.OtlpAuthMode switch - { - OtlpAuthMode.ApiKey => OtlpApiKeyAuthenticationDefaults.AuthenticationScheme, - OtlpAuthMode.ClientCertificate => CertificateAuthenticationDefaults.AuthenticationScheme, - _ => null - }; - - if (scheme is not null) + foreach (var scheme in GetRelevantAuthenticationSchemes()) { var result = await Context.AuthenticateAsync(scheme).ConfigureAwait(false); + if (result.Failure is not null) { return result; @@ -44,6 +32,20 @@ protected override async Task HandleAuthenticateAsync() var id = new ClaimsIdentity([new Claim(OtlpAuthorization.OtlpClaimName, bool.TrueString)]); return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(id), Scheme.Name)); + + IEnumerable GetRelevantAuthenticationSchemes() + { + yield return OtlpConnectionAuthenticationDefaults.AuthenticationScheme; + + if (Options.OtlpAuthMode is OtlpAuthMode.ApiKey) + { + yield return OtlpApiKeyAuthenticationDefaults.AuthenticationScheme; + } + else if (Options.OtlpAuthMode is OtlpAuthMode.ClientCertificate) + { + yield return CertificateAuthenticationDefaults.AuthenticationScheme; + } + } } } diff --git a/src/Aspire.Dashboard/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index f6e86941f6..9b80535598 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -1,5 +1,8 @@ @using System.Net.Http @using System.Net.Http.Json +@using Aspire.Dashboard.Authentication +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @@ -10,3 +13,6 @@ @using Aspire.Dashboard.Components @using Aspire.Dashboard.Components.Controls @using Microsoft.Extensions.Localization + +@* Require authorization for all pages of the web app *@ +@attribute [Authorize(Policy = FrontendAuthorizationDefaults.PolicyName)] diff --git a/src/Aspire.Dashboard/DashboardStartupConfiguration.cs b/src/Aspire.Dashboard/DashboardStartupConfiguration.cs deleted file mode 100644 index 3518625051..0000000000 --- a/src/Aspire.Dashboard/DashboardStartupConfiguration.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Authentication; - -namespace Aspire.Dashboard; - -public sealed class DashboardStartupConfiguration -{ - public required Uri[] BrowserUris { get; init; } - public required Uri OtlpUri { get; init; } - public required OtlpAuthMode OtlpAuthMode { get; init; } - public required string? OtlpApiKey { get; init; } -} diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index b536ab132e..85be6b5bd8 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -12,19 +12,25 @@ using Aspire.Dashboard.Otlp.Grpc; using Aspire.Dashboard.Otlp.Storage; using Microsoft.AspNetCore.Authentication.Certificate; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Aspire.Dashboard; -public class DashboardWebApplication : IAsyncDisposable +public sealed class DashboardWebApplication : IAsyncDisposable { - internal const string DashboardInsecureAllowAnonymousVariableName = "DOTNET_DASHBOARD_INSECURE_ALLOW_ANONYMOUS"; - internal const string DashboardOtlpAuthModeVariableName = "DOTNET_DASHBOARD_OTLP_AUTH_MODE"; - internal const string DashboardOtlpApiKeyVariableName = "DOTNET_DASHBOARD_OTLP_API_KEY"; + internal const string FrontendAuthModeKey = "Frontend:AuthMode"; + + internal const string OtlpAuthModeKey = "Otlp:AuthMode"; + internal const string OtlpApiKeyKey = "Otlp:ApiKey"; + internal const string DashboardOtlpUrlVariableName = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; internal const string DashboardOtlpUrlDefaultValue = "http://localhost:18889"; internal const string DashboardUrlVariableName = "ASPNETCORE_URLS"; @@ -51,10 +57,8 @@ public Func OtlpServiceEndPointAccessor public DashboardWebApplication(Action? configureBuilder = null) { var builder = WebApplication.CreateBuilder(); - if (configureBuilder != null) - { - configureBuilder(builder); - } + + configureBuilder?.Invoke(builder); #if !DEBUG builder.Logging.AddFilter("Default", LogLevel.Information); @@ -320,8 +324,8 @@ static Func CreateEndPointAccessor(ListenOptions options, bool isH private static void ConfigureAuthentication(WebApplicationBuilder builder, DashboardStartupConfiguration dashboardStartupConfig) { - builder.Services - .AddAuthentication(defaultScheme: OtlpCompositeAuthenticationDefaults.AuthenticationScheme) + var authentication = builder.Services + .AddAuthentication() .AddScheme(OtlpCompositeAuthenticationDefaults.AuthenticationScheme, o => { o.OtlpAuthMode = dashboardStartupConfig.OtlpAuthMode; @@ -333,7 +337,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .AddScheme(OtlpConnectionAuthenticationDefaults.AuthenticationScheme, o => { }) .AddCertificate(options => { - // Bind options to configuration so they can be override by environment variables. + // Bind options to configuration so they can be overridden by environment variables. builder.Configuration.Bind("CertificateAuthentication", options); options.Events = new CertificateAuthenticationEvents @@ -358,9 +362,76 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb }; }); + if (dashboardStartupConfig.FrontendAuthMode == 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.AddOpenIdConnect(options => + { + // Use authorization code flow so clients don't see access tokens. + options.ResponseType = OpenIdConnectResponseType.Code; + + 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); + } + + if (!options.Scope.Contains("profile")) + { + options.Scope.Add("profile"); + } + + // 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; + }); + } + builder.Services.AddAuthorization(options => { - options.AddPolicy(OtlpAuthorization.PolicyName, policy => policy.RequireClaim(OtlpAuthorization.OtlpClaimName)); + options.AddPolicy( + name: OtlpAuthorization.PolicyName, + policy: new AuthorizationPolicyBuilder( + OtlpCompositeAuthenticationDefaults.AuthenticationScheme) + .RequireClaim(OtlpAuthorization.OtlpClaimName) + .Build()); + + if (dashboardStartupConfig.FrontendAuthMode == FrontendAuthMode.OpenIdConnect) + { + // Frontend is secured with OIDC, so delegate to that authentication scheme. + options.AddPolicy( + name: FrontendAuthorizationDefaults.PolicyName, + policy: new AuthorizationPolicyBuilder( + FrontendAuthenticationDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .Build()); + } + else if (dashboardStartupConfig.FrontendAuthMode == FrontendAuthMode.Unsecured) + { + // Frontend is unsecured so our policy doesn't need any special handling. + options.AddPolicy( + name: FrontendAuthorizationDefaults.PolicyName, + policy: new AuthorizationPolicyBuilder() + .RequireAssertion(_ => true) + .Build()); + } + else + { + throw new NotSupportedException($"Unexpected {nameof(FrontendAuthMode)} enum member."); + } }); } @@ -382,55 +453,74 @@ private static DashboardStartupConfiguration LoadDashboardConfig(IConfiguration var browserUris = configuration.GetUris(DashboardUrlVariableName, new(DashboardUrlDefaultValue)); if (browserUris.Length == 0) { - throw new InvalidOperationException($"One URL for Aspire dashboard OTLP endpoint with {DashboardUrlDefaultValue} is required."); + throw new InvalidOperationException($"At least one URL for Aspire dashboard browser endpoint with {DashboardOtlpUrlDefaultValue} is required."); } + var frontendAuthMode = configuration.GetEnum(FrontendAuthModeKey); + var otlpUris = configuration.GetUris(DashboardOtlpUrlVariableName, new(DashboardOtlpUrlDefaultValue)); if (otlpUris.Length != 1) { - throw new InvalidOperationException($"At least one URL for Aspire dashboard browser endpoint with {DashboardOtlpUrlDefaultValue} is required."); + throw new InvalidOperationException($"One URL for Aspire dashboard OTLP endpoint with {DashboardUrlDefaultValue} is required, not {otlpUris.Length}."); } - var allowAnonymous = configuration.GetBool(DashboardInsecureAllowAnonymousVariableName) ?? false; - var otlpAuthMode = configuration.GetEnum(DashboardOtlpAuthModeVariableName); + var otlpAuthMode = configuration.GetEnum(OtlpAuthModeKey); + string? otlpApiKey = null; switch (otlpAuthMode) { - case null: - if (!allowAnonymous) - { - throw new InvalidOperationException($"Configuration of OTLP endpoint authentication is required. Either specify {DashboardInsecureAllowAnonymousVariableName} with a value of true, or specify {DashboardOtlpAuthModeVariableName}. Possible values: {string.Join(", ", typeof(OtlpAuthMode).GetEnumNames())}"); - } - otlpAuthMode = OtlpAuthMode.None; - break; - case OtlpAuthMode.None: - break; case OtlpAuthMode.ApiKey: - otlpApiKey = configuration[DashboardOtlpApiKeyVariableName]; + otlpApiKey = configuration[OtlpApiKeyKey]; if (string.IsNullOrEmpty(otlpApiKey)) { - throw new InvalidOperationException($"{DashboardOtlpAuthModeVariableName} value of {nameof(OtlpAuthMode.ApiKey)} requires an API key from {DashboardOtlpApiKeyVariableName}."); + throw new InvalidOperationException($"{OtlpAuthModeKey} value of {nameof(OtlpAuthMode.ApiKey)} requires an API key from {OtlpApiKeyKey}."); } break; case OtlpAuthMode.ClientCertificate: if (!IsHttps(otlpUris[0])) { - throw new InvalidOperationException($"{DashboardOtlpAuthModeVariableName} value of {nameof(OtlpAuthMode.ClientCertificate)} requires a HTTPS OTLP endpoint."); + throw new InvalidOperationException($"{OtlpAuthModeKey} value of {nameof(OtlpAuthMode.ClientCertificate)} requires a HTTPS OTLP endpoint."); } break; default: - throw new InvalidOperationException($"Unexpected auth mode value: {otlpAuthMode}"); + break; } return new DashboardStartupConfiguration { BrowserUris = browserUris, + FrontendAuthMode = frontendAuthMode, OtlpUri = otlpUris[0], - OtlpAuthMode = otlpAuthMode.Value, + OtlpAuthMode = otlpAuthMode, OtlpApiKey = otlpApiKey }; } + + private enum FrontendAuthMode + { + Unsecured, + OpenIdConnect + } + + public static class FrontendAuthenticationDefaults + { + public const string AuthenticationScheme = "Frontend"; + } + + private sealed class DashboardStartupConfiguration + { + public required Uri[] BrowserUris { get; init; } + public required Uri OtlpUri { get; init; } + public required OtlpAuthMode OtlpAuthMode { get; init; } + public required FrontendAuthMode FrontendAuthMode { get; init; } + public required string? OtlpApiKey { get; init; } + } } public record EndpointInfo(IPEndPoint EndPoint, bool isHttps); + +public static class FrontendAuthorizationDefaults +{ + public const string PolicyName = "Frontend"; +} diff --git a/src/Aspire.Dashboard/Model/DashboardClient.cs b/src/Aspire.Dashboard/Model/DashboardClient.cs index f8ec782676..80d94221f9 100644 --- a/src/Aspire.Dashboard/Model/DashboardClient.cs +++ b/src/Aspire.Dashboard/Model/DashboardClient.cs @@ -3,7 +3,9 @@ using System.Collections.Immutable; using System.Diagnostics; +using System.Net.Security; using System.Runtime.CompilerServices; +using System.Security.Cryptography.X509Certificates; using System.Threading.Channels; using Aspire.Dashboard.Utils; using Aspire.V1; @@ -98,6 +100,28 @@ GrpcChannel CreateChannel() KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests }; + var authMode = configuration.GetEnum("ResourceServiceClient:AuthMode"); + + if (authMode == ResourceClientAuthMode.Certificate) + { + // Auth hasn't been suppressed, so configure it. + var sourceType = configuration.GetEnum("ResourceServiceClient:ClientCertificate:Source"); + + var certificates = sourceType switch + { + DashboardClientCertificateSource.File => GetFileCertificate(), + DashboardClientCertificateSource.KeyStore => GetKeyStoreCertificate(), + _ => throw new InvalidOperationException("Unable to load ResourceServiceClient client certificate.") + }; + + httpHandler.SslOptions = new SslClientAuthenticationOptions + { + ClientCertificates = certificates + }; + + configuration.Bind("ResourceServiceClient:Ssl", httpHandler.SslOptions); + } + // https://learn.microsoft.com/aspnet/core/grpc/retries var methodConfig = new MethodConfig @@ -124,6 +148,44 @@ GrpcChannel CreateChannel() LoggerFactory = _loggerFactory, ThrowOperationCanceledOnCancellation = true }); + + X509CertificateCollection GetFileCertificate() + { + var filePath = configuration["ResourceServiceClient:ClientCertificate:FilePath"]; + var password = configuration["ResourceServiceClient:ClientCertificate:Password"]; + + if (filePath is null or []) + { + throw new InvalidOperationException("ResourceServiceClient:ClientCertificate:Source is \"File\", but no Certificate:FilePath is configured."); + } + + return [new X509Certificate2(filePath, password)]; + } + + X509CertificateCollection GetKeyStoreCertificate() + { + var subject = configuration["ResourceServiceClient:ClientCertificate:Subject"]; + + if (subject is null or []) + { + throw new InvalidOperationException("ResourceServiceClient:ClientCertificate:Source is \"KeyStore\", but no Certificate:FilePath is configured."); + } + + using var store = new X509Store(storeName: StoreName.My, storeLocation: StoreLocation.CurrentUser); + + configuration.Bind("ResourceServiceClient:ClientCertificate:KeyStore"); + + store.Open(OpenFlags.ReadOnly); + + var certificates = store.Certificates.Find(X509FindType.FindBySubjectName, findValue: subject, validOnly: true); + + if (certificates is []) + { + throw new InvalidOperationException($"Unable to load client certificate with subject \"{subject}\" from key store."); + } + + return certificates; + } } } @@ -484,4 +546,16 @@ internal void SetInitialDataReceived(IList? initialData = null) _initialDataReceivedTcs.TrySetResult(); } + + private enum DashboardClientCertificateSource + { + File, + KeyStore + } + + private enum ResourceClientAuthMode + { + Unsecured, + Certificate + } } diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index ccfb79db36..798dc0c63c 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -1,30 +1,55 @@ # .NET Aspire Dashboard -Configuration is obtained through `IConfiguration`, so it can be provided in several ways, such as via environment variables. +## Configuration -## Endpoints +Configuration is obtained through `IConfiguration`, so it can be provided in several ways, such as via environment variables. The dashboard has two kinds of endpoints: a browser endpoint for viewing the dashboard UI and an OTLP endpoint that hosts an OTLP service and receives telemetry. -- `ASPNETCORE_URLS` specifies one or more HTTP endpoints through which the dashboard web application is served. Defaults to http://localhost:18888. +- `ASPNETCORE_URLS` specifies one or more HTTP endpoints through which the dashboard frontend is served. Defaults to http://localhost:18888. - `DOTNET_DASHBOARD_OTLP_ENDPOINT_URL` specifies the OTLP endpoint. Defaults to http://localhost:18889. Endpoints are given names in Kestrel (`Browser` and `Otlp`) and can be configured using [Kestrel endpoint configuration](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/endpoints#configure-endpoints-in-appsettingsjson). For example, the default certificate used by HTTPS endpoints can be configured using the `ASPNETCORE_Kestrel__Certificates__Default__Path` and `ASPNETCORE_Kestrel__Certificates__Default__Password` environment variables. Alternatively, the certificate can be configured for individual endpoints, such as `ASPNETCORE_Kestrel__Endpoints__Browser__Path`, etc. -### OTLP endpoint authentication +### Frontend -The OTLP endpoint can be secured with [client certificate](https://learn.microsoft.com/aspnet/core/security/authentication/certauth) or API key authentication. +The dashboard's web application frontend supports OpenID Connect (OIDC). Set `Frontend:AuthMode` to `OpenIdConnect`, then add the following configuration: -- `DOTNET_DASHBOARD_OTLP_AUTH_MODE` specifies the authentication mode on the OTLP endpoint. Possible values are `Certificate`, `ApiKey`, `None`. This configuration is required. -- `DOTNET_DASHBOARD_OTLP_API_KEY` specifies the API key for the OTLP endpoint when API key authentication is enabled. This configuration is required for API key authentication. +- `Authentication:Schemes:OpenIdConnect:Authority` — URL to the identity provider (IdP) +- `Authentication:Schemes:OpenIdConnect:ClientId` — Identity of the relying party (RP) +- `Authentication:Schemes:OpenIdConnect:ClientSecret`— A secret that only the real RP would know +- Other properties of [`OpenIdConnectOptions`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.openidconnectoptions) specified in configuration container `Authentication:Schemes:OpenIdConnect:*` -## Resources +It may also be run unsecured. Set `Frontend:AuthMode` to `Unsecured`. This completely disables all security for the dashboard frontend. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings. + +### Resources - `DOTNET_RESOURCE_SERVICE_ENDPOINT_URL` specifies the gRPC endpoint to which the dashboard connects for its data. There's no default. If this variable is unspecified, the dashboard shows OTEL data but no resource list or console logs. -## Telemetry Limits +The resource service client supports certificates. Set `ResourceServiceClient:AuthMode` to `Certificate`, then add the following configuration: + +- `ResourceServiceClient:ClientCertificate:Source` (required) one of: + - `File` to load the cert from a file path, configured with: + - `ResourceServiceClient:ClientCertificate:FilePath` (required, string) + - `ResourceServiceClient:ClientCertificate:Password` (optional, string) + - `KeyStore` to load the cert from a key store, configured with: + - `ResourceServiceClient:ClientCertificate:Subject` (required, string) + - `ResourceServiceClient:ClientCertificate:KeyStore:Name` (optional, [`StoreName`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storename), defaults to `My`) + - `ResourceServiceClient:ClientCertificate:KeyStore:Location` (optional, [`StoreLocation`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storelocation), defaults to `CurrentUser`) +- `ResourceServiceClient:Ssl` (optional, [`SslClientAuthenticationOptions`](https://learn.microsoft.com/dotnet/api/system.net.security.sslclientauthenticationoptions)) + +To opt-out of authentication, set `ResourceServiceClient:AuthMode` to `Unsecured`. This completely disables all security for the resource service client. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings. + +### OTLP + +The OTLP endpoint can be secured with [client certificate](https://learn.microsoft.com/aspnet/core/security/authentication/certauth) or API key authentication. + +- `Otlp:AuthMode` specifies the authentication mode on the OTLP endpoint. Possible values are `Certificate`, `ApiKey`, `Unsecured`. This configuration is required. +- `Otlp:ApiKey` specifies the API key for the OTLP endpoint when API key authentication is enabled. This configuration is required for API key authentication. + +#### Telemetry Limits Telemetry is stored in-memory. To avoid excessive memory usage, the dashboard has limits on the count and size of stored telemetry. When a count limit is reached, new telemetry is added, and the oldest telemetry is removed. When a size limit is reached, data is truncated to the limit. @@ -35,6 +60,6 @@ Telemetry is stored in-memory. To avoid excessive memory usage, the dashboard ha - `DOTNET_DASHBOARD_OTEL_ATTRIBUTE_LENGTH_LIMIT` specifies the maximum length of attributes. Defaults to unlimited. - `DOTNET_DASHBOARD_OTEL_SPAN_EVENT_COUNT_LIMIT` specifies the maximum number of events on span attributes. Defaults to unlimited. -## Other +### Other - `DOTNET_DASHBOARD_APPLICATION_NAME` specifies the application name to be displayed in the UI. This applies only when no resource service URL is specified. When a resource service exists, the service specifies the application name. diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index 06f38da523..8410b40d20 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -711,11 +711,12 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) context.EnvironmentVariables["ASPNETCORE_URLS"] = appHostApplicationUrl; context.EnvironmentVariables["DOTNET_RESOURCE_SERVICE_ENDPOINT_URL"] = grpcEndpointUrl; context.EnvironmentVariables["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = otlpEndpointUrl; + context.EnvironmentVariables["ResourceServiceClient__AuthMode"] = "Unsecured"; // No auth in local dev experience if (configuration["AppHost:OtlpApiKey"] is { } otlpApiKey) { - context.EnvironmentVariables["DOTNET_DASHBOARD_OTLP_AUTH_MODE"] = "ApiKey"; // Matches value in OtlpAuthMode enum. - context.EnvironmentVariables["DOTNET_DASHBOARD_OTLP_API_KEY"] = otlpApiKey; + context.EnvironmentVariables["Otlp__AuthMode"] = "ApiKey"; // Matches value in OtlpAuthMode enum. + context.EnvironmentVariables["Otlp__ApiKey"] = otlpApiKey; } else { @@ -775,6 +776,11 @@ private async Task StartDashboardAsDcpExecutableAsync(CancellationToken cancella Value = grpcEndpointUrl }, new() + { + Name = "ResourceServiceClient__AuthMode", + Value = "Unsecured" // No auth in local dev experience + }, + new() { Name = "ASPNETCORE_URLS", Value = dashboardUrls @@ -796,12 +802,12 @@ private async Task StartDashboardAsDcpExecutableAsync(CancellationToken cancella dashboardExecutableSpec.Env.AddRange([ new() { - Name = "DOTNET_DASHBOARD_OTLP_API_KEY", + Name = "Otlp__ApiKey", Value = otlpApiKey }, new() { - Name = "DOTNET_DASHBOARD_OTLP_AUTH_MODE", + Name = "Otlp__AuthMode", Value = "ApiKey" // Matches value in OtlpAuthMode enum. } ]); diff --git a/src/Shared/IConfigurationExtensions.cs b/src/Shared/IConfigurationExtensions.cs index cad57a3b88..f4eb22f68f 100644 --- a/src/Shared/IConfigurationExtensions.cs +++ b/src/Shared/IConfigurationExtensions.cs @@ -119,35 +119,55 @@ public static bool GetBool(this IConfiguration configuration, string key, bool d } } - public static TEnum? GetEnum(this IConfiguration configuration, string key, TEnum? defaultValue = null) where TEnum : struct, Enum + /// + /// Gets the named configuration value as a member of an enum, or if the value was null or empty. + /// + /// + /// Parsing is case-insensitive. + /// + /// The this method extends. + /// The configuration key. + /// A default value, for when the configuration value is unable to be parsed. + /// The configuration value is not a valid member of the enum. + /// The parsed enum member, or the configuration value was null or empty. + [return: NotNullIfNotNull(nameof(defaultValue))] + public static T? GetEnum(this IConfiguration configuration, string key, T? defaultValue = default) + where T : struct { - try - { - var value = configuration[key]; + var value = configuration[key]; - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue switch - { - not null => defaultValue, - null => null - }; - } - else - { - if (Enum.TryParse(value, ignoreCase: true, out var e)) - { - return e; - } - else - { - throw new InvalidOperationException($"Unknown {nameof(TEnum)} value: {value}"); - } - } + if (value is null or []) + { + return defaultValue; } - catch (Exception ex) + else if (Enum.TryParse(value, ignoreCase: true, out var e)) { - throw new InvalidOperationException($"Error parsing {nameof(TEnum)} from configuration value '{key}'.", ex); + return e; } + + throw new InvalidOperationException($"Unknown {typeof(T).Name} value \"{value}\". Valid values are {string.Join(", ", Enum.GetNames(typeof(T)))}."); + } + + /// + /// Gets the specified required configuration value as a member of an enum. + /// + /// + /// Parsing is case-insensitive. + /// + /// The this method extends. + /// The configuration key. + /// The configuration value is empty or not a valid member of the enum. + /// The parsed enum member. + public static T GetEnum(this IConfiguration configuration, string key) + where T : struct + { + var value = configuration.GetEnum(key, defaultValue: null); + + if (value is null) + { + throw new InvalidOperationException($"Missing required configuration for {key}. Valid values are {string.Join(", ", Enum.GetNames(typeof(T)))}."); + } + + return value.Value; } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs index c3953579f1..6380b88177 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Security.Cryptography.X509Certificates; -using Aspire.Dashboard.Authentication; using Grpc.Core; using Grpc.Net.Client; using Grpc.Net.Client.Configuration; @@ -29,8 +28,10 @@ public static DashboardWebApplication CreateDashboardWebApplication( { [DashboardWebApplication.DashboardUrlVariableName] = "http://127.0.0.1:0", [DashboardWebApplication.DashboardOtlpUrlVariableName] = "http://127.0.0.1:0", - [DashboardWebApplication.DashboardOtlpAuthModeVariableName] = nameof(OtlpAuthMode.None) + [DashboardWebApplication.OtlpAuthModeKey] = "Unsecured", + [DashboardWebApplication.FrontendAuthModeKey] = "Unsecured" }; + additionalConfiguration?.Invoke(initialData); var config = new ConfigurationManager() diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs index f61f32112c..5d904491b3 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs @@ -46,8 +46,8 @@ public async void CallService_OtlpEndPoint_RequiredApiKeyMissing_Failure() var apiKey = "TestKey123!"; await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => { - config[DashboardWebApplication.DashboardOtlpAuthModeVariableName] = OtlpAuthMode.ApiKey.ToString(); - config[DashboardWebApplication.DashboardOtlpApiKeyVariableName] = apiKey; + config[DashboardWebApplication.OtlpAuthModeKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardWebApplication.OtlpApiKeyKey] = apiKey; }); await app.StartAsync(); @@ -68,8 +68,8 @@ public async void CallService_OtlpEndPoint_RequiredApiKeyWrong_Failure() var apiKey = "TestKey123!"; await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => { - config[DashboardWebApplication.DashboardOtlpAuthModeVariableName] = OtlpAuthMode.ApiKey.ToString(); - config[DashboardWebApplication.DashboardOtlpApiKeyVariableName] = apiKey; + config[DashboardWebApplication.OtlpAuthModeKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardWebApplication.OtlpApiKeyKey] = apiKey; }); await app.StartAsync(); @@ -95,8 +95,8 @@ public async void CallService_OtlpEndPoint_RequiredApiKeySent_Success() var apiKey = "TestKey123!"; await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => { - config[DashboardWebApplication.DashboardOtlpAuthModeVariableName] = OtlpAuthMode.ApiKey.ToString(); - config[DashboardWebApplication.DashboardOtlpApiKeyVariableName] = apiKey; + config[DashboardWebApplication.OtlpAuthModeKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardWebApplication.OtlpApiKeyKey] = apiKey; }); await app.StartAsync(); @@ -155,7 +155,7 @@ public async void CallService_OtlpEndpoint_RequiredClientCertificateMissing_Fail // Change dashboard to HTTPS so the caller can negotiate a HTTP/2 connection. config[DashboardWebApplication.DashboardOtlpUrlVariableName] = "https://127.0.0.1:0"; - config[DashboardWebApplication.DashboardOtlpAuthModeVariableName] = OtlpAuthMode.ClientCertificate.ToString(); + config[DashboardWebApplication.OtlpAuthModeKey] = OtlpAuthMode.ClientCertificate.ToString(); }); await app.StartAsync(); @@ -189,7 +189,7 @@ public async void CallService_OtlpEndpoint_RequiredClientCertificateValid_Succes // Change dashboard to HTTPS so the caller can negotiate a HTTP/2 connection. config[DashboardWebApplication.DashboardOtlpUrlVariableName] = "https://127.0.0.1:0"; - config[DashboardWebApplication.DashboardOtlpAuthModeVariableName] = OtlpAuthMode.ClientCertificate.ToString(); + config[DashboardWebApplication.OtlpAuthModeKey] = OtlpAuthMode.ClientCertificate.ToString(); config["CertificateAuthentication:AllowedCertificateTypes"] = "SelfSigned"; config["CertificateAuthentication:ValidateValidityPeriod"] = "false"; diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index c923bd9e30..7112dc026e 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.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 System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -11,20 +10,13 @@ namespace Aspire.Dashboard.Tests.Integration; -public class StartupTests +public class StartupTests(ITestOutputHelper testOutputHelper) { - private readonly ITestOutputHelper _testOutputHelper; - - public StartupTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } - [Fact] public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned() { // Arrange - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper); // Act await app.StartAsync(); @@ -43,7 +35,7 @@ public async Task Configuration_BrowserAndOtlpEndpointSame_Https_EndPointPortsAs { await ServerRetryHelper.BindPortsWithRetry(async port => { - app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, + app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, additionalConfiguration: initialData => { initialData[DashboardWebApplication.DashboardUrlVariableName] = $"https://127.0.0.1:{port}"; @@ -55,7 +47,7 @@ await ServerRetryHelper.BindPortsWithRetry(async port => }, NullLogger.Instance); // Assert - Debug.Assert(app != null); + Assert.NotNull(app); Assert.Equal(app.BrowserEndPointAccessor().EndPoint.Port, app.OtlpServiceEndPointAccessor().EndPoint.Port); // Check browser access @@ -74,7 +66,7 @@ await ServerRetryHelper.BindPortsWithRetry(async port => response.EnsureSuccessStatusCode(); // Check OTLP service - using var channel = IntegrationTestHelpers.CreateGrpcChannel($"https://{app.BrowserEndPointAccessor().EndPoint}", _testOutputHelper); + using var channel = IntegrationTestHelpers.CreateGrpcChannel($"https://{app.BrowserEndPointAccessor().EndPoint}", testOutputHelper); var client = new LogsService.LogsServiceClient(channel); var serviceResponse = await client.ExportAsync(new ExportLogsServiceRequest()); Assert.Equal(0, serviceResponse.PartialSuccess.RejectedLogRecords); @@ -98,7 +90,7 @@ public async Task Configuration_BrowserAndOtlpEndpointSame_Https_Error() { await ServerRetryHelper.BindPortsWithRetry(async port => { - app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, + app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, additionalConfiguration: initialData => { initialData[DashboardWebApplication.DashboardUrlVariableName] = $"http://127.0.0.1:{port}"; @@ -143,26 +135,25 @@ public async Task Configuration_NoOtlpAuthMode_Error() // Arrange & Act var ex = await Assert.ThrowsAsync(async () => { - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, additionalConfiguration: data => { - data.Remove(DashboardWebApplication.DashboardOtlpAuthModeVariableName); + data.Remove(DashboardWebApplication.OtlpAuthModeKey); }); }); // Assert - Assert.Equal("Configuration of OTLP endpoint authentication is required. Either specify DOTNET_DASHBOARD_INSECURE_ALLOW_ANONYMOUS with a value of true, or specify DOTNET_DASHBOARD_OTLP_AUTH_MODE. Possible values: None, ApiKey, ClientCertificate", ex.Message); + Assert.Equal("Missing required configuration for Otlp:AuthMode. Valid values are Unsecured, ApiKey, ClientCertificate.", ex.Message); } [Fact] public async Task Configuration_AllowAnonymous_NoError() { // Arrange - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, additionalConfiguration: data => { - data.Remove(DashboardWebApplication.DashboardOtlpAuthModeVariableName); - data[DashboardWebApplication.DashboardInsecureAllowAnonymousVariableName] = bool.TrueString; + data[DashboardWebApplication.OtlpAuthModeKey] = "Unsecured"; }); // Act @@ -178,7 +169,7 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() { // Arrange var testSink = new TestSink(); - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, testSink: testSink); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, testSink: testSink); // Act await app.StartAsync(); @@ -216,7 +207,7 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() public async void EndPointAccessors_AppStarted_BrowserGet_Success() { // Arrange - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper); // Act await app.StartAsync(); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 236270182f..419cf920d1 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -18,7 +18,10 @@ public sealed class DashboardClientTests public DashboardClientTests() { var configuration = new ConfigurationManager(); - configuration.AddInMemoryCollection(new Dictionary() { { "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", "http://localhost:12345" } }); + configuration.AddInMemoryCollection([ + new("ResourceServiceClient:AuthMode", "Unsecured"), + new("DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", "http://localhost:12345") + ]); _configuration = configuration; }