< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ExternalIdentityLinkManagementService
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs
Line coverage
93%
Covered lines: 142
Uncovered lines: 10
Coverable lines: 152
Total lines: 262
Line coverage: 93.4%
Branch coverage
82%
Covered branches: 56
Total branches: 68
Branch coverage: 82.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ListAsync()100%11100%
FindUsersAsync()50%22100%
PrelinkAsync()100%101092%
UnlinkAsync()75%4487.5%
ReplaceAsync()80%101091.66%
FindManageableConnectionAsync()100%11100%
PublishAsync()75%4492.85%
PublishReplacementAsync()92.85%141496.42%
NormalizeIssuer(...)90%1010100%
NormalizeSubject(...)50%7675%
ValidateTargetTenant(...)50%2275%

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>
 7217public sealed class ExternalIdentityLinkManagementService(
 7218    IExternalIdentityProvisioner provisioner,
 7219    IExternalIdentityLinkManagementStore links,
 7220    IIdentityProviderConnectionRegistry connections,
 7221    IUserStore users,
 7222    ISystemClock clock,
 7223    IServiceProvider services)
 24{
 25    public async ValueTask<Page<ExternalIdentityLink>> ListAsync(string tenantId, ExternalIdentityLinkFilter filter, Can
 26    {
 1127        ValidateTargetTenant(tenantId);
 1128        ArgumentNullException.ThrowIfNull(filter);
 1129        filter.TenantId = tenantId;
 1130        return await links.FindAsync(filter, cancellationToken);
 1131    }
 32
 33    public async ValueTask<Page<ExternalIdentityLinkUser>> FindUsersAsync(string tenantId, string? search, CancellationT
 34    {
 235        ValidateTargetTenant(tenantId);
 236        var normalizedSearch = search?.Trim();
 237        var usersInTenant = (await users.FindManyAsync(new UserFilter { TenantId = tenantId }, cancellationToken))
 438            .Where(x => string.IsNullOrEmpty(normalizedSearch) || x.Name.Contains(normalizedSearch, StringComparison.Ord
 439            .OrderBy(x => x.Name, StringComparer.Ordinal)
 440            .ThenBy(x => x.Id, StringComparer.Ordinal)
 441            .Select(x => new ExternalIdentityLinkUser(x.Id, x.Name))
 242            .ToArray();
 243        return Page.Of<ExternalIdentityLinkUser>(usersInTenant, usersInTenant.Length);
 244    }
 45
 46    public async ValueTask<ExternalIdentityLinkPrelinkResult> PrelinkAsync(string tenantId, string userId, string connec
 47    {
 3348        ValidateTargetTenant(tenantId);
 3349        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 3350        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 3351        var normalizedIssuer = NormalizeIssuer(issuer);
 3352        var normalizedSubject = NormalizeSubject(subject);
 53
 3354        var user = await users.FindAsync(new UserFilter { Id = userId }, cancellationToken);
 3355        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 156            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 57
 58        // Manual administration may target disabled or invalid connections, but never archived, shadowed, or cross-tena
 3259        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 3260        if (connection is null)
 261            return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound();
 62
 63        try
 64        {
 3065            var result = await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest(
 3066                tenantId,
 3067                ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 3068                new ExternalIdentity(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<str
 3069                null,
 3070                user.Id), cancellationToken);
 71
 3072            if (!string.Equals(result.UserId, user.Id, StringComparison.Ordinal))
 173                return new ExternalIdentityLinkPrelinkResult.Conflict(result.Link);
 74
 2975            if (result.WasLinkCreated)
 1476                await PublishAsync(actor, result.Link, "prelinked", cancellationToken);
 2977            return new ExternalIdentityLinkPrelinkResult.Success(result.Link, result.WasLinkCreated);
 78        }
 079        catch (InvalidOperationException)
 80        {
 81            // The provisioner protects its own user/tenant invariant. Do not turn a malformed or cross-tenant target in
 082            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 83        }
 3384    }
 85
 86    public async ValueTask<bool> UnlinkAsync(string tenantId, string linkId, ClaimsPrincipal actor, CancellationToken ca
 87    {
 188        ValidateTargetTenant(tenantId);
 189        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 190        var existing = await links.FindAsync(new ExternalIdentityLinkFilter { TenantId = tenantId }, cancellationToken);
 291        var link = existing.Items.FirstOrDefault(x => string.Equals(x.Id, linkId, StringComparison.Ordinal));
 192        if (link is null || !await links.DeleteAsync(tenantId, linkId, cancellationToken))
 093            return false;
 94
 195        await PublishAsync(actor, link, "unlinked", cancellationToken);
 196        return true;
 197    }
 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    {
 9109        ValidateTargetTenant(tenantId);
 9110        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 9111        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 9112        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 9113        var normalizedIssuer = NormalizeIssuer(issuer);
 8114        var normalizedSubject = NormalizeSubject(subject);
 115
 8116        var user = await users.FindAsync(new UserFilter { Id = userId }, cancellationToken);
 8117        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 0118            return new ExternalIdentityLinkReplaceResult.NotFound();
 119
 8120        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 8121        if (connection is null)
 1122            return new ExternalIdentityLinkReplaceResult.NotFound();
 123
 124        ExternalIdentityLinkReplaceResult result;
 125        try
 126        {
 7127            result = await provisioner.ReplaceAsync(
 7128                new ExternalIdentityLinkReplaceRequest(
 7129                    tenantId,
 7130                    linkId,
 7131                    user.Id,
 7132                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 7133                    new ExternalIdentity(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection
 7134                cancellationToken);
 7135        }
 0136        catch (InvalidOperationException)
 137        {
 0138            return new ExternalIdentityLinkReplaceResult.NotFound();
 139        }
 140
 141        switch (result)
 142        {
 143            case ExternalIdentityLinkReplaceResult.Success success:
 4144                await PublishReplacementAsync(actor, success.OldLink, success.NewLink, null, user.Id, success.NewLink.Co
 4145                break;
 146            case ExternalIdentityLinkReplaceResult.Conflict conflict:
 2147                await PublishReplacementAsync(
 2148                    actor,
 2149                    conflict.OldLink,
 2150                    null,
 2151                    conflict.ConflictingLink,
 2152                    user.Id,
 2153                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 2154                    SecurityEventOutcome.Failed,
 2155                    cancellationToken);
 156                break;
 157        }
 158
 7159        return result;
 8160    }
 161
 162    private async ValueTask<EffectiveIdentityProviderConnection?> FindManageableConnectionAsync(string tenantId, string 
 163    {
 40164        var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(connectionKey);
 40165        var registry = await connections.GetAsync(tenantId, cancellationToken);
 40166        return registry.Connections.FirstOrDefault(x =>
 81167            !x.IsShadowed &&
 81168            !x.Connection.ArchivedAt.HasValue &&
 81169            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparison.O
 40170    }
 171
 172    private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, Cancellatio
 173    {
 15174        var sender = services.GetService<INotificationSender>();
 15175        if (sender is null)
 0176            return;
 177
 15178        var context = new SecurityEventContext(
 15179            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 15180            link.TenantId,
 15181            link.ConnectionKey,
 15182            link.UserId,
 15183            clock.UtcNow,
 15184            SecurityEventOutcome.Succeeded,
 15185            Guid.NewGuid().ToString("N"),
 15186            "External identity link management operation completed.");
 15187        await sender.SendAsync(new ExternalIdentityLinkChanged(context, operation, link.Id), cancellationToken);
 15188    }
 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    {
 6200        var sender = services.GetService<INotificationSender>();
 6201        if (sender is null)
 0202            return;
 203
 6204        var context = new SecurityEventContext(
 6205            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 6206            oldLink.TenantId,
 6207            targetConnectionKey,
 6208            targetUserId,
 6209            clock.UtcNow,
 6210            outcome,
 6211            Guid.NewGuid().ToString("N"),
 6212            outcome == SecurityEventOutcome.Succeeded
 6213                ? "External identity link replacement completed."
 6214                : "External identity link replacement was rejected because the identity is already linked.");
 6215        await sender.SendAsync(
 6216            new ExternalIdentityLinkReplaced(
 6217                context,
 6218                oldLink.Id,
 6219                newLink?.Id,
 6220                oldLink.UserId,
 6221                targetUserId,
 6222                oldLink.ConnectionKey,
 6223                targetConnectionKey,
 6224                conflictingLink?.Id,
 6225                conflictingLink?.UserId,
 6226                conflictingLink?.ConnectionKey),
 6227            cancellationToken);
 6228    }
 229
 230    private static string NormalizeIssuer(string issuer)
 231    {
 42232        if (!Uri.TryCreate(issuer?.Trim(), UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHtt
 1233            throw new ArgumentException("The issuer must be an absolute HTTPS URI without a query or fragment.", nameof(
 41234        return uri.GetComponents(UriComponents.AbsoluteUri, UriFormat.UriEscaped).TrimEnd('/');
 235    }
 236
 237    private static string NormalizeSubject(string subject)
 238    {
 41239        var normalized = subject?.Trim();
 41240        if (string.IsNullOrEmpty(normalized) || normalized.Length > 4096)
 0241            throw new ArgumentException("The subject is required and must not exceed 4096 characters.", nameof(subject))
 41242        return normalized;
 243    }
 244
 245    private static void ValidateTargetTenant(string tenantId)
 246    {
 56247        ArgumentNullException.ThrowIfNull(tenantId);
 56248        if (string.Equals(tenantId, ConnectionScope.HostTenantId, StringComparison.Ordinal))
 0249            throw new ArgumentException("External identity links require a resolved tenant.", nameof(tenantId));
 56250    }
 251}
 252
 253public sealed record ExternalIdentityLinkUser(string Id, string DisplayName);
 254
 255public abstract record ExternalIdentityLinkPrelinkResult
 256{
 257    private ExternalIdentityLinkPrelinkResult() { }
 258    public sealed record Success(ExternalIdentityLink Link, bool WasCreated) : ExternalIdentityLinkPrelinkResult;
 259    public sealed record Conflict(ExternalIdentityLink Link) : ExternalIdentityLinkPrelinkResult;
 260    public sealed record UserNotFound : ExternalIdentityLinkPrelinkResult;
 261    public sealed record ConnectionNotFound : ExternalIdentityLinkPrelinkResult;
 262}