Skip to content

Commit 02af028

Browse files
WeihanLiCopilotJamesNK
authored
support ClaimActions configure for dashboard (#8396)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: James Newton-King <james@newtonking.com>
1 parent 69896f1 commit 02af028

File tree

3 files changed

+208
-0
lines changed

3 files changed

+208
-0
lines changed

src/Aspire.Dashboard/Configuration/DashboardOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ public sealed class OpenIdConnectOptions
272272
/// </summary>
273273
public string RequiredClaimValue { get; set; } = "";
274274

275+
/// <summary>
276+
/// Gets or sets the optional value to configure the ClaimActions of <see cref="Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions"/>
277+
/// </summary>
278+
public List<ClaimAction> ClaimActions { get; set; } = new();
279+
275280
public string[] GetNameClaimTypes()
276281
{
277282
Debug.Assert(_nameClaimTypes is not null, "Should have been parsed during validation.");
@@ -314,6 +319,15 @@ internal bool TryParseOptions([NotNullWhen(false)] out IEnumerable<string>? erro
314319
}
315320
}
316321

322+
public sealed class ClaimAction
323+
{
324+
public required string ClaimType { get; set; }
325+
public required string JsonKey { get; set; }
326+
public string? SubKey { get; set; }
327+
public bool? IsUnique { get; set; }
328+
public string? ValueType { get; set; }
329+
}
330+
317331
public sealed class AIOptions
318332
{
319333
public bool? Disabled { get; set; }

src/Aspire.Dashboard/DashboardWebApplication.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
using Microsoft.Extensions.Options;
4242
using Microsoft.FluentUI.AspNetCore.Components;
4343
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
44+
using OpenIdConnectOptions = Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions;
4445

4546
namespace Aspire.Dashboard;
4647

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

802803
// Avoid "message.State is null or empty" due to use of CallbackPath above.
803804
options.SkipUnrecognizedRequests = true;
805+
806+
// Configure additional ClaimActions
807+
var claimActions = dashboardOptions.Frontend.OpenIdConnect.ClaimActions;
808+
if (claimActions.Count > 0)
809+
{
810+
foreach (var claimAction in claimActions)
811+
{
812+
var configureAction = GetOidcClaimActionConfigure(claimAction);
813+
configureAction(options);
814+
}
815+
}
804816
});
805817
break;
806818
case FrontendAuthMode.BrowserToken:
@@ -878,6 +890,18 @@ static string ConfigureDefaultAuthScheme(DashboardOptions dashboardOptions)
878890
}
879891
}
880892

893+
internal static Action<OpenIdConnectOptions> GetOidcClaimActionConfigure(ClaimAction action)
894+
{
895+
Action<OpenIdConnectOptions> configureAction = (action.SubKey is null, action.IsUnique) switch
896+
{
897+
(true, true) => options => options.ClaimActions.MapUniqueJsonKey(action.ClaimType, action.JsonKey, action.ValueType ?? ClaimValueTypes.String),
898+
(true, _) => options => options.ClaimActions.MapJsonKey(action.ClaimType, action.JsonKey, action.ValueType ?? ClaimValueTypes.String),
899+
(false, _) => options => options.ClaimActions.MapJsonSubKey(action.ClaimType, action.JsonKey, action.SubKey!, action.ValueType ?? ClaimValueTypes.String)
900+
};
901+
902+
return configureAction;
903+
}
904+
881905
public int Run()
882906
{
883907
if (_validationFailures.Count > 0)

tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Security.Claims;
5+
using System.Text.Json;
46
using Aspire.Dashboard.Configuration;
57
using Aspire.Hosting;
8+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Options;
612
using Xunit;
13+
using OpenIdConnectOptions = Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions;
714

815
namespace Aspire.Dashboard.Tests;
916

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

285+
[Fact]
286+
public void OpenIdConnectOptions_ClaimActions_MapJsonKeyTest()
287+
{
288+
var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection(
289+
[
290+
new("ASPNETCORE_URLS", "http://localhost:8000/"),
291+
new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"),
292+
new("Authentication:Schemes:OpenIdConnect:Authority", "https://id.aspire.dev/"),
293+
new("Authentication:Schemes:OpenIdConnect:ClientId", "aspire-dashboard"),
294+
new("Dashboard:Frontend:AuthMode", "OpenIdConnect"),
295+
new("Dashboard:Frontend:OpenIdConnect:ClaimActions:0:ClaimType", "role"),
296+
new("Dashboard:Frontend:OpenIdConnect:ClaimActions:0:JsonKey", "role"),
297+
new("Dashboard:Frontend:OpenIdConnect:RequiredClaimType", "role")
298+
]));
299+
var openIdConnectAuthOptions = app.Services.GetService<IOptionsMonitor<OpenIdConnectOptions>>()?.Get(OpenIdConnectDefaults.AuthenticationScheme);
300+
Assert.NotNull(openIdConnectAuthOptions);
301+
Assert.NotEmpty(openIdConnectAuthOptions.ClaimActions);
302+
var claimAction = openIdConnectAuthOptions.ClaimActions.FirstOrDefault(x => x.ClaimType == "role");
303+
Assert.NotNull(claimAction);
304+
Assert.Equal("role", claimAction.ClaimType);
305+
var jsonElement = JsonDocument.Parse("""
306+
{
307+
"role": ["admin", "test"]
308+
}
309+
""").RootElement.Clone();
310+
var claimIdentity = new ClaimsIdentity();
311+
claimAction.Run(jsonElement, claimIdentity, "test");
312+
Assert.Equal(2, claimIdentity.Claims.Count());
313+
Assert.True(claimIdentity.HasClaim("role", "admin"));
314+
Assert.True(claimIdentity.HasClaim("role", "test"));
315+
}
316+
317+
[Fact]
318+
public void GetOidcClaimActionConfigure_MapJsonKeyTest()
319+
{
320+
var claimAction = new ClaimAction
321+
{
322+
ClaimType = "role",
323+
JsonKey = "role"
324+
};
325+
var oidcOption = new OpenIdConnectOptions();
326+
oidcOption.ClaimActions.Clear();
327+
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
328+
configure(oidcOption);
329+
Assert.Single(oidcOption.ClaimActions);
330+
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
331+
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
332+
Assert.NotNull(action);
333+
var jsonElement = JsonDocument.Parse("""
334+
{
335+
"role": ["admin", "test"]
336+
}
337+
""").RootElement.Clone();
338+
var claimIdentity = new ClaimsIdentity();
339+
action.Run(jsonElement, claimIdentity, "test");
340+
Assert.Equal(2, claimIdentity.Claims.Count());
341+
Assert.True(claimIdentity.HasClaim("role", "admin"));
342+
Assert.True(claimIdentity.HasClaim("role", "test"));
343+
}
344+
345+
[Fact]
346+
public void GetOidcClaimActionConfigure_MapUniqueJsonKeyTest()
347+
{
348+
var claimAction = new ClaimAction
349+
{
350+
ClaimType = "name",
351+
JsonKey = "name",
352+
IsUnique = true
353+
};
354+
var oidcOption = new OpenIdConnectOptions();
355+
oidcOption.ClaimActions.Clear();
356+
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
357+
configure(oidcOption);
358+
Assert.Single(oidcOption.ClaimActions);
359+
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
360+
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
361+
Assert.NotNull(action);
362+
var jsonElement = JsonDocument.Parse("""
363+
{
364+
"name": "test"
365+
}
366+
""").RootElement.Clone();
367+
var claimIdentity = new ClaimsIdentity(
368+
[
369+
new Claim("name", "test")
370+
]);
371+
action.Run(jsonElement, claimIdentity, "test");
372+
Assert.Single(claimIdentity.Claims);
373+
Assert.True(claimIdentity.HasClaim("name", "test"));
374+
375+
var emptyClaimIdentity = new ClaimsIdentity();
376+
action.Run(jsonElement, emptyClaimIdentity, "test");
377+
Assert.Single(emptyClaimIdentity.Claims);
378+
Assert.True(emptyClaimIdentity.HasClaim("name", "test"));
379+
}
380+
381+
[Fact]
382+
public void GetOidcClaimActionConfigure_MapJsonSubKeyTest()
383+
{
384+
var claimAction = new ClaimAction
385+
{
386+
ClaimType = "name",
387+
JsonKey = "profile",
388+
SubKey = "name"
389+
};
390+
var oidcOption = new OpenIdConnectOptions();
391+
oidcOption.ClaimActions.Clear();
392+
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
393+
configure(oidcOption);
394+
Assert.Single(oidcOption.ClaimActions);
395+
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == ClaimValueTypes.String);
396+
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
397+
Assert.NotNull(action);
398+
var jsonElement = JsonDocument.Parse("""
399+
{
400+
"profile": {
401+
"name": "test"
402+
}
403+
}
404+
""").RootElement.Clone();
405+
var claimIdentity = new ClaimsIdentity(
406+
[
407+
new Claim("name", "test")
408+
]);
409+
action.Run(jsonElement, claimIdentity, "test");
410+
Assert.Equal(2, claimIdentity.Claims.Count());
411+
Assert.True(claimIdentity.HasClaim("name", "test"));
412+
413+
var emptyClaimIdentity = new ClaimsIdentity();
414+
action.Run(jsonElement, emptyClaimIdentity, "test");
415+
Assert.Single(emptyClaimIdentity.Claims);
416+
Assert.True(emptyClaimIdentity.HasClaim("name", "test"));
417+
}
418+
419+
[Fact]
420+
public void GetOidcClaimActionConfigure_MapJsonKey_ValueTypeTest()
421+
{
422+
var claimAction = new ClaimAction
423+
{
424+
ClaimType = "sub",
425+
JsonKey = "userId",
426+
ValueType = ClaimValueTypes.Integer,
427+
IsUnique = true
428+
};
429+
var oidcOption = new OpenIdConnectOptions();
430+
oidcOption.ClaimActions.Clear();
431+
var configure = DashboardWebApplication.GetOidcClaimActionConfigure(claimAction);
432+
configure(oidcOption);
433+
Assert.Single(oidcOption.ClaimActions);
434+
Assert.Contains(oidcOption.ClaimActions, x => x.ClaimType == claimAction.ClaimType && x.ValueType == claimAction.ValueType);
435+
var action = oidcOption.ClaimActions.FirstOrDefault(x => x.ClaimType == claimAction.ClaimType);
436+
Assert.NotNull(action);
437+
var jsonElement = JsonDocument.Parse("""
438+
{
439+
"userId": "1"
440+
}
441+
""").RootElement.Clone();
442+
var claimIdentity = new ClaimsIdentity();
443+
action.Run(jsonElement, claimIdentity, "test");
444+
Assert.NotEmpty(claimIdentity.Claims);
445+
Assert.True(claimIdentity.HasClaim("sub", "1"));
446+
}
447+
278448
#endregion
279449
}

0 commit comments

Comments
 (0)