diff --git a/LANCommander.Server.Services/AuthenticationService.cs b/LANCommander.Server.Services/AuthenticationService.cs
index 666b6c84..56df40fa 100644
--- a/LANCommander.Server.Services/AuthenticationService.cs
+++ b/LANCommander.Server.Services/AuthenticationService.cs
@@ -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;
}
+
+ ///
+ /// Reads the provider's OpenID Connect discovery document (the well-known
+ /// configuration URL) and returns the advertised claims_supported and
+ /// scopes_supported 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.
+ ///
+ public static async Task<(IReadOnlyCollection Claims, IReadOnlyCollection Scopes)> GetDiscoveryMetadataAsync(string configurationUrl)
+ {
+ if (String.IsNullOrWhiteSpace(configurationUrl))
+ return (Array.Empty(), Array.Empty());
+
+ 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 ReadStringArray(JsonElement root, string propertyName)
+ {
+ if (!root.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array)
+ return Array.Empty();
+
+ 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();
+ }
}
}
diff --git a/LANCommander.Server/Startup/Authentication.cs b/LANCommander.Server/Startup/Authentication.cs
index b6628c1e..aa2b002e 100644
--- a/LANCommander.Server/Startup/Authentication.cs
+++ b/LANCommander.Server/Startup/Authentication.cs
@@ -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 ProvisionUserAsync(
+ HttpContext httpContext,
+ ClaimsIdentity identity,
+ AuthenticationProvider authenticationProvider)
+ {
+ var userService = httpContext.RequestServices.GetService()!;
+ var roleService = httpContext.RequestServices.GetService()!;
+ var userCustomFieldService = httpContext.RequestServices.GetService()!;
+ var settings = httpContext.RequestServices.GetService>()!.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()!;
+ var roleService = httpContext.RequestServices.GetService()!;
+ var settings = httpContext.RequestServices.GetService>()!.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(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()!;
+
+ await cache.RemoveByTagAsync(["User/Security", "User/Roles", $"User/{user.Id}", $"Library/{user.Id}"]);
+ }
+ }
}
\ No newline at end of file
diff --git a/LANCommander.Server/Startup/ProviderClaimTypes.cs b/LANCommander.Server/Startup/ProviderClaimTypes.cs
new file mode 100644
index 00000000..74501f53
--- /dev/null
+++ b/LANCommander.Server/Startup/ProviderClaimTypes.cs
@@ -0,0 +1,27 @@
+using System.Security.Claims;
+
+namespace LANCommander.Server.Startup;
+
+///
+/// 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.
+///
+public static class ProviderClaimTypes
+{
+ /// External unique identifier. Required to link a provider login to a user.
+ public const string NameId = ClaimTypes.NameIdentifier;
+
+ /// Maps to the user's username.
+ public const string Username = ClaimTypes.Name;
+
+ /// Maps to the user's email address.
+ public const string Email = ClaimTypes.Email;
+
+ /// Maps to the user's display alias.
+ public const string Alias = "alias";
+
+ /// Holds one or more role names. The value(s) are used directly as role names.
+ public const string Roles = "role";
+}
diff --git a/LANCommander.Server/Startup/RolesClaimAction.cs b/LANCommander.Server/Startup/RolesClaimAction.cs
new file mode 100644
index 00000000..0c1d8e37
--- /dev/null
+++ b/LANCommander.Server/Startup/RolesClaimAction.cs
@@ -0,0 +1,69 @@
+using System.Security.Claims;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication.OAuth.Claims;
+
+namespace LANCommander.Server.Startup;
+
+///
+/// A claim action that projects a provider's role/group claim onto one claim per value.
+/// Unlike MapJsonKey, this expands JSON arrays into multiple claims and supports
+/// dotted paths for nested claims (e.g. Keycloak's realm_access.roles).
+///
+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;
+ }
+}
diff --git a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ClaimMappingsEditor.razor b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ClaimMappingsEditor.razor
index eeb4497b..3079fd2a 100644
--- a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ClaimMappingsEditor.razor
+++ b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ClaimMappingsEditor.razor
@@ -1,18 +1,49 @@
@using LANCommander.Server.Settings.Models
+@using LANCommander.Server.Startup
+@inject IMessageService MessageService
+@inject ILogger Logger
+
+
-
+ @if (_suggestedClaims.Any())
+ {
+
+ Claims advertised by the provider (click to add a mapping):
+
+ @foreach (var claim in _suggestedClaims)
+ {
+ var alreadyMapped = ClaimMappings.Any(cm => String.Equals(cm.Value, claim, StringComparison.OrdinalIgnoreCase));
+
+ @claim
+
+ }
+
+
+ }
+
+
+
+
+
@@ -20,13 +51,146 @@
@code {
[Parameter] public IEnumerable Values { get; set; }
[Parameter] public EventCallback> ValuesChanged { get; set; }
+ [Parameter] public IEnumerable Scopes { get; set; }
+ [Parameter] public EventCallback> ScopesChanged { get; set; }
+ [Parameter] public string ConfigurationUrl { get; set; }
List ClaimMappings = new();
+ string[] _suggestedClaims = Array.Empty();
+ 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()).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 AutoAddStandardScopes(IReadOnlyCollection advertisedScopes)
+ {
+ var current = (Scopes ?? Enumerable.Empty()).ToList();
+ var existing = new HashSet(current, StringComparer.OrdinalIgnoreCase);
+ var advertised = new HashSet(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(_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()
diff --git a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ExternalProviderEditor.razor b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ExternalProviderEditor.razor
index b0ec4088..e849a61a 100644
--- a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ExternalProviderEditor.razor
+++ b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ExternalProviderEditor.razor
@@ -105,7 +105,7 @@
-
+
diff --git a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ScopesEditor.razor b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ScopesEditor.razor
index d6879955..c6399756 100644
--- a/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ScopesEditor.razor
+++ b/LANCommander.Server/UI/Pages/Settings/Authentication/Components/ScopesEditor.razor
@@ -6,8 +6,8 @@
@foreach (var scope in Scopes)
{
-
-
+
+
}
@@ -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()).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());
}
}