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:
parent
0f03461f53
commit
f1fa66b632
7 changed files with 510 additions and 38 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
LANCommander.Server/Startup/ProviderClaimTypes.cs
Normal file
27
LANCommander.Server/Startup/ProviderClaimTypes.cs
Normal 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";
|
||||
}
|
||||
69
LANCommander.Server/Startup/RolesClaimAction.cs
Normal file
69
LANCommander.Server/Startup/RolesClaimAction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue