< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ProviderHttpException
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ProviderHttpClientFactory.cs
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 194
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_Failure()100%11100%

File(s)

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

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