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

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 NotImplementedException($"Unexpected {nameof(FrontendAuthMode)} enum member.");
drewnoakes marked this conversation as resolved.
Show resolved Hide resolved
}
});
}

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

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

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