Skip to content
14 changes: 14 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,11 @@ public sealed class OpenIdConnectOptions
/// </summary>
public string RequiredClaimValue { get; set; } = "";

/// <summary>
/// Gets or sets the optional value to configure the ClaimActions of <see cref="Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions"/>
/// </summary>
public List<ClaimAction> ClaimActions { get; set; } = new();

public string[] GetNameClaimTypes()
{
Debug.Assert(_nameClaimTypes is not null, "Should have been parsed during validation.");
Expand Down Expand Up @@ -314,6 +319,15 @@ internal bool TryParseOptions([NotNullWhen(false)] out IEnumerable<string>? erro
}
}

public sealed class ClaimAction
{
public required string ClaimType { get; set; }
public required string JsonKey { get; set; }
public string? SubKey { get; set; }
public bool? IsUnique { get; set; }
public string? ValueType { get; set; }
}

public sealed class AIOptions
{
public bool? Disabled { get; set; }
Expand Down
24 changes: 24 additions & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
using Microsoft.Extensions.Options;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using OpenIdConnectOptions = Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions;

namespace Aspire.Dashboard;

Expand Down Expand Up @@ -801,6 +802,17 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb

// Avoid "message.State is null or empty" due to use of CallbackPath above.
options.SkipUnrecognizedRequests = true;

// Configure additional ClaimActions
var claimActions = dashboardOptions.Frontend.OpenIdConnect.ClaimActions;
if (claimActions.Count > 0)
{
foreach (var claimAction in claimActions)
{
var configureAction = GetOidcClaimActionConfigure(claimAction);
configureAction(options);
}
}
});
break;
case FrontendAuthMode.BrowserToken:
Expand Down Expand Up @@ -878,6 +890,18 @@ static string ConfigureDefaultAuthScheme(DashboardOptions dashboardOptions)
}
}

internal static Action<OpenIdConnectOptions> GetOidcClaimActionConfigure(ClaimAction action)
{
Action<OpenIdConnectOptions> configureAction = (action.SubKey is null, action.IsUnique) switch
{
(true, true) => options => options.ClaimActions.MapUniqueJsonKey(action.ClaimType, action.JsonKey, action.ValueType ?? ClaimValueTypes.String),
(true, _) => options => options.ClaimActions.MapJsonKey(action.ClaimType, action.JsonKey, action.ValueType ?? ClaimValueTypes.String),
(false, _) => options => options.ClaimActions.MapJsonSubKey(action.ClaimType, action.JsonKey, action.SubKey!, action.ValueType ?? ClaimValueTypes.String)
};

return configureAction;
}

public int Run()
{
if (_validationFailures.Count > 0)
Expand Down
170 changes: 170 additions & 0 deletions tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
// 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.Json;
using Aspire.Dashboard.Configuration;
using Aspire.Hosting;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
using OpenIdConnectOptions = Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions;

namespace Aspire.Dashboard.Tests;

Expand Down Expand Up @@ -275,5 +282,168 @@ public void OpenIdConnectOptions_NoUserNameClaimType()
Assert.Equal("OpenID Connect claim type for username not configured. Specify a Dashboard:Frontend:OpenIdConnect:UsernameClaimType value.", result.FailureMessage);
}

[Fact]
public void OpenIdConnectOptions_ClaimActions_MapJsonKeyTest()
{
var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection(
[
new("ASPNETCORE_URLS", "http://localhost:8000/"),
new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"),
new("Authentication:Schemes:OpenIdConnect:Authority", "https://id.aspire.dev/"),
new("Authentication:Schemes:OpenIdConnect:ClientId", "aspire-dashboard"),
new("Dashboard:Frontend:AuthMode", "OpenIdConnect"),
new("Dashboard:Frontend:OpenIdConnect:ClaimActions:0:ClaimType", "role"),
new("Dashboard:Frontend:OpenIdConnect:ClaimActions:0:JsonKey", "role"),
new("Dashboard:Frontend:OpenIdConnect:RequiredClaimType", "role")
]));
var openIdConnectAuthOptions = app.Services.GetService<IOptionsMonitor<OpenIdConnectOptions>>()?.Get(OpenIdConnectDefaults.AuthenticationScheme);
Assert.NotNull(openIdConnectAuthOptions);
Assert.NotEmpty(openIdConnectAuthOptions.ClaimActions);
var claimAction = openIdConnectAuthOptions.ClaimActions.FirstOrDefault(x => x.ClaimType == "role");
Assert.NotNull(claimAction);
Assert.Equal("role", claimAction.ClaimType);
var jsonElement = JsonDocument.Parse("""
{
"role": ["admin", "test"]
}
""").RootElement.Clone();
var claimIdentity = new ClaimsIdentity();
claimAction.Run(jsonElement, claimIdentity, "test");
Assert.Equal(2, claimIdentity.Claims.Count());
Assert.True(claimIdentity.HasClaim("role", "admin"));
Assert.True(claimIdentity.HasClaim("role", "test"));
}

[Fact]
public void GetOidcClaimActionConfigure_MapJsonKeyTest()
{
var claimAction = new ClaimAction
{
ClaimType = "role",
JsonKey = "role"
};
var oidcOption = new OpenIdConnectOptions();
oidcOption.ClaimActions.Clear();
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
configure(oidcOption);
Assert.Single(oidcOption.ClaimActions);
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
Assert.NotNull(action);
var jsonElement = JsonDocument.Parse("""
{
"role": ["admin", "test"]
}
""").RootElement.Clone();
var claimIdentity = new ClaimsIdentity();
action.Run(jsonElement, claimIdentity, "test");
Assert.Equal(2, claimIdentity.Claims.Count());
Assert.True(claimIdentity.HasClaim("role", "admin"));
Assert.True(claimIdentity.HasClaim("role", "test"));
}

[Fact]
public void GetOidcClaimActionConfigure_MapUniqueJsonKeyTest()
{
var claimAction = new ClaimAction
{
ClaimType = "name",
JsonKey = "name",
IsUnique = true
};
var oidcOption = new OpenIdConnectOptions();
oidcOption.ClaimActions.Clear();
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
configure(oidcOption);
Assert.Single(oidcOption.ClaimActions);
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
Assert.NotNull(action);
var jsonElement = JsonDocument.Parse("""
{
"name": "test"
}
""").RootElement.Clone();
var claimIdentity = new ClaimsIdentity(
[
new Claim("name", "test")
]);
action.Run(jsonElement, claimIdentity, "test");
Assert.Single(claimIdentity.Claims);
Assert.True(claimIdentity.HasClaim("name", "test"));

var emptyClaimIdentity = new ClaimsIdentity();
action.Run(jsonElement, emptyClaimIdentity, "test");
Assert.Single(emptyClaimIdentity.Claims);
Assert.True(emptyClaimIdentity.HasClaim("name", "test"));
}

[Fact]
public void GetOidcClaimActionConfigure_MapJsonSubKeyTest()
{
var claimAction = new ClaimAction
{
ClaimType = "name",
JsonKey = "profile",
SubKey = "name"
};
var oidcOption = new OpenIdConnectOptions();
oidcOption.ClaimActions.Clear();
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
configure(oidcOption);
Assert.Single(oidcOption.ClaimActions);
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
Assert.NotNull(action);
var jsonElement = JsonDocument.Parse("""
{
"profile": {
"name": "test"
}
}
""").RootElement.Clone();
var claimIdentity = new ClaimsIdentity(
[
new Claim("name", "test")
]);
action.Run(jsonElement, claimIdentity, "test");
Assert.Equal(2, claimIdentity.Claims.Count());
Assert.True(claimIdentity.HasClaim("name", "test"));

var emptyClaimIdentity = new ClaimsIdentity();
action.Run(jsonElement, emptyClaimIdentity, "test");
Assert.Single(emptyClaimIdentity.Claims);
Assert.True(emptyClaimIdentity.HasClaim("name", "test"));
}

[Fact]
public void GetOidcClaimActionConfigure_MapJsonKey_ValueTypeTest()
{
var claimAction = new ClaimAction
{
ClaimType = "sub",
JsonKey = "userId",
ValueType = ClaimValueTypes.Integer,
IsUnique = true
};
var oidcOption = new OpenIdConnectOptions();
oidcOption.ClaimActions.Clear();
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
configure(oidcOption);
Assert.Single(oidcOption.ClaimActions);
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == claimAction.ValueType);
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
Assert.NotNull(action);
var jsonElement = JsonDocument.Parse("""
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could use JsonElement.Parse when we update to .NET 10

{
"userId": "1"
}
""").RootElement.Clone();
var claimIdentity = new ClaimsIdentity();
action.Run(jsonElement, claimIdentity, "test");
Assert.NotEmpty(claimIdentity.Claims);
Assert.True(claimIdentity.HasClaim("sub", "1"));
}

#endregion
}