Providers/FreeIPA/Private/New-FreeIPAHttpClient.ps1

function New-FreeIPAHttpClient {
    <#
    .SYNOPSIS
        Builds the HTTP client a FreeIPA connection sends every request through

    .DESCRIPTION
        FreeIPA authenticates a browser-style session: a form login sets a cookie, and every
        JSON-RPC call after it carries that cookie and a Referer header naming the server. One
        HttpClient per connection holds both, in both PowerShell editions, which is why this is
        not Invoke-WebRequest: a session variable would do the cookie, but nothing in
        Invoke-WebRequest pins a certificate authority on Windows PowerShell.

        A FreeIPA server almost always presents a certificate from the realm's own CA, which
        the machine running this module does not trust. Rather than turning certificate
        validation off, the CA is pinned: given its PEM, the handler accepts a server
        certificate only if it chains to exactly that CA, and refuses anything else including
        a certificate the operating system would have trusted. The validation runs inside a
        small compiled class, because a PowerShell script block handed to HttpClient as a
        callback runs on a thread with no runspace and fails there. Without a PEM the operating
        system's trust store decides, as it would for any other HTTPS call.

    .PARAMETER BaseUrl
        The server URL, for the Referer header.

    .PARAMETER CaCertificate
        The PEM text of the certificate authority to pin, or nothing to use the OS trust store.

    .OUTPUTS
        PSCustomObject with Client, the HttpClient with cookies enabled and the Referer header
        set, and Cookies, the container the session cookie lands in.

    .EXAMPLE
        PS> $http = New-FreeIPAHttpClient -BaseUrl https://ipa.example.com -CaCertificate $pem

        DESCRIPTION: Builds a client that trusts only the realm's CA
        OUTPUT: The client and its cookie container
        USE CASE: Connect-FreeIPAEnvironment, and the bootstrap's handover check

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds an in-memory client and changes nothing outside the process.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$BaseUrl,

        [Parameter()]
        [string]$CaCertificate
    )

    # Windows PowerShell defaults to TLS 1.0. Only ever add to the enabled set: clearing it
    # would change behaviour for everything else in the session.
    if ($PSVersionTable.PSEdition -eq 'Desktop') {
        $tls12 = [System.Net.SecurityProtocolType]::Tls12
        if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) {
            [System.Net.ServicePointManager]::SecurityProtocol =
                [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12
        }
        Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
    }

    $handler = $null
    if (-not [string]::IsNullOrWhiteSpace($CaCertificate)) {
        if (-not ('TestEnvironment.FreeIPA.PinnedCaHandler' -as [type])) {
            # Compiled once per session. The chain is built with the pinned CA offered as an
            # extra store and unknown authorities allowed, and then the root the chain ended
            # on has to be the pinned CA itself; that is the check, not the chain status.
            $source = @'
using System;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;

namespace TestEnvironment.FreeIPA
{
    public static class PinnedCaHandler
    {
        public static HttpClientHandler Create(X509Certificate2 ca)
        {
            var handler = new HttpClientHandler();
            handler.UseCookies = true;
            handler.CookieContainer = new System.Net.CookieContainer();
            handler.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) =>
            {
                if (cert == null) { return false; }
                var check = new X509Chain();
                check.ChainPolicy.ExtraStore.Add(ca);
                check.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
                check.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
                if (!check.Build(cert)) { return false; }
                var root = check.ChainElements[check.ChainElements.Count - 1].Certificate;
                return string.Equals(root.Thumbprint, ca.Thumbprint, StringComparison.OrdinalIgnoreCase);
            };
            return handler;
        }
    }
}
'@

            $addTypeArgs = @{ TypeDefinition = $source; ErrorAction = 'Stop' }
            if ($PSVersionTable.PSEdition -eq 'Desktop') { $addTypeArgs['ReferencedAssemblies'] = @('System.Net.Http') }
            Add-Type @addTypeArgs
        }

        $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
            [System.Text.Encoding]::ASCII.GetBytes($CaCertificate))
        $handler = [TestEnvironment.FreeIPA.PinnedCaHandler]::Create($certificate)
    }
    else {
        $handler = [System.Net.Http.HttpClientHandler]::new()
        $handler.UseCookies = $true
        $handler.CookieContainer = [System.Net.CookieContainer]::new()
    }

    $client = [System.Net.Http.HttpClient]::new($handler)
    $client.Timeout = [TimeSpan]::FromMinutes(5)
    $client.DefaultRequestHeaders.Referrer = [uri]('{0}/ipa' -f $BaseUrl.TrimEnd('/'))

    return [PSCustomObject]@{
        Client  = $client
        Cookies = $handler.CookieContainer
    }
}