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

Resource service supports API key authentication #3400

Merged
merged 7 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all 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 src/Aspire.Dashboard/Aspire.Dashboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
<Link>Protos\resource_service.proto</Link>
</Protobuf>
<Compile Include="$(SharedDir)ChannelExtensions.cs" Link="Extensions\ChannelExtensions.cs" />
<Compile Include="$(SharedDir)CompareHelpers.cs" Link="Utils\CompareHelpers.cs" />
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="Utils\IConfigurationExtensions.cs" />
<Compile Include="$(SharedDir)KnownResourceNames.cs" Link="Utils\KnownResourceNames.cs" />
<Compile Include="$(SharedDir)KnownFormats.cs" Link="Utils\KnownFormats.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using System.Text.Encodings.Web;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ public sealed class DashboardOptions
public sealed class ResourceServiceClientOptions
{
private Uri? _parsedUrl;
private byte[]? _apiKeyBytes;

public string? Url { get; set; }
public ResourceClientAuthMode? AuthMode { get; set; }
public ResourceServiceClientCertificateOptions ClientCertificates { get; set; } = new ResourceServiceClientCertificateOptions();
public string? ApiKey { get; set; }

public Uri? GetUri() => _parsedUrl;

internal byte[] GetApiKeyBytes() => _apiKeyBytes ?? throw new InvalidOperationException($"{nameof(ApiKey)} is not available.");

internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
{
if (!string.IsNullOrEmpty(Url))
Expand All @@ -40,6 +44,8 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
}
}

_apiKeyBytes = ApiKey != null ? Encoding.UTF8.GetBytes(ApiKey) : null;

errorMessage = null;
return true;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Dashboard/Configuration/ResourceClientAuthMode.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// 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;

public enum ResourceClientAuthMode
{
Unsecured,
ApiKey,
Certificate
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,20 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options)
errorMessages.Add(resourceServiceClientParseErrorMessage);
}

// Only validate resource service configuration if we have a URI to connect to.
// If we do not, then the dashboard will run without resources, but still show OTEL data.
if (options.ResourceServiceClient.GetUri() != null)
{
switch (options.ResourceServiceClient.AuthMode)
{
case ResourceClientAuthMode.Unsecured:
break;
case ResourceClientAuthMode.ApiKey:
if (string.IsNullOrWhiteSpace(options.ResourceServiceClient.ApiKey))
{
errorMessages.Add($"{DashboardConfigNames.ResourceServiceClientAuthModeName.ConfigKey} is \"{nameof(ResourceClientAuthMode.ApiKey)}\", but no {DashboardConfigNames.ResourceServiceClientApiKeyName.ConfigKey} is configured.");
}
break;
case ResourceClientAuthMode.Certificate:

switch (options.ResourceServiceClient.ClientCertificates.Source)
Expand Down
16 changes: 13 additions & 3 deletions src/Aspire.Dashboard/Model/DashboardClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ namespace Aspire.Dashboard.Model;
/// </remarks>
internal sealed class DashboardClient : IDashboardClient
{
private const string ApiKeyHeaderName = "x-resource-service-api-key";
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved

private readonly Dictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
private readonly CancellationTokenSource _cts = new();
private readonly CancellationToken _clientCancellationToken;
Expand All @@ -59,6 +61,7 @@ internal sealed class DashboardClient : IDashboardClient

private readonly GrpcChannel? _channel;
private readonly DashboardService.DashboardServiceClient? _client;
private readonly Metadata _headers = [];

private Task? _connection;

Expand Down Expand Up @@ -89,6 +92,12 @@ public DashboardClient(ILoggerFactory loggerFactory, IConfiguration configuratio
// We will dispose it when we are disposed.
_channel = CreateChannel();

if (_dashboardOptions.ResourceServiceClient.AuthMode is ResourceClientAuthMode.ApiKey)
{
// We're using an API key for auth, so set it in the headers we pass on each call.
_headers.Add(ApiKeyHeaderName, _dashboardOptions.ResourceServiceClient.ApiKey!);
}

_client = new DashboardService.DashboardServiceClient(_channel);

GrpcChannel CreateChannel()
Expand Down Expand Up @@ -226,7 +235,7 @@ async Task ConnectAsync()
{
try
{
var response = await _client!.GetApplicationInformationAsync(new(), cancellationToken: cancellationToken);
var response = await _client!.GetApplicationInformationAsync(new(), headers: _headers, cancellationToken: cancellationToken);

_applicationName = response.ApplicationName;

Expand Down Expand Up @@ -279,7 +288,7 @@ static TimeSpan ExponentialBackOff(int errorCount, double maxSeconds)

async Task WatchResourcesAsync()
{
var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = errorCount != 0 }, cancellationToken: cancellationToken);
var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = errorCount != 0 }, headers: _headers, cancellationToken: cancellationToken);

await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
{
Expand Down Expand Up @@ -437,6 +446,7 @@ async IAsyncEnumerable<IReadOnlyList<ResourceViewModelChange>> StreamUpdatesAsyn

var call = _client!.WatchResourceConsoleLogs(
new WatchResourceConsoleLogsRequest() { ResourceName = resourceName },
headers: _headers,
cancellationToken: combinedTokens.Token);

// Write incoming logs to a channel, and then read from that channel to yield the logs.
Expand Down Expand Up @@ -498,7 +508,7 @@ public async Task<ResourceCommandResponseViewModel> ExecuteResourceCommandAsync(
{
using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken);

var response = await _client!.ExecuteResourceCommandAsync(request, cancellationToken: combinedTokens.Token);
var response = await _client!.ExecuteResourceCommandAsync(request, headers: _headers, cancellationToken: combinedTokens.Token);

return response.ToViewModel();
}
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ The resource service client supports certificates. Set `Dashboard:ResourceServic
- `Dashboard:ResourceServiceClient:ClientCertificate:Store` (optional, [`StoreName`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storename), defaults to `My`)
- `Dashboard:ResourceServiceClient:ClientCertificate:Location` (optional, [`StoreLocation`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storelocation), defaults to `CurrentUser`)

The resource service client supports API keys. Set `Dashboard:ResourceServiceClient:AuthMode` to `ApiKey` and set `Dashboard:ResourceServiceClient:ApiKey` to the required key. This is used when the resource service is configured to require API keys. It causes gRPC calls to include the configured API key in a request header, for the server to validate.

To opt-out of authentication, set `Dashboard: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.

#### Telemetry Limits
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Aspire.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<ItemGroup>
<Compile Include="$(SharedDir)ChannelExtensions.cs" Link="ChannelExtensions.cs" />
<Compile Include="$(SharedDir)CircularBuffer.cs" Link="CircularBuffer.cs" />
<Compile Include="$(SharedDir)CompareHelpers.cs" Link="Utils\CompareHelpers.cs" />
<Compile Include="$(SharedDir)Model\KnownProperties.cs" Link="Dashboard\KnownProperties.cs" />
<Compile Include="$(SharedDir)Model\KnownResourceTypes.cs" Link="Dashboard\KnownResourceTypes.cs" />
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="Utils\IConfigurationExtensions.cs" />
Expand Down
15 changes: 14 additions & 1 deletion src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource)
context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendUrlName.EnvVarName] = dashboardUrls;
context.EnvironmentVariables[DashboardConfigNames.ResourceServiceUrlName.EnvVarName] = resourceServiceUrl;
context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpUrlName.EnvVarName] = otlpEndpointUrl;
context.EnvironmentVariables[DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName] = "Unsecured";

// Configure frontend browser token
if (!string.IsNullOrEmpty(browserToken))
{
context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName] = "BrowserToken";
Expand All @@ -131,6 +131,19 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource)
context.EnvironmentVariables[DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName] = "Unsecured";
}

// Configure resource service API key
if (string.Equals(configuration["AppHost:ResourceService:AuthMode"], nameof(ResourceServiceAuthMode.ApiKey), StringComparison.OrdinalIgnoreCase)
&& configuration["AppHost:ResourceService:ApiKey"] is { Length: > 0 } resourceServiceApiKey)
{
context.EnvironmentVariables[DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName] = nameof(ResourceServiceAuthMode.ApiKey);
context.EnvironmentVariables[DashboardConfigNames.ResourceServiceClientApiKeyName.EnvVarName] = resourceServiceApiKey;
}
else
{
context.EnvironmentVariables[DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName] = nameof(ResourceServiceAuthMode.Unsecured);
}

// Configure OTLP API key
if (!string.IsNullOrEmpty(otlpApiKey))
{
context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "ApiKey";
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Hosting/Dashboard/DashboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.RegularExpressions;
using Aspire.V1;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Hosting;

namespace Aspire.Hosting.Dashboard;
Expand All @@ -15,6 +16,7 @@ namespace Aspire.Hosting.Dashboard;
/// An instance of this type is created for every gRPC service call, so it may not hold onto any state
/// required beyond a single request. Longer-scoped data is stored in <see cref="DashboardServiceData"/>.
/// </remarks>
[Authorize(Policy = ResourceServiceApiKeyAuthorization.PolicyName)]
internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime)
: V1.DashboardService.DashboardServiceBase
{
Expand Down
65 changes: 65 additions & 0 deletions src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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 System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Dashboard;

internal static class ResourceServiceApiKeyAuthorization
{
public const string PolicyName = "ResourceServiceApiKeyPolicy";
}

internal static class ResourceServiceApiKeyAuthenticationDefaults
{
public const string AuthenticationScheme = "ResourceServiceApiKey";
}

internal sealed class ResourceServiceApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
}

internal sealed class ResourceServiceApiKeyAuthenticationHandler(
IOptionsMonitor<ResourceServiceOptions> resourceServiceOptions,
IOptionsMonitor<ResourceServiceApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<ResourceServiceApiKeyAuthenticationOptions>(options, logger, encoder)
{
private const string ApiKeyHeaderName = "x-resource-service-api-key";

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var options = resourceServiceOptions.CurrentValue;

if (options.AuthMode is ResourceServiceAuthMode.ApiKey)
{
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var headerValues))
{
return Task.FromResult(AuthenticateResult.Fail($"'{ApiKeyHeaderName}' header not found"));
}

if (headerValues.Count != 1)
{
return Task.FromResult(AuthenticateResult.Fail($"Expecting only a single '{ApiKeyHeaderName}' header."));
}

if (!CompareHelpers.CompareKey(expectedKeyBytes: options.GetApiKeyBytes(), requestKey: headerValues.ToString()))
{
return Task.FromResult(AuthenticateResult.Fail($"Invalid '{ApiKeyHeaderName}' header value."));
}
}

return Task.FromResult(
AuthenticateResult.Success(
new AuthenticationTicket(
principal: new ClaimsPrincipal(new ClaimsIdentity(
claims: [],
authenticationType: ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme)),
authenticationScheme: Scheme.Name)));
}
}
30 changes: 30 additions & 0 deletions src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
Expand Down Expand Up @@ -75,6 +76,32 @@ public DashboardServiceHost(
// Configuration
builder.Services.AddSingleton(configuration);

var resourceServiceConfigSection = configuration.GetSection("AppHost:ResourceService");
Copy link
Member

Choose a reason for hiding this comment

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

We don't want any auth in unsecured mode.

Copy link
Member Author

Choose a reason for hiding this comment

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

The [Authorize(Policy = ...)] attribute on the gRPC service requires us to wire up auth.

We can switch on the auth mode and register a no-op policy that just allows everything, and not register the auth scheme. However, this introduces a bunch more code (as far as I can tell). We need to add the config in two layers (both outer and inner apps).

Overall I don't think it's an improvement. Here's the diff I came up with to do what I think you're asking:

Diff
diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs
index a82b5bbd5..56da4b091 100644
--- a/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs
+++ b/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs
@@ -36,22 +36,25 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
     {
         var options = resourceServiceOptions.CurrentValue;

-        if (options.AuthMode is ResourceServiceAuthMode.ApiKey)
+        if (options.AuthMode is not ResourceServiceAuthMode.ApiKey)
         {
-            if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var headerValues))
-            {
-                return Task.FromResult(AuthenticateResult.Fail($"'{ApiKeyHeaderName}' header not found"));
-            }
+            // Should be unreachable.
+            throw new InvalidOperationException("Auth mode must be ApiKey when this handler is active.");
+        }

-            if (headerValues.Count != 1)
-            {
-                return Task.FromResult(AuthenticateResult.Fail($"Expecting only a single '{ApiKeyHeaderName}' header."));
-            }
+        if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var headerValues))
+        {
+            return Task.FromResult(AuthenticateResult.Fail($"'{ApiKeyHeaderName}' header not found"));
+        }

-            if (!CompareHelpers.CompareKey(expectedKeyBytes: options.GetApiKeyBytes(), requestKey: headerValues.ToString()))
-            {
-                return Task.FromResult(AuthenticateResult.Fail($"Invalid '{ApiKeyHeaderName}' header value."));
-            }
+        if (headerValues.Count != 1)
+        {
+            return Task.FromResult(AuthenticateResult.Fail($"Expecting only a single '{ApiKeyHeaderName}' header."));
+        }
+
+        if (!CompareHelpers.CompareKey(expectedKeyBytes: options.GetApiKeyBytes(), requestKey: headerValues.ToString()))
+        {
+            return Task.FromResult(AuthenticateResult.Fail($"Invalid '{ApiKeyHeaderName}' header value."));
         }

         return Task.FromResult(
diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs
index 36ba2b39d..74f1bb1c9 100644
--- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs
+++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs
@@ -51,6 +51,7 @@ internal sealed class DashboardServiceHost : IHostedService
         DistributedApplicationModel applicationModel,
         IKubernetesService kubernetesService,
         IConfiguration configuration,
+        IOptions<ResourceServiceOptions> resourceServiceOptions,
         DistributedApplicationExecutionContext executionContext,
         ILoggerFactory loggerFactory,
         IConfigureOptions<LoggerFilterOptions> loggerOptions,
@@ -76,31 +77,45 @@ internal sealed class DashboardServiceHost : IHostedService
             // Configuration
             builder.Services.AddSingleton(configuration);

+            // These options are also constructed in the outer app. They are validated there,
+            // rather than here, so we don't perform validation twice.
             var resourceServiceConfigSection = configuration.GetSection("AppHost:ResourceService");
             builder.Services.AddOptions<ResourceServiceOptions>()
-                .Bind(resourceServiceConfigSection)
-                .ValidateOnStart();
-            builder.Services.AddSingleton<IValidateOptions<ResourceServiceOptions>, ValidateResourceServiceOptions>();
-
-            // Configure authentication scheme for the dashboard service
-            builder.Services
-                .AddAuthentication()
-                .AddScheme<ResourceServiceApiKeyAuthenticationOptions, ResourceServiceApiKeyAuthenticationHandler>(
-                    ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme,
-                    options => { });
-
-            // Configure authorization policy for the dashboard service.
-            // The authorization policy accepts anyone who successfully authenticates via the
-            // specified scheme, and that scheme enforces a valid API key (when configured to
-            // use API keys for calls.)
-            builder.Services
-                .AddAuthorizationBuilder()
-                .AddPolicy(
-                    name: ResourceServiceApiKeyAuthorization.PolicyName,
-                    policy: new AuthorizationPolicyBuilder(
-                        ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme)
-                        .RequireAuthenticatedUser()
-                        .Build());
+                .Bind(resourceServiceConfigSection);
+
+            if (resourceServiceOptions.Value.AuthMode == ResourceServiceAuthMode.ApiKey)
+            {
+                // Configure authentication scheme for the resource service
+                builder.Services
+                    .AddAuthentication()
+                    .AddScheme<ResourceServiceApiKeyAuthenticationOptions, ResourceServiceApiKeyAuthenticationHandler>(
+                        ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme,
+                        options => { });
+
+                // Configure authorization policy for the resource service.
+                // The authorization policy accepts anyone who successfully authenticates via the
+                // specified scheme, and that scheme enforces a valid API key (when configured to
+                // use API keys for calls.)
+                builder.Services
+                    .AddAuthorizationBuilder()
+                    .AddPolicy(
+                        name: ResourceServiceApiKeyAuthorization.PolicyName,
+                        policy: new AuthorizationPolicyBuilder(
+                            ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme)
+                            .RequireAuthenticatedUser()
+                            .Build());
+            }
+            else
+            {
+                builder.Services.AddAuthentication();
+                builder.Services
+                    .AddAuthorizationBuilder()
+                    .AddPolicy(
+                        name: ResourceServiceApiKeyAuthorization.PolicyName,
+                        policy: new AuthorizationPolicyBuilder()
+                            .RequireAssertion(_ => true)
+                            .Build());
+            }

             // Logging
             builder.Services.AddSingleton(loggerFactory);
diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
index 705077790..c1b758d98 100644
--- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
@@ -151,6 +151,12 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
                     );
                 }

+                var resourceServiceConfigSection = _innerBuilder.Configuration.GetSection("AppHost:ResourceService");
+                _innerBuilder.Services.AddOptions<ResourceServiceOptions>()
+                    .Bind(resourceServiceConfigSection)
+                    .ValidateOnStart();
+                _innerBuilder.Services.AddSingleton<IValidateOptions<ResourceServiceOptions>, ValidateResourceServiceOptions>();
+
                 _innerBuilder.Services.AddOptions<TransportOptions>().ValidateOnStart().PostConfigure(MapTransportOptionsFromCustomKeys);
                 _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TransportOptions>, TransportOptionsValidator>());
                 _innerBuilder.Services.AddSingleton<DashboardServiceHost>();

builder.Services.AddOptions<ResourceServiceOptions>()
.Bind(resourceServiceConfigSection)
.ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<ResourceServiceOptions>, ValidateResourceServiceOptions>();

// Configure authentication scheme for the dashboard service
builder.Services
.AddAuthentication()
.AddScheme<ResourceServiceApiKeyAuthenticationOptions, ResourceServiceApiKeyAuthenticationHandler>(
ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme,
options => { });

// Configure authorization policy for the dashboard service.
// The authorization policy accepts anyone who successfully authenticates via the
// specified scheme, and that scheme enforces a valid API key (when configured to
// use API keys for calls.)
builder.Services
.AddAuthorizationBuilder()
.AddPolicy(
name: ResourceServiceApiKeyAuthorization.PolicyName,
policy: new AuthorizationPolicyBuilder(
ResourceServiceApiKeyAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build());

// Logging
builder.Services.AddSingleton(loggerFactory);
builder.Services.AddSingleton(loggerOptions);
Expand All @@ -91,6 +118,9 @@ public DashboardServiceHost(

_app = builder.Build();

_app.UseAuthentication();
_app.UseAuthorization();

_app.MapGrpcService<DashboardService>();
}
catch (Exception ex)
Expand Down
62 changes: 62 additions & 0 deletions src/Aspire.Hosting/Dashboard/ResourceServiceOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Dashboard;

internal enum ResourceServiceAuthMode
{
// NOTE unlike ResourceClientAuthMode, there's no 'Certificate' option here.
// The AppHost's implementation of the resource service does not support
// certificate-based auth.

Unsecured,
ApiKey
}

internal sealed class ResourceServiceOptions
{
private string? _apiKey;
private byte[]? _apiKeyBytes;

public ResourceServiceAuthMode? AuthMode { get; set; }

public string? ApiKey
{
get => _apiKey;
set
{
_apiKey = value;
_apiKeyBytes = value is null ? null : Encoding.UTF8.GetBytes(value);
}
}

internal byte[] GetApiKeyBytes()
{
return _apiKeyBytes ?? throw new InvalidOperationException($"AppHost:ResourceService:ApiKey is not specified in configuration.");
}
}

internal sealed class ValidateResourceServiceOptions : IValidateOptions<ResourceServiceOptions>
{
public ValidateOptionsResult Validate(string? name, ResourceServiceOptions options)
{
List<string>? errorMessages = null;

if (options.AuthMode is ResourceServiceAuthMode.ApiKey)
{
if (string.IsNullOrWhiteSpace(options.ApiKey))
{
AddError($"AppHost:ResourceService:ApiKey is required when AppHost:ResourceService:AuthMode is '{nameof(ResourceServiceAuthMode.ApiKey)}'.");
}
}

return errorMessages is { Count: > 0 }
? ValidateOptionsResult.Fail(errorMessages)
: ValidateOptionsResult.Success;

void AddError(string message) => (errorMessages ??= []).Add(message);
}
}
Loading