Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth for dashboard web app and resource service client #3033

Merged
merged 16 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<PackageVersion Include="Azure.Provisioning" Version="1.0.0-alpha.20240315.2" />
<!-- ASP.NET Core dependencies -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="$(MicrosoftAspNetCoreAuthenticationCertificatePackageVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="$(MicrosoftAspNetCoreAuthenticationOpenIdConnectPackageVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="$(MicrosoftAspNetCoreOpenApiPackageVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="$(MicrosoftAspNetCoreOutputCachingStackExchangeRedisPackageVersion)" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="$(MicrosoftExtensionsCachingStackExchangeRedisPackageVersion)" />
Expand Down
1 change: 1 addition & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<MicrosoftExtensionsOptionsPackageVersion>8.0.2</MicrosoftExtensionsOptionsPackageVersion>
<MicrosoftExtensionsPrimitivesPackageVersion>8.0.0</MicrosoftExtensionsPrimitivesPackageVersion>
<MicrosoftAspNetCoreAuthenticationCertificatePackageVersion>8.0.3</MicrosoftAspNetCoreAuthenticationCertificatePackageVersion>
<MicrosoftAspNetCoreAuthenticationOpenIdConnectPackageVersion>8.0.3</MicrosoftAspNetCoreAuthenticationOpenIdConnectPackageVersion>
<MicrosoftAspNetCoreOpenApiPackageVersion>8.0.3</MicrosoftAspNetCoreOpenApiPackageVersion>
<MicrosoftAspNetCoreOutputCachingStackExchangeRedisPackageVersion>8.0.2</MicrosoftAspNetCoreOutputCachingStackExchangeRedisPackageVersion>
<MicrosoftExtensionsCachingStackExchangeRedisPackageVersion>8.0.2</MicrosoftExtensionsCachingStackExchangeRedisPackageVersion>
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Aspire.Dashboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageReference Include="Grpc.AspNetCore" />
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
</ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Dashboard/Components/_Imports.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
Expand All @@ -10,3 +11,6 @@
@using Aspire.Dashboard.Components
@using Aspire.Dashboard.Components.Controls
@using Microsoft.Extensions.Localization

@* Require authorization for all pages *@
@attribute [Authorize]
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
46 changes: 42 additions & 4 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
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.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;

Expand Down Expand Up @@ -50,10 +53,8 @@ public Func<EndpointInfo> OtlpServiceEndPointAccessor
public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder = null)
{
var builder = WebApplication.CreateBuilder();
if (configureBuilder != null)
{
configureBuilder(builder);
}

configureBuilder?.Invoke(builder);

#if !DEBUG
builder.Logging.AddFilter("Default", LogLevel.Information);
Expand All @@ -62,6 +63,13 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
builder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error);
#endif

var authMode = builder.Configuration.GetEnum<DashboardWebAppAuthMode>("DashboardWebApp:AuthMode");

if (authMode == null)
{
throw new InvalidOperationException("Unable to read DashboardWebApp:AuthMode. Supported values are Unsecured and OpenIdConnect.");
}
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

var dashboardConfig = LoadDashboardConfig(builder.Configuration);

ConfigureKestrelEndpoints(builder, dashboardConfig);
Expand Down Expand Up @@ -103,6 +111,30 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =

builder.Services.AddLocalization();

if (authMode == DashboardWebAppAuthMode.OpenIdConnect)
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
{
// Configure OpenID Connect (OIDC)
var authentication = builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = 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;

// "openid" and "profile" are added by default, but need to be re-added in case the user added more
// scopes via Authentication:Schemes:OpenIdConnect:Scope configuration.
options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
});
}

_app = builder.Build();

var logger = _app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DashboardWebApplication>();
Expand Down Expand Up @@ -408,6 +440,12 @@ private static DashboardStartupConfiguration LoadDashboardConfig(IConfiguration
OtlpApiKey = otlpApiKey
};
}

private enum DashboardWebAppAuthMode
{
Unsecured,
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
OpenIdConnect
}
}

public record EndpointInfo(IPEndPoint EndPoint, bool isHttps);
79 changes: 79 additions & 0 deletions src/Aspire.Dashboard/Model/DashboardClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,6 +100,33 @@ GrpcChannel CreateChannel()
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests
};

var authMode = configuration.GetEnum<ResourceClientAuthMode>("ResourceServiceClient:AuthMode");
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

if (authMode == null)
{
throw new InvalidOperationException("Unable to read ResourceServiceClient:AuthMode. Supported values are Unsecured and Certificate.");
}

if (authMode == ResourceClientAuthMode.Certificate)
{
// Auth hasn't been suppressed, so configure it.
var sourceType = configuration.GetEnum<DashboardClientCertificateSource>("ResourceServiceClient:ClientCertificate:Source");
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

var certificates = sourceType switch
{
DashboardClientCertificateSource.File => GetFileCertificate(),
DashboardClientCertificateSource.KeyStore => GetKeyStoreCertificate(),
_ => throw new InvalidOperationException("Unable to load ResourceServiceClient client certificate.")
};

httpHandler.SslOptions = new SslClientAuthenticationOptions
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may wish to have a RemoteCertificateValidationCallback here that validates the server certificate's thumbprint (and corresponding config to pass that cert in).

{
ClientCertificates = certificates
};

configuration.Bind("ResourceServiceClient:Ssl", httpHandler.SslOptions);
}

// https://learn.microsoft.com/aspnet/core/grpc/retries

var methodConfig = new MethodConfig
Expand All @@ -124,6 +153,44 @@ GrpcChannel CreateChannel()
LoggerFactory = _loggerFactory,
ThrowOperationCanceledOnCancellation = true
});

X509CertificateCollection GetFileCertificate()
{
var filePath = configuration["ResourceServiceClient:ClientCertificate:FilePath"];
var password = configuration["ResourceServiceClient:ClientCertificate:Password"];
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

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");
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

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;
}
}
}

Expand Down Expand Up @@ -484,4 +551,16 @@ internal void SetInitialDataReceived(IList<Resource>? initialData = null)

_initialDataReceivedTcs.TrySetResult();
}

private enum DashboardClientCertificateSource
{
File,
KeyStore
}

private enum ResourceClientAuthMode
{
Unsecured,
Certificate
}
}
29 changes: 27 additions & 2 deletions src/Aspire.Dashboard/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# .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.

Expand All @@ -24,6 +24,31 @@ The OTLP endpoint can be secured with [client certificate](https://learn.microso

- `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.

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.
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

## Dashboard web app auth
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

The dashboard's web application supports OpenID Connect. Set `DashboardWebApp:AuthMode` to `OpenIdConnect`, then add the following configuration:

- `Authentication:Schemes:OpenIdConnect:Authority` &mdash; URL to the identity provider (IdP)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now but I'd like to see if we have move this auth configuration to Dashboard:Frontend:OpenIdConnect. I think we can do that by binding another config section.

Can happen in a future PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication:Schemes:<schemename> seems to be the de facto way of doing this, no? If we move to our own config container, I'd be concerned we miss out on other built-in configuration controls.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schemes include functionality to bind their options to a convention-based location in IConfiguration. It's 1 line of code to add support for binding to an extra location.

I don't know yet whether we want to do this. If there are sacrifices, then it might be left at the default location.

- `Authentication:Schemes:OpenIdConnect:ClientId` &mdash; Identity of the relying party (RP)
- `Authentication:Schemes:OpenIdConnect:ClientSecret`&mdash; 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:*`

It may also be run unsecured. Set `DashboardWebApp:AuthMode` to `Unsecured`. This completely disables all security for the dashboard web app. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings.

## 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.
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Hosting/Dcp/ApplicationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ 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)
{
Expand Down Expand Up @@ -793,6 +794,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
Expand Down
28 changes: 28 additions & 0 deletions src/Shared/IConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,32 @@ public static bool GetBool(this IConfiguration configuration, string key, bool d
throw new InvalidOperationException($"Error parsing URIs from configuration value '{key}'.", ex);
}
}

/// <summary>
/// Gets the named configuration value as a member of an enum, or <paramref name="defaultValue"/> if parsing failed.
/// </summary>
/// <remarks>
/// Parsing is case-insensitive.
/// </remarks>
/// <param name="configuration">The <see cref="IConfiguration"/> this method extends.</param>
/// <param name="key">The configuration key.</param>
/// <param name="defaultValue">A default value, for when the configuration value is unable to be parsed.</param>
/// <returns>The parsed enum member, or <paramref name="defaultValue"/> if parsing failed.</returns>
[return: NotNullIfNotNull(nameof(defaultValue))]
public static T? GetEnum<T>(this IConfiguration configuration, string key, T? defaultValue = default)
where T : struct
{
var value = configuration[key];

if (value is null or [])
{
return defaultValue;
}
else if (Enum.TryParse<T>(value, ignoreCase: true, out var e))
{
return e;
}

return default;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ public static DashboardWebApplication CreateDashboardWebApplication(
var initialData = new Dictionary<string, string?>
{
[DashboardWebApplication.DashboardUrlVariableName] = "http://127.0.0.1:0",
[DashboardWebApplication.DashboardOtlpUrlVariableName] = "http://127.0.0.1:0"
[DashboardWebApplication.DashboardOtlpUrlVariableName] = "http://127.0.0.1:0",
["ResourceServiceClient:AuthMode"] = "Unsecured",
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
["DashboardWebApp:AuthMode"] = "Unsecured"
};
additionalConfiguration?.Invoke(initialData);

Expand Down
6 changes: 5 additions & 1 deletion tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ public sealed class DashboardClientTests
public DashboardClientTests()
{
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>() { { "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", "http://localhost:12345" } });
configuration.AddInMemoryCollection([
new("DashboardWebApp:AuthMode", "Unsecured"),
new("ResourceServiceClient:AuthMode", "Unsecured"),
new("DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", "http://localhost:12345")
]);
_configuration = configuration;
}

Expand Down
Loading