Public/Authentication.ps1

## Ensure moveSessions folder exists
$moveSessionsFolderPath = Join-Path -Path $userPaths.credentials -ChildPath '.moveSessions'
if (-not (Test-Path -Path $moveSessionsFolderPath -PathType Container)) {
    $null = New-Item -Path $moveSessionsFolderPath -ItemType Directory
}

## Define a MoveSessions Variable to store connection details in
New-Variable -Scope Global -Name 'MoveSessions' -Value @{ } -ErrorAction SilentlyContinue
New-Variable -Scope Global -Name 'MoveSessionsPath' -Value (Join-Path -Path $userPaths.credentials -ChildPath '.moveSessions') -ErrorAction SilentlyContinue

function Get-MoveSessionFilePath {
    <#
    .SYNOPSIS
    Builds the on-disk session file path for a given Move server.
 
    .DESCRIPTION
    Each Move server gets its own session file so that connections to multiple
    servers can be persisted side-by-side. The server name is normalized (the
    protocol prefix is stripped and any characters that are illegal in a file
    name are replaced) and used as the file name.
 
    .PARAMETER Server
    The Move server hostname or URI to build the session file path for.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Server
    )

    # Normalize: drop the protocol prefix, then sanitize illegal file name chars
    $instance = $Server -replace '^https?://'
    $invalid = [regex]::Escape(-join [IO.Path]::GetInvalidFileNameChars())
    $safeName = $instance -replace "[$invalid]", '_'

    return Join-Path -Path $MoveSessionsPath -ChildPath ('{0}.json' -f $safeName)
}

function Lock-FileMutex {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [int]$TimeoutSeconds = 15
    )

    begin {
        Write-Verbose "LOCK: Attempting [$Path]"
        $stopWatch = [Diagnostics.Stopwatch]::StartNew()
    }

    process {
        while ($stopWatch.Elapsed -lt [TimeSpan]::FromSeconds($TimeoutSeconds)) {
            try {
                $fs = [IO.File]::Open(
                    $Path, 'OpenOrCreate', 'ReadWrite', 'None'
                )

                Write-Verbose "LOCK: Acquired [$Path]"
                return $fs
            }
            catch [IO.IOException] {
                Write-Verbose "LOCK: Busy, retrying..."
                Start-Sleep -Milliseconds 88
            }
        }

        throw "LOCK: Timeout acquiring [$Path]"
    }
}


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


    [CmdletBinding()]
    [Alias('Connect-MoveServer')]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true)]
        [string]$Server,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true)]
        [int]$Port = 443,

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

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true)]
        [switch]$AllowInsecureSSL,

        [Parameter(
            DontShow = $true,
            Mandatory = $false)]
        [switch]$Force,

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

    begin {
        # Each session is named after (and persisted per) its server, so that
        # connections to multiple servers can coexist.
        $SessionName = $Server -replace '^https?://'
        # If there is no saved session, build the path to the one we will create
        if (-not $Force.IsPresent) {
            try {
                $savedSession = Get-MoveSession -Server $Server -Credential $Credential
            } catch {
                Write-Verbose "No Move Session found for [$Server]"
            }
        }
    }

    process {

        if ($savedSession) {
            Write-Verbose 'Returning existing session'
            $Global:MoveSessions[$savedSession.Name] = $savedSession
            return $Passthru.IsPresent ? $savedSession : $null
        }

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

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

        ## Save the Instance and Port used
        $NewMoveSession.MoveServer = $Instance

        ## Prepare Request Headers for use in the Session Header Cache
        $ContentType = 'application/json;charset=UTF-8'

        # Authenticating with Move APIs - Basic AUTH over SSL
        $RequestHeaders = @{
            'Content-Type'  = $ContentType
            'Accept'        = 'application/json'
            'Cache-Control' = 'no-cache'
        }

        $body = @{
            Spec = @{
                Username = $Credential.UserName
                Password = $Credential.GetNetworkCredential().Password
            }
        }
        $WebRequestSplat = @{
            Method                          = 'POST'
            Uri                             = 'https://{0}:{1}/move/v2/users/login' -f $Instance, $Port
            Headers                         = $RequestHeaders
            SessionVariable                 = 'MoveWebSession'
            PreserveAuthorizationOnRedirect = $true
            ContentType                     = $ContentType
            Body                            = $body | ConvertTo-Json -Compress
            ProgressAction                  = 'SilentlyContinue'
            SkipCertificateCheck            = $AllowInsecureSSL.IsPresent
        }

        ## Attempt Login
        if ($VerbosePreference -eq 'Continue') {
            Write-Host 'Logging into Move instance [ ' -NoNewline
            Write-Host $Instance -ForegroundColor Cyan -NoNewline
            Write-Host ' ] as [ ' -NoNewline
            Write-Host $Credential.UserName -ForegroundColor Cyan -NoNewline
            Write-Host ' ]'
        }

        try {
            $vp = $VerbosePreference; $VerbosePreference = 'SilentlyContinue'
            $Response = Invoke-WebRequest @WebRequestSplat
            $loginRequestTime = Get-Date
            $VerbosePreference = $vp
            if ($Response.StatusCode -eq 200) {
                $ResponseContent = $Response.Content | ConvertFrom-Json
            }
        } catch {
            Write-Host $_.Exception.Message
            Write-Host $_.Exception.InnerException.Message
            throw $_
        }

        ## Add this Session to the MoveSessions list and save it to disk
        Write-Verbose ' NEW: Populating NewMoveSession'
        $MoveWebSession.Headers['Authorization'] = $ResponseContent.Status.Token
        Write-Verbose " NEW: new TOK = $($MoveWebSession.Headers['Authorization']?.Substring(270))"
        $NewMoveSession.setExpirationDate($loginRequestTime, $ResponseContent.Status)
        $NewMoveSession.MoveWebSession = $MoveWebSession
        $sessionPath = Get-MoveSessionFilePath -Server $SessionName
        $NewMoveSession | ConvertTo-Json -Depth 10 | Out-File -FilePath $sessionPath -Force
        Write-Verbose ' NEW: Exported NewMoveSession, adding to Global:MoveSessions'
        $Global:MoveSessions[$SessionName] = $NewMoveSession
        Write-Verbose "New Token created with auth header (truncated): $($global:MoveSessions[$SessionName].MoveWebSession.Headers.Authorization?.Substring(270))"

        ## Return the session if requested
        if ($Passthru.IsPresent) {
            return $NewMoveSession
        }
    }
}

function Get-MoveSession {
    <#
    .SYNOPSIS
    Gets a MoveSession by Name, Server or Version
 
    .PARAMETER Name
    One or more MoveSession names to get
 
    .PARAMETER Server
    One or more TM servers for which a MoveSession has been created
 
    .PARAMETER Version
    One or more TM server versions for which a MoveSession has been created
 
    .EXAMPLE
    Get-MoveSession -Name 'Default', 'DEV'
 
    .EXAMPLE
    Get-MoveSession -Version '6.1.*'
 
    .EXAMPLE
    Get-MoveSession -Server '*.Move.net'
 
    .OUTPUTS
    One MoveSession, or an array of MoveSessions depending on the number of sessions to return
    #>


    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    param(
        [Parameter(
            Mandatory = $false,
            Position = 0,
            ParameterSetName = 'ByName',
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [Alias('SessionName')]
        [string[]]$Name = '*',

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

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true)]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'ByVersion',
            ValueFromPipelineByPropertyName = $true)]
        [string[]]$Version,

        [Parameter(
            Mandatory = $false,
            Position = 0,
            ParameterSetName = 'ByObject')]
        [MoveSession]$MoveSession
    )

    begin {
        # The in-memory and on-disk caches are keyed per-server, so the fast-path
        # (return a cached/persisted session) only applies when we can pin down a
        # single, exact server. Wildcard, Name and Version lookups fall through to
        # the process block, which filters the in-memory sessions.
        $targetServer = $null
        switch ($PSCmdlet.ParameterSetName) {
            'ByServer' { if ($Server -and $Server -notmatch '\*') { $targetServer = $Server } }
            'ByObject' { $targetServer = $MoveSession.MoveServer }
        }

        if (-not $targetServer) {
            Write-Verbose ' GET: No single server target; deferring to in-memory lookup'
            return # go to the process block
        }

        $sessionKey = $targetServer -replace '^https?://'
        $sessionPath = Get-MoveSessionFilePath -Server $sessionKey

        # Is there a valid session for this server in the Global:MoveSessions variable?
        if (Test-MoveSession -MoveSession $Global:MoveSessions[$sessionKey]) {
            Write-Verbose "GET: Returning valid session for [$sessionKey] from Global var"
            $existingSession = $Global:MoveSessions[$sessionKey]
            return # go to the process block
        } else {
            Write-Verbose "GET: No valid session for [$sessionKey] in Global var"
            $Global:MoveSessions.Remove($sessionKey)
        }

        # Is there a valid session saved to disk for this server?
        Write-Verbose (' GET: Checking for a session on disk: Server = {0}' -f $sessionKey)

        try {
            $existingSessionJson = Get-Content -Path $sessionPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            $existingSession = [MoveSession]::new(
                $sessionKey, $existingSessionJson.MoveServer, $existingSessionJson.MovePort, $existingSessionJson.AllowInsecureSSL
            )

            $existingWebSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
            foreach ($header in $existingSessionJson.MoveWebSession.Headers.psobject.Members | Where-Object membertype -EQ NoteProperty) {
                $existingWebSession.Headers.Add($header.Name, $header.Value)
            }
            $existingSession.ExpirationDate = $existingSessionJson.ExpirationDate
            $existingSession.MoveWebSession = $existingWebSession
            $existingSession.MoveWebSession.Cookies = [System.Net.CookieContainer]::new()

            if (Test-MoveSession -MoveSession $existingSession) {
                $Global:MoveSessions[$sessionKey] = $existingSession
                Write-Verbose "GET: Returning existing MoveSession from path: $sessionPath"
            } else {
                # there is no refresh token
                # create a new session

                try {
                    Write-Verbose ' GET: Creating New Session -Force'
                    # New-MoveSession -Server $existingSession.MoveServer -Port $existingSession.MovePort -Credential $existingSession.Credential
                    $newSessionSplat = @{
                        Server           = $Global:MoveSessions[$sessionKey].MoveServer ?? $existingSession.MoveServer
                        Port             = $Global:MoveSessions[$sessionKey].MovePort ?? $existingSession.MovePort
                        Credential       = $Credential
                        AllowInsecureSSL = $Global:MoveSessions[$sessionKey].AllowInsecureSSL ?? $existingSession.AllowInsecureSSL
                    }

                    if (-not ($newSessionSplat.Credential -is [pscredential])) {
                        try {
                            $newSessionSplat.Credential = Get-StoredCredential -Name $newSessionSplat.Server
                        }
                        catch {
                            throw "No stored credential: {0}" -f $newSessionSplat.Server
                        }
                    }

                    try {
                        $lockFileName = '{0}_{1}.lock' -f $sessionKey, $newSessionSplat.Port
                        $lockPath = Join-Path -Path $MoveSessionsPath -ChildPath $lockFileName
                        $lock     = Lock-FileMutex $lockPath

                        if (Test-Path $sessionPath) {
                            $retry = Get-Content $sessionPath -Raw | ConvertFrom-Json

                            $retrySession = [MoveSession]::new(
                                $sessionKey,
                                $retry.MoveServer,
                                $retry.MovePort,
                                $retry.AllowInsecureSSL
                            )

                            $retryWebSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
                            foreach ($header in $retry.MoveWebSession.Headers.psobject.Members | Where-Object membertype -EQ NoteProperty) {
                                $retryWebSession.Headers.Add($header.Name, $header.Value)
                            }
                            $retrySession.ExpirationDate = $retry.ExpirationDate
                            $retrySession.MoveWebSession = $retryWebSession
                            $retrySession.MoveWebSession.Cookies = [System.Net.CookieContainer]::new()

                            if (Test-MoveSession -MoveSession $retrySession) {
                                $Global:MoveSessions[$sessionKey] = $retrySession
                                $existingSession = $retrySession
                                return
                            }
                        }

                        $existingSession = New-MoveSession @newSessionSplat -Force -Passthru
                    }
                    finally {
                        Write-Verbose "LOCK: Releasing [$lockFileName]"
                        $lock.Dispose()
                    }

                } catch {
                    $Global:MoveSessions.Remove($sessionKey)
                    Write-Verbose ' GET: Error creating refreshed session'
                    throw $_
                }

            }
        } catch {
            Write-Verbose " GET: No valid MoveSession in [$sessionPath]: $_"
            $existingSession = $null
        }
    }

    process {
        if ($existingSession) {
            Write-Verbose " GET: Returning existing MoveSession [$($existingSession.Name)] - exp $($existingSession.ExpirationDate)"
            return  $existingSession
        }

        [string] $StarMatch = '.*\*.*'

        $SessionsToReturn = switch ($PSCmdlet.ParameterSetName) {
            'ByObject' {
                $Global:MoveSessions.Values | Where-Object {
                    $_.Name -eq $MoveSession.Name -and
                    $_.TMServer -eq $MoveSession.TMServer -and
                    $_.TMVersion -eq $MoveSession.TMVersion
                }
                break
            }

            'ByName' {
                if ( $Name -eq '*' ) {
                    $Global:MoveSessions.Values
                } else {    
                    foreach ($SingleName in $Name) {
                        $Global:MoveSessions.Values | Where-Object Name -Like $SingleName
                    }
                }
                break
            }

            'ByServer' {
                if ($Server.Count -eq 1 -and $Server -match $StarMatch) {
                    # Adding a star at the end so we can match just by name and not by FQDN
                    $Global:MoveSessions.Values | Where-Object MoveServer -Like "$Server*"
                } else {
                    $Global:MoveSessions.Values | Where-Object MoveServer -In $Server
                }
                break
            }

            'ByVersion' {
                if ($Version.Count -eq 1 -and $Version -match $StarMatch) {
                    $Global:MoveSessions.Values | Where-Object MoveVersion -Like $Version
                } else {
                    $Global:MoveSessions.Values | Where-Object MoveVersion -In $Version
                }
                break
            }
        }

        if ( $SessionsToReturn.Count ) {
            Write-Verbose " GET: Returning $($SessionsToReturn.Count) sessions"
            return [MoveSession] $SessionsToReturn
        }

        switch ($PSCmdlet.ParameterSetName) {
            'ByObject' {
                throw ('Unexpected error returning a MoveSession with Name: "{0}" on server "{1}"' -f ($MoveSession)?.Name, ($MoveSession).TMServer)
            }

            default {
                $MatchingProperty = $PSCmdlet.ParameterSetName -replace '^By'
                $MatchingPropertyValue = (Get-Variable $MatchingProperty -ErrorAction SilentlyContinue)?.Value
                if ( $MatchingPropertyValue -match $StarMatch) {
                    Write-Verbose ' GET: No MoveSessions found'
                    return @{}
                } else {
                    throw "MoveSession with provided $MatchingProperty '$MatchingPropertyValue' was not found"
                }
            }
        }
    }
}

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


    [CmdletBinding()]
    param(
        [AllowNull()][Parameter(
            Mandatory = $false,
            Position = 0,
            ValueFromPipeline = $true,
            ParameterSetName = 'MoveSessionObject')]
        [pscustomobject]$MoveSession,

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

    process {
        if ($PSCmdlet.ParameterSetName -eq 'MoveSessionServer') {
            Write-Verbose ' TST: PSN=MoveSessionServer'
            $sessionPath = Get-MoveSessionFilePath -Server $Server
            if (Test-Path -Path $sessionPath -ErrorAction SilentlyContinue) {
                $NewMoveSession = Get-Content -Path $sessionPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                Test-MoveSession -MoveSession $NewMoveSession
            } else {
                Write-Verbose ' TST: FALSE bc no sessionPath'
                return $false
            }
        }

        if ($null -eq $MoveSession) {
            Write-Verbose " TST: FALSE bc null = MoveSession"
            return $false
        }

        if ($MoveSession.MoveWebSession) {
            Write-Verbose " TST: Testing Move Session [$($MoveSession.Name)] on server [$($MoveSession.MoveServer)]"
            $expirationDateTime = $MoveSession.ExpirationDate
            $isExpired = (Get-Date) -ge $expirationDateTime
            if ($isExpired) {
                Write-Verbose " TST: it is expired: expirationDate = $expirationdatetime; now = $now"
                return $false
            }
            $testTokenSplat = @{
                Method               = 'POST'
                Uri                  = 'https://{0}:{1}/move/v2/providers/list' -f $MoveSession.MoveServer, $MoveSession.MovePort
                Headers              = @{
                    Authorization   = "Bearer $($MoveSession.MoveWebSession.Headers.Authorization)"
                    'Content-Type'  = 'application/json'
                    'Accept'        = 'application/json'
                    'Cache-Control' = 'no-cache'
                }
                SkipCertificateCheck = $MoveSession.AllowInsecureSSL.IsPresent
                StatusCodeVariable   = 'statusCode'
            }
            try {
                $vp = $VerbosePreference; $VerbosePreference = 'SilentlyContinue'
                $null = Invoke-RestMethod @testTokenSplat
                $VerbosePreference = $vp
                if ($statusCode -eq 200) {
                    Write-Verbose " TST: Move Session [$($MoveSession.Name)] on server [$($MoveSession.MoveServer)] is valid"
                    return $true
                } else {
                    Write-Verbose " TST: Move Session [$($MoveSession.Name)] on server [$($MoveSession.MoveServer)] is not valid, status code: $statusCode"
                    return $false
                }
            } catch {
                Write-Verbose " TST: Error: $_"
                return $false
            }
        } else {
            Write-Verbose " TST: No Move Web Session found for server [$($MoveSession.TMServer)]"
            return $false
        }
    }
}