Skip to content

Commit

Permalink
Auth for dashboard web app and resource service client (#3033)
Browse files Browse the repository at this point in the history
* Enable basic certificate auth for resource service

This adds the ability to configured the gRPC connection from the dashboard to a resource service to use certificates for authentication.

Such auth is not used in the local dev scenario, so the app host is changed to suppress auth via the `DOTNET_RESOURCE_SERVICE_DISABLE_AUTH` environment variable. The dashboard will now require certificates unless this variable is set to `1`. This discourages self-hosting the dashboard in an insecure manner.

* Support loading client cert from keystore

* Use enum for resource service auth mode

* Dashboard web app supports OpenID Connect

* Use a single authentication call for both the web app and OTLP

Use explicit policies and configure those policies with the authentication schemes required.

Also rejig some config settings for consistency.

* Remove redundant configuration key

* Rename "web app" to "frontend"

* Reorder readme sections and adjust headings and their levels

* Seal class

* Simplify frontend auth implementation

* Add docs

* More thorough enum validation

* Remove unused config key

* Change exception type
  • Loading branch information
drewnoakes authored Mar 25, 2024
1 parent c97c3d7 commit 4768eb6
Show file tree
Hide file tree
Showing 16 changed files with 343 additions and 136 deletions.
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
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Aspire.Dashboard.Authentication;

public enum OtlpAuthMode
{
None,
Unsecured,
ApiKey,
ClientCertificate
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,18 @@

namespace Aspire.Dashboard.Authentication;

public sealed class OtlpCompositeAuthenticationHandler : AuthenticationHandler<OtlpCompositeAuthenticationHandlerOptions>
public sealed class OtlpCompositeAuthenticationHandler(
IOptionsMonitor<OtlpCompositeAuthenticationHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<OtlpCompositeAuthenticationHandlerOptions>(options, logger, encoder)
{
public OtlpCompositeAuthenticationHandler(IOptionsMonitor<OtlpCompositeAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
{
}

protected override async Task<AuthenticateResult> 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;
Expand All @@ -44,6 +32,20 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
var id = new ClaimsIdentity([new Claim(OtlpAuthorization.OtlpClaimName, bool.TrueString)]);

return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(id), Scheme.Name));

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

Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Dashboard/Components/_Imports.razor
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)]
14 changes: 0 additions & 14 deletions src/Aspire.Dashboard/DashboardStartupConfiguration.cs

This file was deleted.

150 changes: 120 additions & 30 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -51,10 +57,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 Down Expand Up @@ -320,8 +324,8 @@ static Func<EndpointInfo> 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<OtlpCompositeAuthenticationHandlerOptions, OtlpCompositeAuthenticationHandler>(OtlpCompositeAuthenticationDefaults.AuthenticationScheme, o =>
{
o.OtlpAuthMode = dashboardStartupConfig.OtlpAuthMode;
Expand All @@ -333,7 +337,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb
.AddScheme<OtlpConnectionAuthenticationHandlerOptions, OtlpConnectionAuthenticationHandler>(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
Expand All @@ -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.");
}
});
}

Expand All @@ -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<FrontendAuthMode>(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<OtlpAuthMode>(DashboardOtlpAuthModeVariableName);
var otlpAuthMode = configuration.GetEnum<OtlpAuthMode>(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";
}
Loading

0 comments on commit 4768eb6

Please sign in to comment.