Skip to content

Commit

Permalink
Resource service supports API keys
Browse files Browse the repository at this point in the history
Unless unsecured, the app host will generate an API key and pass it to the dashboard via an environment variable. The dashboard then includes this key in a header for all gRPC calls. The app host's resource service validates that the expected key is received and rejects requests where the key is omitted.
  • Loading branch information
drewnoakes committed Apr 4, 2024
1 parent 0fdfcd5 commit cf1276f
Show file tree
Hide file tree
Showing 17 changed files with 228 additions and 9 deletions.
6 changes: 6 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,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 @@ -39,6 +43,8 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
}
}

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

errorMessage = null;
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void PostConfigure(string? name, DashboardOptions options)
{
options.Frontend.AuthMode = FrontendAuthMode.Unsecured;
options.Otlp.AuthMode = OtlpAuthMode.Unsecured;
options.ResourceServiceClient.AuthMode = ResourceClientAuthMode.Unsecured;
}
if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken && string.IsNullOrEmpty(options.Frontend.BrowserToken))
{
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";

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))
{
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 @@ -76,6 +76,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.

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
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 = ResourceServiceAuthorization.PolicyName)]
internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime)
: V1.DashboardService.DashboardServiceBase
{
Expand Down
63 changes: 63 additions & 0 deletions src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 ResourceServiceAuthorization
{
public const string PolicyName = "ResourceServicePolicy";
}

internal static class ResourceServiceAuthenticationDefaults
{
public const string AuthenticationScheme = "ResourceService";
}

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()),
authenticationScheme: Scheme.Name)));
}
}
27 changes: 27 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,29 @@ public DashboardServiceHost(
// Configuration
builder.Services.AddSingleton(configuration);

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>(
ResourceServiceAuthenticationDefaults.AuthenticationScheme,
options => { });

// Configure authorization policy for the dashboard service
builder.Services
.AddAuthorizationBuilder()
.AddPolicy(
name: ResourceServiceAuthorization.PolicyName,
policy: new AuthorizationPolicyBuilder(
ResourceServiceAuthenticationDefaults.AuthenticationScheme)
.RequireAssertion(_ => true)
.Build());

// Logging
builder.Services.AddSingleton(loggerFactory);
builder.Services.AddSingleton(loggerOptions);
Expand All @@ -91,6 +115,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($"{nameof(ApiKey)} 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($"{nameof(ResourceServiceOptions.ApiKey)} value is required when AuthMode is {nameof(ResourceServiceAuthMode.ApiKey)}.");
}
}

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

void AddError(string message) => (errorMessages ??= []).Add(message);
}
}
15 changes: 14 additions & 1 deletion src/Aspire.Hosting/Dcp/ApplicationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -857,9 +857,9 @@ private async Task<List<KeyValuePair<string, string>>> GetDashboardEnvironmentVa
KeyValuePair.Create(DashboardConfigNames.DashboardFrontendUrlName.EnvVarName, dashboardUrls),
KeyValuePair.Create(DashboardConfigNames.ResourceServiceUrlName.EnvVarName, resourceServiceUrl),
KeyValuePair.Create(DashboardConfigNames.DashboardOtlpUrlName.EnvVarName, otlpEndpointUrl),
KeyValuePair.Create(DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName, "Unsecured"),
};

// Configure frontend browser token
if (configuration["AppHost:BrowserToken"] is { Length: > 0 } browserToken)
{
env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "BrowserToken"));
Expand All @@ -870,6 +870,19 @@ private async Task<List<KeyValuePair<string, string>>> GetDashboardEnvironmentVa
env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "Unsecured"));
}

// Configure resource service API key
if (StringComparer.OrdinalIgnoreCase.Equals(configuration["AppHost:ResourceService:AuthMode"], nameof(ResourceServiceAuthMode.ApiKey))
&& configuration["AppHost:ResourceService:ApiKey"] is { Length: > 0 } resourceServiceApiKey)
{
env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName, nameof(ResourceServiceAuthMode.ApiKey)));
env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientApiKeyName.EnvVarName, resourceServiceApiKey));
}
else
{
env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName, nameof(ResourceServiceAuthMode.Unsecured)));
}

// Configure OTLP API key
if (configuration["AppHost:OtlpApiKey"] is { Length: > 0 } otlpApiKey)
{
env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName, "ApiKey"));
Expand Down
Loading

0 comments on commit cf1276f

Please sign in to comment.