lib/Authentication.ps1

## Define a ZertoSessions Variable to store connection details in

New-Variable -Scope Global -Name 'ZertoSessions' -Value @{ } -Force
New-Variable -Scope Global -Name 'ZertoSessionsPath' -Value (Join-Path -Path $userPaths.credentials -ChildPath '.zertoSessions') -Force

function New-ZertoSession {
    <#
    .SYNOPSIS
    Creates a connection to a Zerto VM instance
 
    .DESCRIPTION
    This function creates a connection to the specified Zerto instance using
    the provided credentials. This session can be used when calling other functions within
    the Zerto module
 
    .PARAMETER SessionName
    The name that will be used when referring to the created ZertoSession
 
    .PARAMETER Server
    The URI of the Zerto instance to connect to
 
    .PARAMETER Credential
    The credentials to be used twhen connecting to Zerto
 
    .PARAMETER AllowInsecureSSL
    Boolean indicating whether or not an insecure SSL connection is allowed
 
    .EXAMPLE
    $Session = @{
        SessionName = 'ZertoDEV'
        Server = 'zertodev.Zerto.net'
        Credential = (Get-StoredCredential -Name 'ME')
    }
    New-ZertoSession @Session
 
    .OUTPUTS
    None
    #>


    [CmdletBinding()]
    [Alias('Connect-ZertoServer')]
    param(
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true)]
        [Alias('Name')]
        [string]$SessionName = 'Default',

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'ByProperty',
            ValueFromPipelineByPropertyName = $true)]
        [string]$Server,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'ByProperty',
            ValueFromPipelineByPropertyName = $true)]
        [int]$Port = 9669,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'ByProperty',
            ValueFromPipelineByPropertyName = $true)]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'ByProperty',
            ValueFromPipelineByPropertyName = $true)]
        [bool]$AllowInsecureSSL = $false,

        [Parameter(
            Mandatory = $false)]
        [switch]$Passthru
    )

    begin {
        # If there is no saved session, build the path to the one we will create
        if (-not ($savedSession = Get-ZertoSession -Server $Server) ) {
            $sessionPath = Join-Path -Path $ZertoSessionsPath -ChildPath $Server -ErrorAction Stop
        }
    }

    process {
        if ($savedSession) {
            $Global:ZertoSessions[$savedSession.Name] = $savedSession
            return $Passthru.IsPresent ? $savedSession : $null
        }

        ## Create a session object for this new connection
        $NewZertoSession = [ZertoSession]::new($SessionName, $Server, $AllowInsecureSSL)

        ## Trim the server name
        $Instance = $Server -replace '^https?://'

        ## Save the Instance and Port used
        $NewZertoSession.ZertoServer = $Instance
        $NewZertoSession.ZertoPort = $Port

        ## Attempt login to v10
        try {
            $WebRequestSplat = @{
                Method                          = 'POST'
                Uri                             = 'https://{0}:{1}/auth/realms/zerto/protocol/openid-connect/token' -f $Instance, $Port
                SessionVariable                 = 'ZertoWebSession'
                PreserveAuthorizationOnRedirect = $true
                Body                            = @{
                    grant_type = 'password'
                    client_id  = 'zerto-client'
                    scope      = 'openid email profile'
                    username   = $Credential.UserName
                    password   = $Credential.GetNetworkCredential().Password
                }
                SkipCertificateCheck            = $AllowInsecureSSL
            }

            Write-Verbose "$($MyInvocation.MyCommand.Name) - Web Request Parameters:"
            Write-Verbose ($WebRequestSplat | ConvertTo-Json -Depth 10 -Compress)
            Write-Verbose "$($MyInvocation.MyCommand.Name) - Invoking web request"
            $Response = Invoke-WebRequest @WebRequestSplat -ProgressAction 'SilentlyContinue'
            Write-Verbose "$($MyInvocation.MyCommand.Name) - Response status code: $($Response.StatusCode)"
            # Write-Verbose "Response Content: $($Response.Content)"

            ## Check the Response code for 200
            if ($Response.StatusCode -eq 200) {
                $ResponseContent = $Response.Content | ConvertFrom-Json
                $ZertoWebSession.Headers['Authorization'] = "Bearer $($ResponseContent.access_token)"
                $NewZertoSession.ZertoVersion = 10
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Now: $(Get-Date)"
                $NewZertoSession.AccessToken = $ResponseContent.access_token
                $NewZertoSession.TokenExpires = (Get-Date).AddSeconds($ResponseContent.expires_in)
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Token $($NewZertoSession.AccessToken.substring($NewZertoSession.AccessToken.Length - 10)) Expires: $($NewZertoSession.TokenExpires)"
                $NewZertoSession.RefreshToken = $ResponseContent.refresh_token
                $NewZertoSession.RefreshTokenExpires = (Get-Date).AddSeconds($ResponseContent.refresh_expires_in)
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Refresh Token Expires: $($NewZertoSession.RefreshTokenExpires)"
            }
            elseif (-not $Response) {
                Write-Verbose "$($MyInvocation.MyCommand.Name) - v10 login failed. Attempting v9 login"
                # Attempt v9 login
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Web Request Parameters:"
                Write-Verbose ($WebRequestSplat | ConvertTo-Json -Depth 10 -Compress)
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Invoking web request"
                $WebRequestSplat.Uri = "https://{0}:{1}/v1/session/add" -f $Instance, $Port
                $Response = Invoke-WebRequest @WebRequestSplat -ProgressAction 'SilentlyContinue'
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Response status code: $($Response.StatusCode)"
                Write-Verbose "$($MyInvocation.MyCommand.Name) - Response Content: $($Response.Content)"

                ## Check the Response code for 200
                if ($Response.StatusCode -eq 200) {
                    $ZertoSessionToken = $Response.headers.get_item("x-zerto-session") | Select-Object -First 1
                    $ZertoWebSession.Headers["x-zerto-session"] = $ZertoSessionToken
                    $ZertoWebSession.Headers.Remove('Authorization')
                    $NewZertoSession.ZertoVersion = 9
                }
                else {
                    throw 'Status Code "{0}" does not indicate success while v9 login' -f $Response.StatusCode
                }
            }
        }
        catch {
            Write-Host $_.Exception.Message
            Write-Host $_.Exception.InnerException.Message
            throw $_
        }


        ## Add this Session to the ZertoSessions list and save it to disk
        $NewZertoSession.ZertoWebSession = $ZertoWebSession
        $cookiePath = "$sessionPath.cookie"
        $ZertoWebSession.Cookies.GetAllCookies() | Export-Clixml -Path $cookiePath -Depth 10
        $NewZertoSession | Export-Clixml -Depth 100 -Path $sessionPath
        $Global:ZertoSessions[$SessionName] = $NewZertoSession

        ## Return the session if requested
        return $Passthru.IsPresent ? $savedSession : $null
    }
}

function Build-ZertoWebSession {
    param (
        [Parameter(
            Mandatory = $true)]
        [string]$sessionPath,

        [Parameter(
            Mandatory = $true)]
        [PSCustomObject]$existingSession
    )

    $newWebSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
    foreach ($prop in $existingSession.ZertoWebSession.PSObject.Properties | Where-Object Name -NotIn 'Headers', 'Cookies') {
        $newWebSession.$($prop.Name) = $prop.Value
    }
    $newCookie = [System.Net.Cookie]::new()
    $existingCookies = Import-Clixml -Path "$sessionPath.cookie"
    foreach ($prop in $existingCookies.PSObject.Properties | Where-Object Name -ne 'TimeStamp') {
        $newCookie.($prop.Name) = $prop.Value
    }
    $newWebSession.Cookies.Add($newCookie)

    foreach ($header in $existingSession.ZertoWebSession.Headers.GetEnumerator()) {
        $newWebSession.Headers.Add($header.Key, $header.Value)
    }

    return $newWebSession
}

function Get-ZertoSession {
    [CmdletBinding(DefaultParameterSetName = 'ListAll')]
    param(
        [Parameter(ParameterSetName = 'BySessionName')][string]$SessionName = 'Default',
        [Parameter(ParameterSetName = 'ListAll')][switch]$List,
        [Parameter(ParameterSetName = 'ByServerName')][string]$Server
    )

    begin {
        switch ($PSCmdlet.ParameterSetName) {
            'BySessionName' { }
            'ByServerName' {
                # Is there a valid session saved to disk? if so, load it and return it
                $sessionPath = Join-Path -Path $ZertoSessionsPath -ChildPath $Server
                try {
                    $existingSession = Import-Clixml -Path $sessionPath -ErrorAction Stop
                    if (Test-ZertoSession -ZertoSession $existingSession) {
                        Write-Verbose "$($MyInvocation.MyCommand.Name) - Returning existing ZertoSession from path: $sessionPath"
                    }
                    else {
                        try {
                            $refresh = Request-TokenRefresh -ZertoSession $existingSession
                            $now = Get-Date
                            $existingSession.AccessToken = $refresh.access_token # update the token, refresh token, and their expiration values
                            $existingSession.RefreshToken = $refresh.refresh_token
                            $existingSession.TokenExpires = $now.AddSeconds($refresh.expires_in)
                            $existingSession.RefreshTokenExpires = $now.AddSeconds($refresh.refresh_expires_in)
                            $existingSession.ZertoWebSession.Headers['Authorization'] = "Bearer $($existingSession.AccessToken)"
                            Write-Verbose ('{3} - Refreshed token. New: {2} Expires at {0}. Refresh exp at {1}' -f $existingSession.TokenExpires, $existingSession.RefreshTokenExpires, $existingSession.AccessToken.substring($existingSession.AccessToken.Length - 10), "$($MyInvocation.MyCommand.Name)")
                            $existingSession | Export-Clixml -Depth 100 -Path $sessionPath -Force
                        }
                        catch {
                            $existingSession = $null
                        }
                    }
                }
                catch {
                    $existingSession = $null
                }

                if ($null -eq $existingSession) {
                    Write-Verbose "$($MyInvocation.MyCommand.Name) - No valid ZertoSession found at path: $sessionPath"
                    Remove-Item $sessionPath, "$sessionPath.cookie" -Force -ErrorAction SilentlyContinue
                }

            }
            'ListAll' { return }
            Default {}
        }

    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'BySessionName' {
                if ($Global:ZertoSessions.Keys -contains $SessionName) {
                    $ZertoSessionConfig = $Global:ZertoSessions[$SessionName]
                    Write-Verbose "$($MyInvocation.MyCommand.Name) - Returning ZertoSession: $($ZertoSessionConfig.Name)@$($ZertoSessionConfig.ZertoServer)"
                }
                else {
                    throw "$($MyInvocation.MyCommand.Name) - No valid ZertoSession found for name: $SessionName"
                }
            }

            'ByServerName' {
                if ($existingSession) {
                    Write-Verbose "$($MyInvocation.MyCommand.Name) - Returning existing ZertoSession: $($existingSession.Name)@$($existingSession.ZertoServer)"
                    $existingSession.ZertoWebSession = Build-ZertoWebSession -sessionPath $sessionPath -existingSession $existingSession
                    return $existingSession
                }
                else {
                    if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Server')) {
                        Write-Verbose "$($MyInvocation.MyCommand.Name) - No valid ZertoSession found at path: $sessionPath for $Server"
                    }
                }

        if ($ZertoSessionConfig.ZertoVersion -eq "10") {

                    ## Set time of Now with a bit of an offset to force refresh before expiration
                    $Now = (Get-Date).AddSeconds(-10)

                    if ($ZertoSessionConfig.TokenExpires -lt $Now) {
                        Write-Verbose ("$($MyInvocation.MyCommand.Name) - Token {0} Is Expired" -f $existingSession.AccessToken.substring($existingSession.AccessToken.Length - 10))
                        if ($ZertoSessionConfig.RefreshTokenExpires -gt $Now) {

                            Write-Verbose "$($MyInvocation.MyCommand.Name) - Token Refresh is still valid. Refreshing Token."
                            # Make the request
                            $uri = "https://{0}:{1}/auth/realms/zerto/protocol/openid-connect/token"
                            $WebRequestSplat = @{
                                Method                          = 'POST'
                                Uri                             = $uri -f $ZertoSessionConfig.ZertoServer, $ZertoSessionConfig.ZertoPort
                                SessionVariable                 = 'ZertoWebSession'
                                PreserveAuthorizationOnRedirect = $true
                                Body                            = @{
                                    grant_type    = 'refresh_token'
                                    client_id     = 'zerto-client'
                                    refresh_token = $ZertoSessionConfig.RefreshToken
                                }
                            }

                            Write-Verbose "$($MyInvocation.MyCommand.Name) - Web Request Parameters:"
                            Write-Verbose ($WebRequestSplat | ConvertTo-Json -Depth 10)
                            Write-Verbose "$($MyInvocation.MyCommand.Name) - Invoking web request"
                            $CertSettings = @{ SkipCertificateCheck = $ZertoSessionConfig.AllowInsecureSSL }
                            $Response = Invoke-WebRequest @WebRequestSplat @CertSettings -ProgressAction 'SilentlyContinue'
                            Write-Verbose "$($MyInvocation.MyCommand.Name) - Response status code: $($Response.StatusCode)"
                            Write-Verbose "$($MyInvocation.MyCommand.Name) - Response Content: $($Response.Content)"

                            ## Check the Response code for 200
                            if ($Response.StatusCode -eq 200) {
                                $ResponseContent = $Response.Content | ConvertFrom-Json
                                $ZertoSessionConfig.ZertoWebSession.Headers['Authorization'] = "Bearer $($ResponseContent.access_token)"
                                $ZertoSessionConfig.ZertoVersion = 10

                                $ZertoSessionConfig.AccessToken = $ResponseContent.access_token
                                $ZertoSessionConfig.TokenExpires = (Get-Date).AddSeconds($ResponseContent.expires_in)

                                $ZertoSessionConfig.RefreshToken = $ResponseContent.refresh_token
                                $ZertoSessionConfig.RefreshTokenExpires = (Get-Date).AddSeconds($ResponseContent.refresh_expires_in)
                            }
                        }
                    }
                }
            }

            'ListAll' {
                return $Global:ZertoSessions.Keys
            }
        }


        return $ZertoSessionConfig
    }

}

function Remove-ZertoSession {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true)]
        [Alias('Name')]
        [string]$SessionName = 'Default'
    )

    ## Check for Existing Session to this server
    if ($Global:ZertoSessions.Keys -contains $SessionName) {
        $TheSession = $Global:ZertoSessions[$SessionName]
    }
    # $ThisSession.Remove()
}

function Test-ZertoSession {
    <#
    .SYNOPSIS
    Tests the connection to a ZertoSession
 
    .PARAMETER ZertoSession
    The ZertoSession to test
 
    .PARAMETER ZertoSession
    Test with Server name only
 
    .EXAMPLE
    Test-ZertoSession -ZertoSession (Get-ZertoSession -Name 'Default')
 
    .EXAMPLE
    Test-ZertoSession -Server my.zerto.net
 
    .OUTPUTS
    None
    #>


    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ParameterSetName = 'ZertoSessionObject')]
        [pscustomobject]$ZertoSession,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'ZertoSessionServer')]
        [string]$Server
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ZertoSessionServer') {
            $sessionPath = Join-Path -Path $ZertoSessionsPath -ChildPath $Server
            if (Test-Path -Path $sessionPath -ErrorAction SilentlyContinue) {
                $NewZertoSession = Get-Content -Path $sessionPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                return Test-ZertoSession -ZertoSession $NewZertoSession
            }
            else {
                return $false
            }
        }

        return $ZertoSession.TokenExpires -gt (Get-Date).AddSeconds(5)
    }
}

function Request-TokenRefresh {
    param(
        [Parameter(
            Mandatory = $true)]
        [ZertoSession]$ZertoSession
    )

    begin {

        if ($ZertoSession.RefreshTokenExpires -lt (Get-Date).AddSeconds(5)) {
            Write-Verbose "$($MyInvocation.MyCommand.Name) - Refresh Token has expired. Will attempt new log in."
            return
        }

        $tokenUrl = "https://{0}:{1}/auth/realms/zerto/protocol/openid-connect/token" -f $ZertoSession.ZertoServer, $ZertoSession.ZertoPort

        $body = @{
            grant_type    = 'refresh_token'
            client_id     = 'zerto-client'
            refresh_token = $ZertoSession.RefreshToken
        }

        $refreshSplat = @{
            Uri                             = $tokenUrl
            Method                          = 'Post'
            Body                            = $body
            ContentType                     = 'application/x-www-form-urlencoded'
            PreserveAuthorizationOnRedirect = $true
            SkipCertificateCheck            = $ZertoSessionConfig.AllowInsecureSSL
        }

    }

    process {
        if ($ZertoSession.RefreshTokenExpires -lt (Get-Date).AddSeconds(5)) {
            throw 'Refresh token expired'
        }

        $response = Invoke-RestMethod @refreshSplat
        return $response
    }
}