< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ProviderHttpResponse
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ProviderHttpClientFactory.cs
Line coverage
66%
Covered lines: 2
Uncovered lines: 1
Coverable lines: 3
Total lines: 195
Line coverage: 66.6%
Branch coverage
50%
Covered branches: 1
Total branches: 2
Branch coverage: 50%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_StatusCode()100%11100%
get_IsSuccessStatusCode()50%22100%
ReadBodyAsUtf8()100%210%

File(s)

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

#LineLine coverage
 1using System.Net;
 2using System.Net.Http;
 3using System.Text;
 4using Elsa.ExternalAuthentication.Options;
 5using Elsa.ExternalAuthentication.Validation;
 6using Microsoft.Extensions.Options;
 7
 8namespace Elsa.ExternalAuthentication.Services;
 9
 10/// <summary>
 11/// Creates the protocol-neutral HTTP client used for all provider traffic.
 12/// Redirects are deliberately handled by <see cref="ProviderHttpClient"/> so every hop is revalidated.
 13/// </summary>
 14public sealed class ProviderHttpClientFactory : IProviderHttpClientFactory, IDisposable
 15{
 16    private readonly HttpMessageInvoker invoker;
 17    private readonly OutboundDestinationValidator destinationValidator;
 18    private readonly IOptions<ExternalAuthenticationOptions> options;
 19
 20    public ProviderHttpClientFactory(
 21        IOptions<ExternalAuthenticationOptions> options,
 22        OutboundDestinationValidator destinationValidator,
 23        ValidatedOutboundConnectionFactory connectionFactory)
 24    {
 25        this.options = options;
 26        this.destinationValidator = destinationValidator;
 27        UsesApprovedProxy = options.Value.ProviderEgress.ProxyUri is not null;
 28        if (options.Value.ProviderEgress.ProxyUri is { } proxyUri)
 29            destinationValidator.ValidateApprovedProxy(proxyUri);
 30
 31        var handler = new SocketsHttpHandler
 32        {
 33            AllowAutoRedirect = false,
 34            ConnectTimeout = options.Value.ProviderEgress.ConnectTimeout,
 35            UseProxy = UsesApprovedProxy
 36        };
 37
 38        if (UsesApprovedProxy)
 39            handler.Proxy = new WebProxy(options.Value.ProviderEgress.ProxyUri!);
 40        else
 41            handler.ConnectCallback = (context, cancellationToken) => connectionFactory.ConnectAsync(context.DnsEndPoint
 42
 43        invoker = new HttpMessageInvoker(handler, disposeHandler: true);
 44    }
 45
 46    /// <summary>
 47    /// A configured proxy is deployment-owned and the sole explicitly approved egress gateway. Requested destinations a
 48    /// </summary>
 49    public bool UsesApprovedProxy { get; }
 50
 51    public IProviderHttpClient CreateClient() => new ProviderHttpClient(invoker, destinationValidator, options);
 52
 53    public void Dispose() => invoker.Dispose();
 54}
 55
 56public interface IProviderHttpClientFactory
 57{
 58    IProviderHttpClient CreateClient();
 59}
 60
 61public interface IProviderHttpClient
 62{
 63    ValueTask<ProviderHttpResponse> GetAsync(Uri uri, ProviderResponseKind kind, CancellationToken cancellationToken = d
 64    ValueTask<ProviderHttpResponse> PostFormAsync(Uri uri, IReadOnlyDictionary<string, string> values, IReadOnlyDictiona
 65}
 66
 67public sealed class ProviderHttpClient(HttpMessageInvoker invoker, OutboundDestinationValidator destinationValidator, IO
 68{
 69    public ValueTask<ProviderHttpResponse> GetAsync(Uri uri, ProviderResponseKind kind, CancellationToken cancellationTo
 70
 71    public ValueTask<ProviderHttpResponse> PostFormAsync(Uri uri, IReadOnlyDictionary<string, string> values, IReadOnlyD
 72        SendAsync(uri, kind, address => CreateFormRequest(address, values, headers), cancellationToken);
 73
 74    private static HttpRequestMessage CreateFormRequest(Uri address, IReadOnlyDictionary<string, string> values, IReadOn
 75    {
 76        var request = new HttpRequestMessage(HttpMethod.Post, address) { Content = new FormUrlEncodedContent(values) };
 77        if (headers is not null)
 78            foreach (var (name, value) in headers)
 79                request.Headers.TryAddWithoutValidation(name, value);
 80        return request;
 81    }
 82
 83    private async ValueTask<ProviderHttpResponse> SendAsync(Uri uri, ProviderResponseKind kind, Func<Uri, HttpRequestMes
 84    {
 85        var redirects = 0;
 86        var current = uri;
 87        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 88        timeout.CancelAfter(options.Value.ProviderEgress.RequestTimeout);
 89
 90        try
 91        {
 92            while (true)
 93            {
 94                await destinationValidator.ValidateAsync(current, timeout.Token);
 95                using var request = createRequest(current);
 96                using var response = await invoker.SendAsync(request, timeout.Token);
 97
 98                if (IsRedirect(response.StatusCode))
 99                {
 100                    if (kind is ProviderResponseKind.Token or ProviderResponseKind.UserInfo || response.Headers.Location
 101                        throw new ProviderHttpException(ProviderHttpFailure.RedirectRejected);
 102
 103                    current = new Uri(current, response.Headers.Location);
 104                    continue;
 105                }
 106
 107                if (!response.IsSuccessStatusCode)
 108                    return new ProviderHttpResponse(response.StatusCode, []);
 109
 110                return new ProviderHttpResponse(response.StatusCode, await ReadResponseBodyAsync(response, kind, timeout
 111            }
 112        }
 113        catch (OutboundDestinationException)
 114        {
 115            throw new ProviderHttpException(ProviderHttpFailure.DestinationRejected);
 116        }
 117        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
 118        {
 119            throw new ProviderHttpException(ProviderHttpFailure.Timeout);
 120        }
 121        catch (ProviderHttpException)
 122        {
 123            throw;
 124        }
 125        catch (Exception) when (!cancellationToken.IsCancellationRequested)
 126        {
 127            throw new ProviderHttpException(ProviderHttpFailure.TransportFailure);
 128        }
 129    }
 130
 131    private async Task<byte[]> ReadResponseBodyAsync(HttpResponseMessage response, ProviderResponseKind kind, Cancellati
 132    {
 133        var limit = GetResponseLimit(kind);
 134        var contentLength = response.Content.Headers.ContentLength;
 135        if (contentLength is not null && contentLength > limit)
 136            throw new ProviderHttpException(ProviderHttpFailure.ResponseTooLarge);
 137
 138        await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
 139        await using var output = new MemoryStream();
 140        var buffer = new byte[81920];
 141        while (true)
 142        {
 143            var read = await input.ReadAsync(buffer, cancellationToken);
 144            if (read == 0)
 145                return output.ToArray();
 146
 147            if (output.Length + read > limit)
 148                throw new ProviderHttpException(ProviderHttpFailure.ResponseTooLarge);
 149
 150            await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
 151        }
 152    }
 153
 154    private long GetResponseLimit(ProviderResponseKind kind) => kind switch
 155    {
 156        ProviderResponseKind.Token => options.Value.ProviderEgress.MaximumTokenResponseBytes,
 157        ProviderResponseKind.UserInfo => options.Value.ProviderEgress.MaximumUserInfoResponseBytes,
 158        _ => options.Value.ProviderEgress.MaximumDiscoveryResponseBytes
 159    };
 160
 161    private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is HttpStatusCode.Moved or HttpStatusCode.Re
 162}
 163
 33164public sealed record ProviderHttpResponse(HttpStatusCode StatusCode, byte[] Body)
 165{
 11166    public bool IsSuccessStatusCode => (int)StatusCode is >= 200 and <= 299;
 0167    public string ReadBodyAsUtf8() => Encoding.UTF8.GetString(Body);
 168}
 169
 170public enum ProviderResponseKind
 171{
 172    Discovery,
 173    SigningKeys,
 174    Token,
 175    UserInfo
 176}
 177
 178public enum ProviderHttpFailure
 179{
 180    DestinationRejected,
 181    RedirectRejected,
 182    Timeout,
 183    ResponseTooLarge,
 184    TransportFailure
 185}
 186
 187public sealed class ProviderHttpException : InvalidOperationException
 188{
 189    public ProviderHttpException(ProviderHttpFailure failure) : base("The provider request could not be completed.")
 190    {
 191        Failure = failure;
 192    }
 193
 194    public ProviderHttpFailure Failure { get; }
 195}