< 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: 145
Uncovered lines: 10
Coverable lines: 155
Total lines: 265
Line coverage: 93.5%
Branch coverage
83%
Covered branches: 60
Total branches: 72
Branch coverage: 83.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.3%
UnlinkAsync()75%4488.88%
ReplaceAsync()80%101091.89%
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()
 238                { TenantId = tenantId }, cancellationToken))
 439            .Where(x => string.IsNullOrEmpty(normalizedSearch) || x.Name.Contains(normalizedSearch, StringComparison.Ord
 440            .OrderBy(x => x.Name, StringComparer.Ordinal)
 441            .ThenBy(x => x.Id, StringComparer.Ordinal)
 442            .Select(x => new ExternalIdentityLinkUser(x.Id, x.Name))
 243            .ToArray();
 244        return Page.Of<ExternalIdentityLinkUser>(usersInTenant, usersInTenant.Length);
 245    }
 46
 47    public async ValueTask<ExternalIdentityLinkPrelinkResult> PrelinkAsync(string tenantId, string userId, string connec
 48    {
 3349        ValidateTargetTenant(tenantId);
 3350        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 3351        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 3352        var normalizedIssuer = NormalizeIssuer(issuer);
 3353        var normalizedSubject = NormalizeSubject(subject);
 54
 3355        var user = await users.FindAsync(new()
 3356            { Id = userId }, cancellationToken);
 3357        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 158            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 59
 60        // Manual administration may target disabled or invalid connections, but never archived, shadowed, or cross-tena
 3261        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 3262        if (connection is null)
 263            return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound();
 64
 65        try
 66        {
 3067            var result = await provisioner.CreateLinkOrGetExistingAsync(new(
 3068                tenantId,
 3069                ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 3070                new(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<string>>()),
 3071                null,
 3072                user.Id), cancellationToken);
 73
 3074            if (!string.Equals(result.UserId, user.Id, StringComparison.Ordinal))
 175                return new ExternalIdentityLinkPrelinkResult.Conflict(result.Link);
 76
 2977            if (result.WasLinkCreated)
 1478                await PublishAsync(actor, result.Link, "prelinked", cancellationToken);
 2979            return new ExternalIdentityLinkPrelinkResult.Success(result.Link, result.WasLinkCreated);
 80        }
 081        catch (InvalidOperationException)
 82        {
 83            // The provisioner protects its own user/tenant invariant. Do not turn a malformed or cross-tenant target in
 084            return new ExternalIdentityLinkPrelinkResult.UserNotFound();
 85        }
 3386    }
 87
 88    public async ValueTask<bool> UnlinkAsync(string tenantId, string linkId, ClaimsPrincipal actor, CancellationToken ca
 89    {
 190        ValidateTargetTenant(tenantId);
 191        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 192        var existing = await links.FindAsync(new()
 193            { TenantId = tenantId }, cancellationToken);
 294        var link = existing.Items.FirstOrDefault(x => string.Equals(x.Id, linkId, StringComparison.Ordinal));
 195        if (link is null || !await links.DeleteAsync(tenantId, linkId, cancellationToken))
 096            return false;
 97
 198        await PublishAsync(actor, link, "unlinked", cancellationToken);
 199        return true;
 1100    }
 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    {
 9112        ValidateTargetTenant(tenantId);
 9113        ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
 9114        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
 9115        ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
 9116        var normalizedIssuer = NormalizeIssuer(issuer);
 8117        var normalizedSubject = NormalizeSubject(subject);
 118
 8119        var user = await users.FindAsync(new()
 8120            { Id = userId }, cancellationToken);
 8121        if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
 0122            return new ExternalIdentityLinkReplaceResult.NotFound();
 123
 8124        var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
 8125        if (connection is null)
 1126            return new ExternalIdentityLinkReplaceResult.NotFound();
 127
 128        ExternalIdentityLinkReplaceResult result;
 129        try
 130        {
 7131            result = await provisioner.ReplaceAsync(
 7132                new(
 7133                    tenantId,
 7134                    linkId,
 7135                    user.Id,
 7136                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 7137                    new(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<string>>())),
 7138                cancellationToken);
 7139        }
 0140        catch (InvalidOperationException)
 141        {
 0142            return new ExternalIdentityLinkReplaceResult.NotFound();
 143        }
 144
 145        switch (result)
 146        {
 147            case ExternalIdentityLinkReplaceResult.Success success:
 4148                await PublishReplacementAsync(actor, success.OldLink, success.NewLink, null, user.Id, success.NewLink.Co
 4149                break;
 150            case ExternalIdentityLinkReplaceResult.Conflict conflict:
 2151                await PublishReplacementAsync(
 2152                    actor,
 2153                    conflict.OldLink,
 2154                    null,
 2155                    conflict.ConflictingLink,
 2156                    user.Id,
 2157                    ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 2158                    SecurityEventOutcome.Failed,
 2159                    cancellationToken);
 160                break;
 161        }
 162
 7163        return result;
 8164    }
 165
 166    private async ValueTask<EffectiveIdentityProviderConnection?> FindManageableConnectionAsync(string tenantId, string 
 167    {
 40168        var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(connectionKey);
 40169        var registry = await connections.GetAsync(tenantId, cancellationToken);
 40170        return registry.Connections.FirstOrDefault(x =>
 81171            x is { IsShadowed: false, Connection.ArchivedAt: null } &&
 81172            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparison.O
 40173    }
 174
 175    private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, Cancellatio
 176    {
 15177        var sender = services.GetService<INotificationSender>();
 15178        if (sender is null)
 0179            return;
 180
 15181        var context = new SecurityEventContext(
 15182            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 15183            link.TenantId,
 15184            link.ConnectionKey,
 15185            link.UserId,
 15186            clock.UtcNow,
 15187            SecurityEventOutcome.Succeeded,
 15188            Guid.NewGuid().ToString("N"),
 15189            "External identity link management operation completed.");
 15190        await sender.SendAsync(new ExternalIdentityLinkChanged(context, operation, link.Id), cancellationToken);
 15191    }
 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    {
 6203        var sender = services.GetService<INotificationSender>();
 6204        if (sender is null)
 0205            return;
 206
 6207        var context = new SecurityEventContext(
 6208            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 6209            oldLink.TenantId,
 6210            targetConnectionKey,
 6211            targetUserId,
 6212            clock.UtcNow,
 6213            outcome,
 6214            Guid.NewGuid().ToString("N"),
 6215            outcome == SecurityEventOutcome.Succeeded
 6216                ? "External identity link replacement completed."
 6217                : "External identity link replacement was rejected because the identity is already linked.");
 6218        await sender.SendAsync(
 6219            new ExternalIdentityLinkReplaced(
 6220                context,
 6221                oldLink.Id,
 6222                newLink?.Id,
 6223                oldLink.UserId,
 6224                targetUserId,
 6225                oldLink.ConnectionKey,
 6226                targetConnectionKey,
 6227                conflictingLink?.Id,
 6228                conflictingLink?.UserId,
 6229                conflictingLink?.ConnectionKey),
 6230            cancellationToken);
 6231    }
 232
 233    private static string NormalizeIssuer(string issuer)
 234    {
 42235        if (!Uri.TryCreate(issuer?.Trim(), UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHtt
 1236            throw new ArgumentException("The issuer must be an absolute HTTPS URI without a query or fragment.", nameof(
 41237        return uri.GetComponents(UriComponents.AbsoluteUri, UriFormat.UriEscaped).TrimEnd('/');
 238    }
 239
 240    private static string NormalizeSubject(string subject)
 241    {
 41242        var normalized = subject?.Trim();
 41243        if (string.IsNullOrEmpty(normalized) || normalized.Length > 4096)
 0244            throw new ArgumentException("The subject is required and must not exceed 4096 characters.", nameof(subject))
 41245        return normalized;
 246    }
 247
 248    private static void ValidateTargetTenant(string tenantId)
 249    {
 56250        ArgumentNullException.ThrowIfNull(tenantId);
 56251        if (string.Equals(tenantId, ConnectionScope.HostTenantId, StringComparison.Ordinal))
 0252            throw new ArgumentException("External identity links require a resolved tenant.", nameof(tenantId));
 56253    }
 254}
 255
 256public 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}