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) { - - + +