< Summary

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

Methods/Properties

.ctor()
get_Link()
get_Link()