Improve advanced auth provider editing, add role claim mapping

- Added auto discovery of scopes and claim mappings to auth provider editor
- Added the ability to map claims to roles
- Auto-provision users logged in over external auth

Ref #411
This commit is contained in:
Pat Hartl 2026-06-21 02:02:27 -05:00
parent 0f03461f53
commit f1fa66b632
7 changed files with 510 additions and 38 deletions

View file

@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AutoMapper;
using LANCommander.SDK;
using LANCommander.SDK.Abstractions;
@ -291,5 +293,41 @@ namespace LANCommander.Server.Services
return externalProviders;
}
/// <summary>
/// Reads the provider's OpenID Connect discovery document (the well-known
/// configuration URL) and returns the advertised <c>claims_supported</c> and
/// <c>scopes_supported</c> values. These are advisory only: the fields are
/// optional in the spec and many providers under-report them, so they should be
/// treated as UI suggestions rather than an authoritative set.
/// </summary>
public static async Task<(IReadOnlyCollection<string> Claims, IReadOnlyCollection<string> Scopes)> GetDiscoveryMetadataAsync(string configurationUrl)
{
if (String.IsNullOrWhiteSpace(configurationUrl))
return (Array.Empty<string>(), Array.Empty<string>());
using var http = new HttpClient();
using var stream = await http.GetStreamAsync(configurationUrl);
using var document = await JsonDocument.ParseAsync(stream);
var claims = ReadStringArray(document.RootElement, "claims_supported");
var scopes = ReadStringArray(document.RootElement, "scopes_supported");
return (claims, scopes);
}
private static IReadOnlyCollection<string> ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array)
return Array.Empty<string>();
return array.EnumerateArray()
.Where(c => c.ValueKind == JsonValueKind.String)
.Select(c => c.GetString())
.Where(s => !String.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}

View file

@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
@ -8,9 +10,11 @@ using LANCommander.Server.Services.Models;
using LANCommander.Server.Settings.Enums;
using LANCommander.Server.Settings.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using ZiggyCreatures.Caching.Fusion;
namespace LANCommander.Server.Startup;
@ -64,6 +68,9 @@ public static class Authentication
options.ClientSecret = authenticationProvider.ClientSecret;
options.MetadataAddress = authenticationProvider.ConfigurationUrl;
options.ResponseType = OpenIdConnectResponseType.Code;
// Fetch the userinfo endpoint so configured claim mappings (which run over
// the userinfo JSON) are applied to the principal.
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
@ -101,12 +108,8 @@ public static class Authentication
{
options.Scope.Add(scope);
}
foreach (var claimMapping in authenticationProvider.ClaimMappings)
{
if (!String.IsNullOrWhiteSpace(claimMapping.Name) && !String.IsNullOrWhiteSpace(claimMapping.Value))
options.ClaimActions.MapJsonKey(claimMapping.Name, claimMapping.Value);
}
RegisterClaimActions(options.ClaimActions, authenticationProvider);
options.Events.OnRemoteFailure = async context =>
{
@ -152,11 +155,7 @@ public static class Authentication
options.SaveTokens = true;
foreach (var claimMapping in authenticationProvider.ClaimMappings)
{
if (!String.IsNullOrWhiteSpace(claimMapping.Name) && !String.IsNullOrWhiteSpace(claimMapping.Value))
options.ClaimActions.MapJsonKey(claimMapping.Name, claimMapping.Value);
}
RegisterClaimActions(options.ClaimActions, authenticationProvider);
options.Events.OnRemoteFailure = async context =>
{
@ -227,21 +226,38 @@ public static class Authentication
{
user = await userService.GetAsync(customField.UserId.Value);
await SyncRolesAsync(httpContext, principal, user);
await signInManager.SignInAsync(user, true);
response.Redirect(properties.RedirectUri);
}
else
{
await httpContext.SignInAsync(IdentityConstants.ApplicationScheme,
principal,
new AuthenticationProperties
{
AllowRefresh = false,
IsPersistent = false,
});
response.Redirect($"/Register?Provider={authenticationProvider.Slug}");
user = await ProvisionUserAsync(httpContext, identity, authenticationProvider);
if (user != null)
{
await SyncRolesAsync(httpContext, principal, user);
await signInManager.SignInAsync(user, true);
response.Redirect(properties.RedirectUri);
}
else
{
// No usable username claim, or the username collides with an
// existing local account. Fall back to manual registration.
await httpContext.SignInAsync(IdentityConstants.ApplicationScheme,
principal,
new AuthenticationProperties
{
AllowRefresh = false,
IsPersistent = false,
});
response.Redirect($"/Register?Provider={authenticationProvider.Slug}");
}
}
break;
@ -286,4 +302,152 @@ public static class Authentication
break;
}
}
private static void RegisterClaimActions(ClaimActionCollection claimActions, AuthenticationProvider authenticationProvider)
{
foreach (var claimMapping in authenticationProvider.ClaimMappings)
{
if (String.IsNullOrWhiteSpace(claimMapping.Name) || String.IsNullOrWhiteSpace(claimMapping.Value))
continue;
if (IsRoleMapping(claimMapping.Name))
claimActions.Add(new RolesClaimAction(ProviderClaimTypes.Roles, claimMapping.Value));
else
claimActions.MapJsonKey(claimMapping.Name, claimMapping.Value);
}
}
private static bool IsRoleMapping(string name)
{
return name.Equals(ProviderClaimTypes.Roles, StringComparison.OrdinalIgnoreCase)
|| name.Equals("roles", StringComparison.OrdinalIgnoreCase)
|| name.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase);
}
private static string FindFirstAny(ClaimsIdentity identity, params string[] claimTypes)
{
foreach (var claimType in claimTypes)
{
var claim = identity.FindFirst(claimType);
if (claim != null && !String.IsNullOrWhiteSpace(claim.Value))
return claim.Value;
}
return null;
}
private static async Task<User> ProvisionUserAsync(
HttpContext httpContext,
ClaimsIdentity identity,
AuthenticationProvider authenticationProvider)
{
var userService = httpContext.RequestServices.GetService<UserService>()!;
var roleService = httpContext.RequestServices.GetService<RoleService>()!;
var userCustomFieldService = httpContext.RequestServices.GetService<UserCustomFieldService>()!;
var settings = httpContext.RequestServices.GetService<SettingsProvider<Settings.Settings>>()!.CurrentValue;
var idClaim = identity.FindFirst(ProviderClaimTypes.NameId);
var username = FindFirstAny(identity, ProviderClaimTypes.Username, "username", "name", "preferred_username");
if (idClaim == null || String.IsNullOrWhiteSpace(username))
return null;
// Never hijack an existing local account that happens to share this username.
var existing = await userService.GetAsync(username);
if (existing != null)
return null;
var user = new User
{
UserName = username,
Email = FindFirstAny(identity, ProviderClaimTypes.Email, "email"),
Alias = FindFirstAny(identity, ProviderClaimTypes.Alias, "alias"),
};
if (!settings.Server.Authentication.RequireApproval)
{
user.Approved = true;
user.ApprovedOn = DateTime.UtcNow;
}
user = await userService.AddAsync(user);
if (settings.Server.Roles.DefaultRoleId != Guid.Empty)
{
var defaultRole = await roleService.GetAsync(settings.Server.Roles.DefaultRoleId);
if (defaultRole != null)
await userService.AddToRoleAsync(user.UserName, defaultRole.Name);
}
await userCustomFieldService.AddAsync(new UserCustomField
{
UserId = user.Id,
Name = authenticationProvider.GetCustomFieldName(),
Value = idClaim.Value,
});
return user;
}
private static async Task SyncRolesAsync(HttpContext httpContext, ClaimsPrincipal principal, User user)
{
var userService = httpContext.RequestServices.GetService<UserService>()!;
var roleService = httpContext.RequestServices.GetService<RoleService>()!;
var settings = httpContext.RequestServices.GetService<SettingsProvider<Settings.Settings>>()!.CurrentValue;
var claimedRoleNames = principal
.FindAll(c => c.Type == ProviderClaimTypes.Roles || c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.Where(v => !String.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// Roles that must never be removed automatically by a provider login.
var protectedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
RoleService.AdministratorRoleName
};
if (settings.Server.Roles.DefaultRoleId != Guid.Empty)
{
var defaultRole = await roleService.GetAsync(settings.Server.Roles.DefaultRoleId);
if (defaultRole != null)
protectedRoles.Add(defaultRole.Name);
}
// Create any roles named in the claims that don't yet exist.
foreach (var roleName in claimedRoleNames)
{
if (await roleService.GetAsync(roleName) == null)
await roleService.AddAsync(new Role { Name = roleName });
}
var currentRoleNames = (await userService.GetRolesAsync(user)).Select(r => r.Name).ToList();
var rolesToAdd = claimedRoleNames
.Where(rn => !currentRoleNames.Contains(rn, StringComparer.OrdinalIgnoreCase))
.ToList();
var rolesToRemove = currentRoleNames
.Where(rn => !claimedRoleNames.Contains(rn, StringComparer.OrdinalIgnoreCase))
.Where(rn => !protectedRoles.Contains(rn))
.ToList();
if (rolesToAdd.Any())
await userService.AddToRolesAsync(user.UserName, rolesToAdd);
foreach (var roleName in rolesToRemove)
await userService.RemoveFromRole(user.UserName, roleName);
if (rolesToRemove.Any())
{
var cache = httpContext.RequestServices.GetService<IFusionCache>()!;
await cache.RemoveByTagAsync(["User/Security", "User/Roles", $"User/{user.Id}", $"Library/{user.Id}"]);
}
}
}

View file

@ -0,0 +1,27 @@
using System.Security.Claims;
namespace LANCommander.Server.Startup;
/// <summary>
/// Well-known destination claim names recognized by the external authentication
/// provider login flow. Administrators map a provider's claims to these names via the
/// provider's ClaimMappings so the values can be projected onto LANCommander user
/// fields and roles.
/// </summary>
public static class ProviderClaimTypes
{
/// <summary>External unique identifier. Required to link a provider login to a user.</summary>
public const string NameId = ClaimTypes.NameIdentifier;
/// <summary>Maps to the user's username.</summary>
public const string Username = ClaimTypes.Name;
/// <summary>Maps to the user's email address.</summary>
public const string Email = ClaimTypes.Email;
/// <summary>Maps to the user's display alias.</summary>
public const string Alias = "alias";
/// <summary>Holds one or more role names. The value(s) are used directly as role names.</summary>
public const string Roles = "role";
}

View file

@ -0,0 +1,69 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
namespace LANCommander.Server.Startup;
/// <summary>
/// A claim action that projects a provider's role/group claim onto one claim per value.
/// Unlike <c>MapJsonKey</c>, this expands JSON arrays into multiple claims and supports
/// dotted paths for nested claims (e.g. Keycloak's <c>realm_access.roles</c>).
/// </summary>
public class RolesClaimAction : ClaimAction
{
private readonly string _jsonKey;
public RolesClaimAction(string claimType, string jsonKey)
: base(claimType, ClaimValueTypes.String)
{
_jsonKey = jsonKey;
}
public override void Run(JsonElement userData, ClaimsIdentity identity, string issuer)
{
if (userData.ValueKind != JsonValueKind.Object)
return;
if (!TryResolve(userData, _jsonKey, out var element))
return;
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
AddClaim(item, identity, issuer);
}
else
{
AddClaim(element, identity, issuer);
}
}
private void AddClaim(JsonElement element, ClaimsIdentity identity, string issuer)
{
if (element.ValueKind != JsonValueKind.String)
return;
var value = element.GetString();
if (!string.IsNullOrWhiteSpace(value))
identity.AddClaim(new Claim(ClaimType, value, ValueType, issuer));
}
private static bool TryResolve(JsonElement root, string path, out JsonElement value)
{
value = root;
foreach (var segment in path.Split('.'))
{
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out var next))
{
value = default;
return false;
}
value = next;
}
return true;
}
}

View file

@ -1,18 +1,49 @@
@using LANCommander.Server.Settings.Models
@using LANCommander.Server.Startup
@inject IMessageService MessageService
@inject ILogger<ClaimMappingsEditor> Logger
<Flex Vertical Gap="@("16")">
<Alert Type="@AlertType.Info" ShowIcon="true" Message="Each mapping projects a provider claim (Value, a key in the provider's userinfo response such as preferred_username) onto a destination claim (Name) that is applied to the user on login." />
<Table DataSource="ClaimMappings" Size="TableSize.Small" HidePagination>
<PropertyColumn Property="cm => cm.Name">
<Input @bind-Value="context.Name" OnBlur="Update" />
<PropertyColumn Property="cm => cm.Value" Title="Claim">
<AutoComplete TOption="string"
Value="@context.Value"
ValueChanged="@((string value) => OnClaimChanged(context, value))"
Options="_suggestedClaims"
Backfill
Placeholder="@("e.g. preferred_username")" />
</PropertyColumn>
<PropertyColumn Property="cm => cm.Value">
<Input @bind-Value="context.Value" OnBlur="Update" />
<PropertyColumn Property="cm => cm.Name" Title="Destination">
<Input @bind-Value="context.Name" OnBlur="Update" />
</PropertyColumn>
<ActionColumn>
<Button Type="ButtonType.Text" Icon="@IconType.Outline.Close" Danger OnClick="() => Remove(context)" />
</ActionColumn>
</Table>
<Flex Justify="FlexJustify.End">
@if (_suggestedClaims.Any())
{
<Flex Vertical Gap="@("4")">
<span class="ant-typography ant-typography-secondary">Claims advertised by the provider (click to add a mapping):</span>
<Flex Wrap="FlexWrap.Wrap" Gap="@("4")">
@foreach (var claim in _suggestedClaims)
{
var alreadyMapped = ClaimMappings.Any(cm => String.Equals(cm.Value, claim, StringComparison.OrdinalIgnoreCase));
<Tag Color="@(alreadyMapped ? null : "blue")"
Style="cursor: pointer;"
OnClick="@(() => AddFromSuggestion(claim))">
@claim
</Tag>
}
</Flex>
</Flex>
}
<Flex Justify="FlexJustify.End" Gap="@("8")">
<Tooltip Title="@(String.IsNullOrWhiteSpace(ConfigurationUrl) ? "Only available for OpenID Connect providers with a configuration URL" : "Read claims and scopes from the provider's discovery document, mapping standard claims and adding base scopes automatically.")">
<Button OnClick="Discover" Loading="_discovering" Disabled="@String.IsNullOrWhiteSpace(ConfigurationUrl)">Discover</Button>
</Tooltip>
<Button Type="ButtonType.Primary" OnClick="Add">Add Claim Mapping</Button>
</Flex>
</Flex>
@ -20,13 +51,146 @@
@code {
[Parameter] public IEnumerable<ClaimMapping> Values { get; set; }
[Parameter] public EventCallback<IEnumerable<ClaimMapping>> ValuesChanged { get; set; }
[Parameter] public IEnumerable<string> Scopes { get; set; }
[Parameter] public EventCallback<IEnumerable<string>> ScopesChanged { get; set; }
[Parameter] public string ConfigurationUrl { get; set; }
List<ClaimMapping> ClaimMappings = new();
string[] _suggestedClaims = Array.Empty<string>();
bool _discovering;
protected override void OnParametersSet()
{
if (Values != null)
ClaimMappings = Values.Select(v => new ClaimMapping { Name = v.Name, Value = v.Value}).ToList();
var incoming = (Values ?? Enumerable.Empty<ClaimMapping>()).ToList();
// Only rebuild local state when the incoming values actually differ. Without
// this guard, the round-trip caused by our own ValuesChanged would clobber
// in-progress edits (the AutoComplete pushes a change on every keystroke).
var changed = incoming.Count != ClaimMappings.Count
|| incoming.Where((c, i) => c.Name != ClaimMappings[i].Name || c.Value != ClaimMappings[i].Value).Any();
if (changed)
ClaimMappings = incoming.Select(v => new ClaimMapping { Name = v.Name, Value = v.Value}).ToList();
}
async Task OnClaimChanged(ClaimMapping claimMapping, string value)
{
claimMapping.Value = value;
await Update();
}
async Task AddFromSuggestion(string claim)
{
ClaimMappings.Add(new ClaimMapping { Value = claim });
await Update();
}
// Standard OpenID Connect claims mapped to LANCommander destinations, in priority
// order. For each destination, the first advertised source claim that isn't already
// mapped is used. Processed top-to-bottom so earlier destinations claim shared
// source names (e.g. "name") before later ones.
static readonly (string Destination, string[] Sources)[] StandardClaimMap =
{
(ProviderClaimTypes.NameId, new[] { "sub" }),
(ProviderClaimTypes.Email, new[] { "email" }),
(ProviderClaimTypes.Username, new[] { "preferred_username", "name", "username" }),
(ProviderClaimTypes.Alias, new[] { "nickname", "name" }),
(ProviderClaimTypes.Roles, new[] { "roles", "groups" }),
};
// Standard scopes to add when the provider advertises them. "openid" is required for
// the OpenID Connect flow, so it's always added regardless of what's advertised.
static readonly string[] StandardScopes = { "openid", "profile", "email", "roles", "groups" };
async Task Discover()
{
_discovering = true;
try
{
var (claims, scopes) = await AuthenticationService.GetDiscoveryMetadataAsync(ConfigurationUrl);
_suggestedClaims = claims.ToArray();
var autoMapped = AutoMapStandardClaims();
if (autoMapped > 0)
await Update();
var addedScopes = await AutoAddStandardScopes(scopes);
if (_suggestedClaims.Any() || scopes.Any())
MessageService.Success($"Mapped {autoMapped} standard claim(s) and added {addedScopes} scope(s) from the provider's configuration.");
else
MessageService.Info("The provider's configuration did not advertise any claims or scopes. You can still enter them manually.");
}
catch (Exception ex)
{
MessageService.Error("Could not read the provider's configuration.");
Logger.LogError(ex, "Discovery failed for {ConfigurationUrl}", ConfigurationUrl);
}
finally
{
_discovering = false;
}
}
async Task<int> AutoAddStandardScopes(IReadOnlyCollection<string> advertisedScopes)
{
var current = (Scopes ?? Enumerable.Empty<string>()).ToList();
var existing = new HashSet<string>(current, StringComparer.OrdinalIgnoreCase);
var advertised = new HashSet<string>(advertisedScopes, StringComparer.OrdinalIgnoreCase);
var added = 0;
foreach (var scope in StandardScopes)
{
// openid is mandatory for OIDC; the rest are only added if advertised.
if (scope != "openid" && !advertised.Contains(scope))
continue;
if (!existing.Add(scope))
continue;
current.Add(scope);
added++;
}
if (added > 0 && ScopesChanged.HasDelegate)
await ScopesChanged.InvokeAsync(current);
return added;
}
int AutoMapStandardClaims()
{
var added = 0;
var advertised = new HashSet<string>(_suggestedClaims, StringComparer.OrdinalIgnoreCase);
foreach (var (destination, sources) in StandardClaimMap)
{
// Leave any destination the admin has already mapped untouched.
if (ClaimMappings.Any(cm => String.Equals(cm.Name, destination, StringComparison.OrdinalIgnoreCase)))
continue;
foreach (var source in sources)
{
if (!advertised.Contains(source))
continue;
// Don't reuse a source claim that's already mapped to something else.
if (ClaimMappings.Any(cm => String.Equals(cm.Value, source, StringComparison.OrdinalIgnoreCase)))
break;
ClaimMappings.Add(new ClaimMapping { Name = destination, Value = source });
added++;
break;
}
}
return added;
}
async Task Add()

View file

@ -105,7 +105,7 @@
</FormItem>
<FormItem Label="Claim Mappings">
<ClaimMappingsEditor @bind-Values="context.ClaimMappings"/>
<ClaimMappingsEditor @bind-Values="context.ClaimMappings" @bind-Scopes="context.Scopes" ConfigurationUrl="@context.ConfigurationUrl"/>
</FormItem>
</Form>
</ChildContent>

View file

@ -6,8 +6,8 @@
@foreach (var scope in Scopes)
{
<Flex>
<Input @bind-Value="scope.Name" OnBlur="Update" />
<Flex @key="scope">
<Input Value="@scope.Name" ValueChanged="@((string value) => OnScopeChanged(scope, value))" BindOnInput />
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Close" Danger OnClick="() => Remove(scope)" />
</Flex>
}
@ -25,29 +25,39 @@
protected override void OnParametersSet()
{
if (Values != null)
Scopes = Values.Select(v => new ExternalProviderScope { Name = v }).ToList();
var incoming = (Values ?? Enumerable.Empty<string>()).ToList();
// Only rebuild local state when the incoming values actually differ. Without
// this guard, the round-trip caused by our own ValuesChanged would clobber
// in-progress edits on every re-render.
if (!incoming.SequenceEqual(Scopes.Select(s => s.Name)))
Scopes = incoming.Select(v => new ExternalProviderScope { Name = v }).ToList();
}
async Task OnScopeChanged(ExternalProviderScope scope, string value)
{
scope.Name = value;
await Update();
}
async Task Add()
{
Scopes.Add(new ExternalProviderScope());
if (ValuesChanged.HasDelegate)
await ValuesChanged.InvokeAsync(Scopes.Select(s => s.Name));
await Update();
}
async Task Remove(ExternalProviderScope scope)
{
Scopes.Remove(scope);
if (ValuesChanged.HasDelegate)
await ValuesChanged.InvokeAsync(Scopes.Select(s => s.Name));
await Update();
}
async Task Update()
{
if (ValuesChanged.HasDelegate)
await ValuesChanged.InvokeAsync(Scopes.Select(s => s.Name));
await ValuesChanged.InvokeAsync(Scopes.Select(s => s.Name).ToList());
}
}