< Summary

Information
Class: Elsa.ExternalAuthentication.Endpoints.IdentityLinks.FindUsers
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs
Line coverage
100%
Covered lines: 16
Uncovered lines: 0
Coverable lines: 16
Total lines: 265
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Configure()100%11100%
ExecuteAsync()100%1414100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs

#LineLine coverage
 1using System.Security.Claims;
 2using System.Text;
 3using System.Text.Json;
 4using Elsa.Abstractions;
 5using Elsa.Common.Multitenancy;
 6using Elsa.ExternalAuthentication.Models;
 7using Elsa.ExternalAuthentication.Permissions;
 8using Elsa.ExternalAuthentication.Services;
 9using Microsoft.AspNetCore.Http;
 10
 11namespace Elsa.ExternalAuthentication.Endpoints.IdentityLinks;
 12
 13internal sealed class GetIdentityLinks(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor)
 14{
 15    public override void Configure()
 16    {
 17        Get("/external-authentication/identity-links");
 18        ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
 19    }
 20
 21    public override async Task<IdentityLinkListResponse> ExecuteAsync(IdentityLinkListRequest request, CancellationToken
 22    {
 23        if (request.PageSize is < 1 or > 100 || !IdentityLinkPagination.TryDecodeCursor(request.Cursor, out var cursor))
 24        {
 25            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
 26            return new IdentityLinkListResponse([], null);
 27        }
 28
 29        var pageSize = request.PageSize ?? 100;
 30        var links = (await management.ListAsync(tenantAccessor.TenantId, new ExternalIdentityLinkFilter { UserId = reque
 31            .OrderBy(x => x.CreatedAt)
 32            .ThenBy(x => x.Id, StringComparer.Ordinal)
 33            .Where(x => cursor is null || IdentityLinkPagination.Compare(new IdentityLinkCursor(x.CreatedAt, x.Id), curs
 34            .Take(pageSize + 1)
 35            .ToArray();
 36        var hasMore = links.Length > pageSize;
 37        var items = hasMore ? links[..^1] : links;
 38        return new IdentityLinkListResponse(items.Select(IdentityLinkDocument.From).ToArray(), hasMore ? IdentityLinkPag
 39    }
 40}
 41
 1642internal sealed class FindUsers(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : Elsa
 43{
 44    public override void Configure()
 45    {
 1346        Get("/external-authentication/user-options");
 1347        ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
 1348    }
 49
 50    public override async Task<FindIdentityLinkUsersResponse> ExecuteAsync(FindIdentityLinkUsersRequest request, Cancell
 51    {
 352        if (request.PageSize is < 1 or > 50 || !IdentityLinkPagination.TryDecodeUserCursor(request.Cursor, out var curso
 53        {
 154            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
 155            return new FindIdentityLinkUsersResponse([], null);
 56        }
 57
 258        var pageSize = request.PageSize ?? 50;
 259        var users = (await management.FindUsersAsync(tenantAccessor.TenantId, request.Search, cancellationToken)).Items
 460            .Where(x => cursor is null || IdentityLinkPagination.Compare(new UserCursor(x.DisplayName, x.Id), cursor) > 
 261            .Take(pageSize + 1)
 262            .ToArray();
 263        var hasMore = users.Length > pageSize;
 264        var items = hasMore ? users[..^1] : users;
 465        return new FindIdentityLinkUsersResponse(items.Select(x => new IdentityLinkUserDocument(x.Id, x.DisplayName)).To
 366    }
 67}
 68
 69internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccess
 70{
 71    public override void Configure()
 72    {
 73        Post("/external-authentication/identity-links");
 74        ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
 75    }
 76
 77    public override async Task HandleAsync(PrelinkIdentityLinkRequest request, CancellationToken cancellationToken)
 78    {
 79        ExternalIdentityLinkPrelinkResult result;
 80        try
 81        {
 82            result = await management.PrelinkAsync(tenantAccessor.TenantId, request.UserId, request.ConnectionKey, reque
 83        }
 84        catch (ArgumentException)
 85        {
 86            await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "validation_f
 87            return;
 88        }
 89
 90        switch (result)
 91        {
 92            case ExternalIdentityLinkPrelinkResult.Success(var link, var wasCreated):
 93                HttpContext.Response.StatusCode = wasCreated ? StatusCodes.Status201Created : StatusCodes.Status200OK;
 94                await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(link), cancellationToken);
 95                return;
 96            case ExternalIdentityLinkPrelinkResult.Conflict:
 97                await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status409Conflict, "conflict",
 98                return;
 99            default:
 100                // Do not reveal whether a user or connection exists outside the trusted tenant scope.
 101                await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found"
 102                return;
 103        }
 104    }
 105}
 106
 107internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccess
 108{
 109    public override void Configure()
 110    {
 111        Post("/external-authentication/identity-links/{linkId}/replace");
 112        ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
 113    }
 114
 115    public override async Task HandleAsync(ReplaceIdentityLinkRequest request, CancellationToken cancellationToken)
 116    {
 117        ExternalIdentityLinkReplaceResult result;
 118        try
 119        {
 120            result = await management.ReplaceAsync(
 121                tenantAccessor.TenantId,
 122                Route<string>("linkId")!,
 123                request.UserId,
 124                request.ConnectionKey,
 125                request.Issuer,
 126                request.Subject,
 127                User,
 128                cancellationToken);
 129        }
 130        catch (ArgumentException)
 131        {
 132            await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "validation_f
 133            return;
 134        }
 135
 136        switch (result)
 137        {
 138            case ExternalIdentityLinkReplaceResult.Success success:
 139                HttpContext.Response.StatusCode = StatusCodes.Status201Created;
 140                await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(success.NewLink), cancellationToke
 141                return;
 142            case ExternalIdentityLinkReplaceResult.Conflict:
 143                await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status409Conflict, "conflict",
 144                return;
 145            default:
 146                await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found"
 147                return;
 148        }
 149    }
 150}
 151
 152internal sealed class DeleteIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccesso
 153{
 154    public override void Configure()
 155    {
 156        Delete("/external-authentication/identity-links/{linkId}");
 157        ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
 158    }
 159
 160    public override async Task HandleAsync(CancellationToken cancellationToken)
 161    {
 162        var deleted = await management.UnlinkAsync(tenantAccessor.TenantId, Route<string>("linkId")!, User, cancellation
 163        HttpContext.Response.StatusCode = deleted ? StatusCodes.Status204NoContent : StatusCodes.Status404NotFound;
 164    }
 165}
 166
 167internal sealed class IdentityLinkListRequest
 168{
 169    public string? UserId { get; set; }
 170    public string? ConnectionKey { get; set; }
 171    public string? Cursor { get; set; }
 172    public int? PageSize { get; set; }
 173}
 174
 175internal sealed class FindIdentityLinkUsersRequest
 176{
 177    public string? Search { get; set; }
 178    public string? Cursor { get; set; }
 179    public int? PageSize { get; set; }
 180}
 181
 182internal sealed class PrelinkIdentityLinkRequest
 183{
 184    public string UserId { get; set; } = null!;
 185    public string ConnectionKey { get; set; } = null!;
 186    public string Issuer { get; set; } = null!;
 187    public string Subject { get; set; } = null!;
 188}
 189
 190internal sealed class ReplaceIdentityLinkRequest
 191{
 192    public string UserId { get; set; } = null!;
 193    public string ConnectionKey { get; set; } = null!;
 194    public string Issuer { get; set; } = null!;
 195    public string Subject { get; set; } = null!;
 196}
 197
 198internal sealed record IdentityLinkListResponse(IReadOnlyCollection<IdentityLinkDocument> Items, string? NextCursor);
 199internal sealed record FindIdentityLinkUsersResponse(IReadOnlyCollection<IdentityLinkUserDocument> Items, string? NextCu
 200internal sealed record IdentityLinkUserDocument(string Id, string DisplayName);
 201internal sealed record IdentityLinkDocument(string Id, string UserId, string ConnectionKey, string Issuer, string? Subje
 202{
 203    public static IdentityLinkDocument From(ExternalIdentityLink link) => new(link.Id, link.UserId, link.ConnectionKey, 
 204}
 205internal sealed record IdentityLinkError(string Error, string Message);
 206internal sealed record IdentityLinkCursor(DateTimeOffset CreatedAt, string Id);
 207internal sealed record UserCursor(string DisplayName, string Id);
 208
 209internal static class IdentityLinkEndpointSupport
 210{
 211    public static Task SendErrorAsync(HttpContext httpContext, int status, string error, string message, CancellationTok
 212    {
 213        httpContext.Response.StatusCode = status;
 214        return httpContext.Response.WriteAsJsonAsync(new IdentityLinkError(error, message), cancellationToken);
 215    }
 216}
 217
 218internal static class IdentityLinkPagination
 219{
 220    public static string EncodeCursor(IdentityLinkCursor cursor) => Encode(cursor);
 221    public static string EncodeUserCursor(UserCursor cursor) => Encode(cursor);
 222    public static bool TryDecodeCursor(string? value, out IdentityLinkCursor? cursor) => TryDecode(value, out cursor);
 223    public static bool TryDecodeUserCursor(string? value, out UserCursor? cursor) => TryDecode(value, out cursor);
 224
 225    public static int Compare(IdentityLinkCursor left, IdentityLinkCursor right)
 226    {
 227        var result = left.CreatedAt.CompareTo(right.CreatedAt);
 228        return result != 0 ? result : string.Compare(left.Id, right.Id, StringComparison.Ordinal);
 229    }
 230
 231    public static int Compare(UserCursor left, UserCursor right)
 232    {
 233        var result = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal);
 234        return result != 0 ? result : string.Compare(left.Id, right.Id, StringComparison.Ordinal);
 235    }
 236
 237    private static string Encode<T>(T cursor) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(
 238
 239    private static bool TryDecode<T>(string? value, out T? cursor) where T : class
 240    {
 241        cursor = null;
 242        if (string.IsNullOrWhiteSpace(value))
 243            return true;
 244        if (value.Length > 512)
 245            return false;
 246        try
 247        {
 248            var padded = value.Replace('-', '+').Replace('_', '/');
 249            padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
 250            cursor = JsonSerializer.Deserialize<T>(Encoding.UTF8.GetString(Convert.FromBase64String(padded)));
 251            return cursor is not null && IsBounded(cursor);
 252        }
 253        catch (Exception exception) when (exception is FormatException or JsonException)
 254        {
 255            return false;
 256        }
 257    }
 258
 259    private static bool IsBounded<T>(T cursor) where T : class => cursor switch
 260    {
 261        IdentityLinkCursor value => value.Id.Length is > 0 and <= 256,
 262        UserCursor value => value.Id.Length is > 0 and <= 256 && value.DisplayName.Length <= 512,
 263        _ => false
 264    };
 265}