< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ExternalIdentityLinkUser
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 265
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Id()100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs

#LineLine coverage
 1using System.Security.Claims;
 2using Elsa.Common;
 3using Elsa.Common.Models;
 4using Elsa.ExternalAuthentication.Contracts;
 5using Elsa.ExternalAuthentication.Models;
 6using Elsa.ExternalAuthentication.Notifications;
 7using Elsa.Identity.Contracts;
 8using Elsa.Identity.Models;
 9using Elsa.Mediator.Contracts;
 10using Microsoft.Extensions.DependencyInjection;
 11
 12namespace Elsa.ExternalAuthentication.Services;
 13
 14/// <summary>
 15/// Coordinates tenant-safe administrator operations over external identity links.
 16/// </summary>
 17public sealed class ExternalIdentityLinkManagementService(
 18    IExternalIdentityProvisioner provisioner,
 19    IExternalIdentityLinkManagementStore links,
 20    IIdentityProviderConnectionRegistry connections,
 21    IUserStore users,
 22    ISystemClock clock,
 23    IServiceProvider services)
 24{
 25    public async ValueTask<Page<ExternalIdentityLink>> ListAsync(string tenantId, ExternalIdentityLinkFilter filter, Can
 26    {
 27        ValidateTargetTenant(tenantId);
 28        ArgumentNullException.ThrowIfNull(filter);
 29        filter.TenantId = tenantId;
 30        return await links.FindAsync(filter, cancellationToken);
 31    }
 32
 33    public async ValueTask<Page<ExternalIdentityLinkUser>> FindUsersAsync(string tenantId, string? search, CancellationT
 34    {
 35        ValidateTargetTenant(tenantId);
 36        var normalizedSearch = search?.Trim();
 37        var usersInTenant = (await users.FindManyAsync(new()
 38                { TenantId = tenantId }, cancellationToken))
 39            .Where(x => string.IsNullOrEmpty(normalizedSearch) || x.Name.Contains(normalizedSearch, StringComparison.Ord
 40            .OrderBy(x => x.Name, StringComparer.Ordinal)
 41            .ThenBy(x => x.Id, StringComparer.Ordinal)
 42            .Select(x => new ExternalIdentityLinkUser(x.Id, x.Name))
 43            .ToArray();
 44        return Page.Of<ExternalIdentityLinkUser>(usersInTenant, usersInTenant.Length);
 45    }
 46
 47    public async ValueTask<ExternalIdentityLinkPrelinkResult> PrelinkAsync(string tenantId, string userId, string connec
 48    {
 49        ValidateTargetTenant(tenantId);
 50        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 51        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 52        var normalizedIssuer = NormalizeIssuer(issuer);
 53        var normalizedSubject = NormalizeSubject(subject);
 54
 55        var user = await users.FindAsync(new()
 56            { Id = userId }, cancellationToken);
 57        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 58            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 59
 60        // Manual administration may target disabled or invalid connections, but never archived, shadowed, or cross-tena
 61        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 62        if (connection is null)
 63            return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound();
 64
 65        try
 66        {
 67            var result = await provisioner.CreateLinkOrGetExistingAsync(new(
 68                tenantId,
 69                ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 70                new(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<string>>()),
 71                null,
 72                user.Id), cancellationToken);
 73
 74            if (!string.Equals(result.UserId, user.Id, StringComparison.Ordinal))
 75                return new ExternalIdentityLinkPrelinkResult.Conflict(result.Link);
 76
 77            if (result.WasLinkCreated)
 78                await PublishAsync(actor, result.Link, "prelinked", cancellationToken);
 79            return new ExternalIdentityLinkPrelinkResult.Success(result.Link, result.WasLinkCreated);
 80        }
 81        catch (InvalidOperationException)
 82        {
 83            // The provisioner protects its own user/tenant invariant. Do not turn a malformed or cross-tenant target in
 84            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 85        }
 86    }
 87
 88    public async ValueTask<bool> UnlinkAsync(string tenantId, string linkId, ClaimsPrincipal actor, CancellationToken ca
 89    {
 90        ValidateTargetTenant(tenantId);
 91        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 92        var existing = await links.FindAsync(new()
 93            { TenantId = tenantId }, cancellationToken);
 94        var link = existing.Items.FirstOrDefault(x => string.Equals(x.Id, linkId, StringComparison.Ordinal));
 95        if (link is null || !await links.DeleteAsync(tenantId, linkId, cancellationToken))
 96            return false;
 97
 98        await PublishAsync(actor, link, "unlinked", cancellationToken);
 99        return true;
 100    }
 101
 102    public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(
 103        string tenantId,
 104        string linkId,
 105        string userId,
 106        string connectionKey,
 107        string issuer,
 108        string subject,
 109        ClaimsPrincipal actor,
 110        CancellationToken cancellationToken = default)
 111    {
 112        ValidateTargetTenant(tenantId);
 113        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 114        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 115        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 116        var normalizedIssuer = NormalizeIssuer(issuer);
 117        var normalizedSubject = NormalizeSubject(subject);
 118
 119        var user = await users.FindAsync(new()
 120            { Id = userId }, cancellationToken);
 121        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 122            return new ExternalIdentityLinkReplaceResult.NotFound();
 123
 124        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 125        if (connection is null)
 126            return new ExternalIdentityLinkReplaceResult.NotFound();
 127
 128        ExternalIdentityLinkReplaceResult result;
 129        try
 130        {
 131            result = await provisioner.ReplaceAsync(
 132                new(
 133                    tenantId,
 134                    linkId,
 135                    user.Id,
 136                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 137                    new(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<string>>())),
 138                cancellationToken);
 139        }
 140        catch (InvalidOperationException)
 141        {
 142            return new ExternalIdentityLinkReplaceResult.NotFound();
 143        }
 144
 145        switch (result)
 146        {
 147            case ExternalIdentityLinkReplaceResult.Success success:
 148                await PublishReplacementAsync(actor, success.OldLink, success.NewLink, null, user.Id, success.NewLink.Co
 149                break;
 150            case ExternalIdentityLinkReplaceResult.Conflict conflict:
 151                await PublishReplacementAsync(
 152                    actor,
 153                    conflict.OldLink,
 154                    null,
 155                    conflict.ConflictingLink,
 156                    user.Id,
 157                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 158                    SecurityEventOutcome.Failed,
 159                    cancellationToken);
 160                break;
 161        }
 162
 163        return result;
 164    }
 165
 166    private async ValueTask<EffectiveIdentityProviderConnection?> FindManageableConnectionAsync(string tenantId, string 
 167    {
 168        var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(connectionKey);
 169        var registry = await connections.GetAsync(tenantId, cancellationToken);
 170        return registry.Connections.FirstOrDefault(x =>
 171            x is { IsShadowed: false, Connection.ArchivedAt: null } &&
 172            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparison.O
 173    }
 174
 175    private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, Cancellatio
 176    {
 177        var sender = services.GetService<INotificationSender>();
 178        if (sender is null)
 179            return;
 180
 181        var context = new SecurityEventContext(
 182            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 183            link.TenantId,
 184            link.ConnectionKey,
 185            link.UserId,
 186            clock.UtcNow,
 187            SecurityEventOutcome.Succeeded,
 188            Guid.NewGuid().ToString("N"),
 189            "External identity link management operation completed.");
 190        await sender.SendAsync(new ExternalIdentityLinkChanged(context, operation, link.Id), cancellationToken);
 191    }
 192
 193    private async ValueTask PublishReplacementAsync(
 194        ClaimsPrincipal actor,
 195        ExternalIdentityLink oldLink,
 196        ExternalIdentityLink? newLink,
 197        ExternalIdentityLink? conflictingLink,
 198        string targetUserId,
 199        string targetConnectionKey,
 200        SecurityEventOutcome outcome,
 201        CancellationToken cancellationToken)
 202    {
 203        var sender = services.GetService<INotificationSender>();
 204        if (sender is null)
 205            return;
 206
 207        var context = new SecurityEventContext(
 208            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 209            oldLink.TenantId,
 210            targetConnectionKey,
 211            targetUserId,
 212            clock.UtcNow,
 213            outcome,
 214            Guid.NewGuid().ToString("N"),
 215            outcome == SecurityEventOutcome.Succeeded
 216                ? "External identity link replacement completed."
 217                : "External identity link replacement was rejected because the identity is already linked.");
 218        await sender.SendAsync(
 219            new ExternalIdentityLinkReplaced(
 220                context,
 221                oldLink.Id,
 222                newLink?.Id,
 223                oldLink.UserId,
 224                targetUserId,
 225                oldLink.ConnectionKey,
 226                targetConnectionKey,
 227                conflictingLink?.Id,
 228                conflictingLink?.UserId,
 229                conflictingLink?.ConnectionKey),
 230            cancellationToken);
 231    }
 232
 233    private static string NormalizeIssuer(string issuer)
 234    {
 235        if (!Uri.TryCreate(issuer?.Trim(), UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHtt
 236            throw new ArgumentException("The issuer must be an absolute HTTPS URI without a query or fragment.", nameof(
 237        return uri.GetComponents(UriComponents.AbsoluteUri, UriFormat.UriEscaped).TrimEnd('/');
 238    }
 239
 240    private static string NormalizeSubject(string subject)
 241    {
 242        var normalized = subject?.Trim();
 243        if (string.IsNullOrEmpty(normalized) || normalized.Length > 4096)
 244            throw new ArgumentException("The subject is required and must not exceed 4096 characters.", nameof(subject))
 245        return normalized;
 246    }
 247
 248    private static void ValidateTargetTenant(string tenantId)
 249    {
 250        ArgumentNullException.ThrowIfNull(tenantId);
 251        if (string.Equals(tenantId, ConnectionScope.HostTenantId, StringComparison.Ordinal))
 252            throw new ArgumentException("External identity links require a resolved tenant.", nameof(tenantId));
 253    }
 254}
 255
 14256public sealed record ExternalIdentityLinkUser(string Id, string DisplayName);
 257
 258public abstract record ExternalIdentityLinkPrelinkResult
 259{
 260    private ExternalIdentityLinkPrelinkResult() { }
 261    public sealed record Success(ExternalIdentityLink Link, bool WasCreated) : ExternalIdentityLinkPrelinkResult;
 262    public sealed record Conflict(ExternalIdentityLink Link) : ExternalIdentityLinkPrelinkResult;
 263    public sealed record UserNotFound : ExternalIdentityLinkPrelinkResult;
 264    public sealed record ConnectionNotFound : ExternalIdentityLinkPrelinkResult;
 265}

Methods/Properties

get_Id()