Providers/FreeIPA/Public/Connect-FreeIPAEnvironment.ps1

function Connect-FreeIPAEnvironment {
    <#
    .SYNOPSIS
        Connects to a FreeIPA realm and stores the connection for the session

    .DESCRIPTION
        FreeIPA authenticates API calls with a session opened by a password login. There are
        two ways to hold one: a credential for an administrator, which is the bootstrap, and
        the password of the service account New-FreeIPAServiceApp creates, which is the
        durable one and is read from the credential record on disk.

        The connection is proven before it is stored. The login has to succeed, then a ping,
        a whoami and an environment read have to answer, which catches a wrong URL, a bad
        password, a certificate the machine does not trust and a proxy in the way, and which
        tells the session the server's API version, the identity it is running as, and the
        realm's domain, which every seeded host name is written under. Storing first and
        failing later would leave a broken connection behind for the next command to trip
        over with an unrelated message.

        Two things about FreeIPA passwords are handled here rather than left to surprise. Any
        password an administrator sets is expired the moment it is set, so a fresh bootstrap
        credential cannot log in until it is changed: -NewPassword changes it as part of
        connecting. And the realm's policy expires the service account's password on its own
        schedule, so a service account whose password has expired is rotated to a new random
        one and the record rewritten, with a warning, rather than failing.

        A FreeIPA server almost always presents a certificate from the realm's own CA. Pass
        that CA's PEM with -CertificateAuthorityPath and the connection trusts exactly it and
        nothing else; the bootstrap writes it into the record so -ServiceAccount needs no path.
        Without either, the operating system's trust store decides.

    .PARAMETER BaseUrl
        The server URL, for example https://ipa.example.com. No path.

    .PARAMETER Credential
        A login and password. The bootstrap credential.

    .PARAMETER NewPassword
        With -Credential: the password to change to before logging in, for a credential whose
        password the realm has marked expired.

    .PARAMETER ServiceAccount
        Connect with the service account password from the credential record.

    .PARAMETER CredentialPath
        Where the credential record is, when not in the default location.

    .PARAMETER VaultPassword
        The SecretStore password, when the record's password is in a vault whose password is
        not a default.

    .PARAMETER CertificateAuthorityPath
        A PEM file holding the realm's certificate authority, to be trusted in place of the
        operating system's store. /etc/ipa/ca.crt on any enrolled host.

    .PARAMETER Prefix
        The naming prefix for everything the session creates. Must end in a hyphen or an
        underscore. Defaults to the module-wide ZZ-TEST-.

    .PARAMETER PassThru
        Returns the connection, without its password or client.

    .OUTPUTS
        PSCustomObject. The connection, when -PassThru is supplied.

    .EXAMPLE
        PS> Connect-FreeIPAEnvironment -BaseUrl https://ipa.example.com -Credential (Get-Credential admin) -CertificateAuthorityPath ./ca.crt

        DESCRIPTION: Connects with an administrator's credential, trusting the realm's CA
        OUTPUT: Nothing, unless -PassThru is supplied
        USE CASE: The first session against a realm, before a service account exists

    .EXAMPLE
        PS> Connect-FreeIPAEnvironment -BaseUrl https://ipa.example.com -ServiceAccount

        DESCRIPTION: Connects with the stored service account password
        OUTPUT: Nothing, unless -PassThru is supplied
        USE CASE: Every session after the bootstrap

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


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath',
        Justification = 'A file path to a credential record, not a credential.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ServiceAccount',
        Justification = 'Selects the parameter set; the set name is what the body switches on.')]
    [CmdletBinding(DefaultParameterSetName = 'Credential')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidatePattern('^https?://')]
        [string]$BaseUrl,

        [Parameter(Mandatory = $true, ParameterSetName = 'Credential')]
        [System.Management.Automation.PSCredential]$Credential,

        [Parameter(ParameterSetName = 'Credential')]
        [System.Security.SecureString]$NewPassword,

        [Parameter(Mandatory = $true, ParameterSetName = 'ServiceAccount')]
        [switch]$ServiceAccount,

        [Parameter(ParameterSetName = 'ServiceAccount')]
        [string]$CredentialPath,

        [Parameter(ParameterSetName = 'ServiceAccount')]
        [System.Security.SecureString]$VaultPassword,

        [Parameter()]
        [string]$CertificateAuthorityPath,

        [Parameter()]
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_-]*[-_]$')]
        [string]$Prefix = $script:TestEnvironmentDefaultPrefix,

        [Parameter()]
        [switch]$PassThru
    )

    $normalisedUrl = $BaseUrl.TrimEnd('/')
    $sharedMarker = Get-TestSeedMarker -Prefix $Prefix
    $authType = $PSCmdlet.ParameterSetName

    $caCertificate = $null
    if (-not [string]::IsNullOrWhiteSpace($CertificateAuthorityPath)) {
        if (-not (Test-Path -LiteralPath $CertificateAuthorityPath)) {
            throw "No certificate authority file at $CertificateAuthorityPath."
        }
        $caCertificate = [System.IO.File]::ReadAllText($CertificateAuthorityPath)
        if ($caCertificate -notmatch '-----BEGIN CERTIFICATE-----') {
            throw "$CertificateAuthorityPath does not hold a PEM certificate."
        }
    }

    $username = $null
    $password = $null
    $resolvedCredentialPath = $null
    $record = $null

    if ($authType -eq 'ServiceAccount') {
        $resolvedCredentialPath = Get-FreeIPACredentialPath -BaseUrl $normalisedUrl -Path $CredentialPath
        $record = Import-FreeIPACredential -Path $resolvedCredentialPath -VaultPassword $VaultPassword
        $username = $record.Username
        $password = $record.Password
        if (-not $caCertificate -and $record.CaCertificate) { $caCertificate = $record.CaCertificate }
    }
    else {
        $username = $Credential.UserName
        $password = $Credential.GetNetworkCredential().Password
    }

    if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) {
        Write-Error 'The credential has no login or no password.' -ErrorAction Stop
        return
    }

    $http = New-FreeIPAHttpClient -BaseUrl $normalisedUrl -CaCertificate $caCertificate

    $candidate = @{
        BaseUrl        = $normalisedUrl
        Username       = $username
        Password       = $password
        AuthType       = $authType
        Prefix         = $sharedMarker.Prefix
        SeedTag        = $sharedMarker.Tag
        SeedMarker     = '[{0}]' -f $sharedMarker.Tag
        Client         = $http.Client
        Cookies        = $http.Cookies
        CaCertificate  = $caCertificate
        CredentialPath = $resolvedCredentialPath
        Identity       = $username
        Domain         = $null
        Realm          = $null
        ApiVersion     = $null
        ConnectedAt    = Get-Date
    }

    try {
        $login = Connect-FreeIPASession -Connection $candidate

        if (-not $login.Success -and $login.Reason -eq 'password-expired') {
            if ($authType -eq 'ServiceAccount') {
                # The realm's policy expired it. Rotated rather than reported, because the
                # account is the module's own and the record is the only place the password
                # lives; the record is rewritten with the same protection it had.
                $fresh = New-TestPassword -Length 32
                Set-FreeIPAPassword -Connection $candidate -Username $username -OldPassword $password -NewPassword $fresh -Confirm:$false
                $candidate.Password = $fresh
                $exportArgs = @{
                    Path          = $resolvedCredentialPath
                    BaseUrl       = $normalisedUrl
                    Username      = $username
                    Password      = $fresh
                    CaCertificate = $caCertificate
                    Confirm       = $false
                }
                if ($record.Protection -eq 'SecretStore') {
                    $exportArgs['UseSecretStore'] = $true
                    $exportArgs['VaultName'] = $record.VaultName
                    if ($VaultPassword) { $exportArgs['VaultPassword'] = $VaultPassword }
                }
                $null = Export-FreeIPACredential @exportArgs
                Write-Warning "The realm had expired the service account's password. It was rotated and the record at $resolvedCredentialPath rewritten."
                $login = Connect-FreeIPASession -Connection $candidate
            }
            elseif ($NewPassword) {
                $plainNew = ConvertFrom-TestSecureString -SecureString $NewPassword
                Set-FreeIPAPassword -Connection $candidate -Username $username -OldPassword $password -NewPassword $plainNew -Confirm:$false
                $candidate.Password = $plainNew
                Write-Verbose "Changed the expired password of $username before connecting."
                $login = Connect-FreeIPASession -Connection $candidate
            }
            else {
                throw ("the password for '$username' has expired. FreeIPA expires every password an administrator " +
                    'sets, so a new credential has to change it before it can be used: pass -NewPassword to change it ' +
                    'as part of connecting, or change it with kinit or the web UI first.')
            }
        }

        if (-not $login.Success) {
            throw "FreeIPA rejected the credential for '$username' ($($login.Reason))."
        }

        # Proven, and learned from. The ping names the API version every later call sends;
        # whoami names the identity; env names the domain seeded hosts are written under.
        $ping = Invoke-FreeIPARequest -Method 'ping' -Connection $candidate
        if ($ping -and $ping.summary -and ([string]$ping.summary) -match 'API version (\d+\.\d+)') {
            $candidate.ApiVersion = $Matches[1]
        }

        $who = Invoke-FreeIPARequest -Method 'whoami' -Connection $candidate
        if ($who -and $who.arguments) { $candidate.Identity = [string](@($who.arguments)[0]) }

        try {
            $environment = Invoke-FreeIPARequest -Method 'env' -Arguments @('domain', 'realm') -Connection $candidate
            if ($environment -and $environment.result) {
                $candidate.Domain = [string]$environment.result.domain
                $candidate.Realm = [string]$environment.result.realm
            }
        }
        catch {
            Write-Verbose "Could not read the realm's environment: $($_.Exception.Message)"
        }
        if ([string]::IsNullOrWhiteSpace($candidate.Domain)) {
            # The server is normally a host inside the domain, so the domain is the name minus
            # its first label. A single-label host cannot be guessed and the connect fails
            # rather than seeding hosts under a name nobody chose.
            $serverHost = ([uri]$normalisedUrl).Host
            if ($serverHost -match '^[^.]+\.(.+\..+)$') {
                $candidate.Domain = $Matches[1]
                Write-Warning "The realm did not report its domain; hosts will be seeded under '$($candidate.Domain)', derived from the server name."
            }
            else {
                throw "the realm did not report its domain and it cannot be derived from '$serverHost'."
            }
        }
    }
    catch {
        $http.Client.Dispose()
        throw (New-Object System.Exception(
                "Could not authenticate to $normalisedUrl as ${username}: $($_.Exception.Message)", $_.Exception))
    }

    $script:FreeIPAConnection = $candidate

    Write-Verbose "Connected to $normalisedUrl as $($candidate.Identity) (API $($candidate.ApiVersion), domain $($candidate.Domain)), seeding under $($candidate.Prefix)"

    if ($PassThru) {
        return [PSCustomObject]@{
            PSTypeName     = 'FreeIPAConnection'
            BaseUrl        = $candidate.BaseUrl
            AuthType       = $candidate.AuthType
            Identity       = $candidate.Identity
            Domain         = $candidate.Domain
            Realm          = $candidate.Realm
            ApiVersion     = $candidate.ApiVersion
            Prefix         = $candidate.Prefix
            SeedTag        = $candidate.SeedTag
            PinnedCa       = [bool]$candidate.CaCertificate
            CredentialPath = $candidate.CredentialPath
            ConnectedAt    = $candidate.ConnectedAt
        }
    }
}