Public/Authentication.ps1

$scriptRootName = Split-Path -Path $PSScriptRoot -Leaf
$script:ServicePointBasePath = if ($scriptRootName -eq 'Public') {
    Split-Path -Path $PSScriptRoot -Parent
}
else {
    $PSScriptRoot
}

$script:ServicePointSessionStorePath = Join-Path -Path $script:ServicePointBasePath -ChildPath '.servicePointSessions'

if (-not (Test-Path -Path $script:ServicePointSessionStorePath -PathType Container)) {
    $null = New-Item -Path $script:ServicePointSessionStorePath -ItemType Directory -Force
}

New-Variable -Scope Global -Name 'ServicePointSessions' -Value @{} -ErrorAction SilentlyContinue

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 Get-SNSessionPath {
    [CmdletBinding()]
    param(
        [string]$Name = 'Default'
    )

    process {
        Join-Path -Path $script:ServicePointSessionStorePath -ChildPath ("{0}.json" -f $Name)
    }
}

function New-SNAuthorizationHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscredential]$Credential
    )

    process {
        $plainText = '{0}:{1}' -f $Credential.UserName, $Credential.GetNetworkCredential().Password
        'Basic {0}' -f [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($plainText))
    }
}

function New-SNSessionObject {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter(Mandatory)]
        [string]$Server,

        [Parameter(Mandatory)]
        [string]$Authorization,

        [Parameter(Mandatory)]
        [string]$UserName,

        [switch]$AllowInsecureSSL,

        [int]$SessionTtlHours = 8
    )

    process {
        $headers = @{
            Authorization  = $Authorization
            Accept         = 'application/json'
            'Content-Type' = 'application/json'
        }

        $webSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
        foreach ($entry in $headers.GetEnumerator()) {
            $webSession.Headers[$entry.Key] = $entry.Value
        }

        [pscustomobject]@{
            PSTypeName       = 'ServicePoint.Session'
            Name             = $Name
            Server           = $Server.TrimEnd('/')
            UserName         = $UserName
            Created          = Get-Date
            ExpirationDate   = (Get-Date).AddHours($SessionTtlHours)
            AllowInsecureSSL = $AllowInsecureSSL.IsPresent
            Headers          = $headers
            WebSession       = $webSession
        }
    }
}

function Save-SNSession {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Session
    )

    process {
        $path = Get-SNSessionPath -Name $Session.Name
        $persisted = [pscustomobject]@{
            Name             = $Session.Name
            Server           = $Session.Server
            UserName         = $Session.UserName
            Created          = $Session.Created
            ExpirationDate   = $Session.ExpirationDate
            AllowInsecureSSL = $Session.AllowInsecureSSL
            Headers          = $Session.Headers
        }
        $persisted | ConvertTo-Json -Depth 6 | Set-Content -Path $path
    }
}

function Restore-SNSession {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    process {
        $saved = Get-Content -Path $Path -Raw | ConvertFrom-Json -ErrorAction Stop
        $webSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
        foreach ($header in $saved.Headers.psobject.Properties) {
            $webSession.Headers[$header.Name] = [string]$header.Value
        }

        [pscustomobject]@{
            PSTypeName       = 'ServicePoint.Session'
            Name             = $saved.Name
            Server           = $saved.Server
            UserName         = $saved.UserName
            Created          = [datetime]$saved.Created
            ExpirationDate   = [datetime]$saved.ExpirationDate
            AllowInsecureSSL = [bool]$saved.AllowInsecureSSL
            Headers          = @{}
            WebSession       = $webSession
        } | ForEach-Object {
            foreach ($header in $saved.Headers.psobject.Properties) {
                $_.Headers[$header.Name] = [string]$header.Value
            }
            $_
        }
    }
}

function Test-SNSession {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [pscustomobject]$Session,

        [string]$Server,

        [string]$UserName
    )

    process {
        if ($null -eq $Session) {
            return $false
        }

        if (-not $Session.Headers.Authorization) {
            return $false
        }

        if ((Get-Date) -ge [datetime]$Session.ExpirationDate) {
            return $false
        }

        if ($Server -and $Session.Server.TrimEnd('/') -ne $Server.TrimEnd('/')) {
            return $false
        }

        if ($UserName -and $Session.UserName -ne $UserName) {
            return $false
        }

        return $true
    }
}

function Get-SNSession {
    [CmdletBinding()]
    param(
        [string]$Name = 'Default',
        [string]$Server,
        [pscredential]$Credential
    )

    begin {
        $userName = $Credential.UserName
    }

    process {
        $globalSession = $Global:ServicePointSessions[$Name]
        if (Test-SNSession -Session $globalSession -Server $Server -UserName $userName) {
            return $globalSession
        }

        if ($globalSession) {
            $Global:ServicePointSessions.Remove($Name)
        }

        $path = Get-SNSessionPath -Name $Name
        if (-not (Test-Path -Path $path -PathType Leaf)) {
            return $null
        }

        try {
            $restored = Restore-SNSession -Path $path
            if (Test-SNSession -Session $restored -Server $Server -UserName $userName) {
                $Global:ServicePointSessions[$Name] = $restored
                return $restored
            }
        }
        catch {
            Write-Verbose "Failed to restore ServicePoint session: $_"
        }

        return $null
    }
}

function New-SNSession {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Server,

        [Parameter(Mandatory)]
        [pscredential]$Credential,

        [string]$Name = 'Default',

        [switch]$AllowInsecureSSL,

        [switch]$Force,

        [switch]$PassThru,

        [int]$SessionTtlHours = 8
    )

    begin {
        $normalizedServer = if ($Server -match '^https?://') { $Server } else { 'https://{0}' -f $Server }
        if (-not $Force.IsPresent) {
            $existingSession = Get-SNSession -Name $Name -Server $normalizedServer -Credential $Credential
        }
    }

    process {
        if ($existingSession) {
            return $PassThru.IsPresent ? $existingSession : $null
        }

        $lockName = '{0}.lock' -f $Name
        $lockPath = Join-Path -Path $script:ServicePointSessionStorePath -ChildPath $lockName
        $lock = $null

        try {
            $lock = Lock-FileMutex -Path $lockPath

            if (-not $Force.IsPresent) {
                $retrySession = Get-SNSession -Name $Name -Server $normalizedServer -Credential $Credential
                if ($retrySession) {
                    return $PassThru.IsPresent ? $retrySession : $null
                }
            }

            $authorization = New-SNAuthorizationHeader -Credential $Credential
            $session = New-SNSessionObject -Name $Name -Server $normalizedServer -Authorization $authorization -UserName $Credential.UserName -AllowInsecureSSL:$AllowInsecureSSL -SessionTtlHours $SessionTtlHours
            Save-SNSession -Session $session
            $Global:ServicePointSessions[$Name] = $session

            if ($PassThru.IsPresent) {
                return $session
            }
        }
        finally {
            if ($lock) {
                $lock.Dispose()
            }
        }
    }
}

function Invoke-SNRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Uri,

        [object]$Body,

        [string]$SessionName = 'Default',

        [string]$Server,

        [pscredential]$Credential,

        [switch]$AllowInsecureSSL,

        [int]$TimeoutSec = 60
    )

    begin {
        $session = Get-SNSession -Name $SessionName -Server $Server -Credential $Credential
        if (-not $session) {
            if (-not $Server -or -not $Credential) {
                throw 'A reusable ServicePoint session was not found. Provide -Server and -Credential, or create a session first with New-SNSession.'
            }

            $session = New-SNSession -Name $SessionName -Server $Server -Credential $Credential -AllowInsecureSSL:$AllowInsecureSSL -PassThru
        }
    }

    process {
        $requestSplat = @{
            Method               = $Method
            Uri                  = $Uri
            WebSession           = $session.WebSession
            Headers              = $session.Headers
            ContentType          = 'application/json'
            TimeoutSec           = $TimeoutSec
            SkipCertificateCheck = $session.AllowInsecureSSL
            StatusCodeVariable   = 'statusCode'
            ErrorAction          = 'Stop'
        }

        if ($PSBoundParameters.ContainsKey('Body')) {
            $requestSplat.Body = $Body | ConvertTo-Json -Depth 10
        }

        try {
            $response = Invoke-RestMethod @requestSplat
            [pscustomobject]@{
                Status         = 'success'
                HttpStatusCode = [int]$statusCode
                Session        = $session
                Body           = $response
            }
        }
        catch {
            $status = $null
            $rawBody = $null
            $parsedBody = $null

            if ($_.Exception.Response) {
                try {
                    $status = [int]$_.Exception.Response.StatusCode
                }
                catch {
                    $status = $null
                }

                try {
                    $reader = [IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
                    $rawBody = $reader.ReadToEnd()
                    $reader.Dispose()
                }
                catch {
                    $rawBody = $null
                }
            }

            if ($rawBody) {
                try {
                    $parsedBody = $rawBody | ConvertFrom-Json -ErrorAction Stop
                }
                catch {
                    $parsedBody = $rawBody
                }
            }

            $errorCode = $null
            $detail = $_.Exception.Message
            $message = $null

            if ($parsedBody -and $parsedBody.error) {
                $errorCode = $parsedBody.error.message
                $detail = $parsedBody.error.detail
                $message = $parsedBody.error.message
            }
            elseif ($parsedBody -is [string]) {
                $detail = $parsedBody
            }

            [pscustomobject]@{
                Status         = 'failure'
                HttpStatusCode = $status
                ErrorCode      = $errorCode
                Message        = $message
                Detail         = $detail
                Body           = $parsedBody
                Session        = $session
            }
        }
    }
}