signalr-utilities.psm1

<# Copyright (c) 2026 One Identity LLC. All rights reserved. #>
# SignalR SSE helpers for event listening
# Not exported -- loaded via NestedModules and imported with -Scope Local by consumers

function Get-SignalRConnectionToken
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$Appliance,
        [Parameter(Mandatory=$false)]
        [string]$ServicePath = "event",
        [Parameter(Mandatory=$false)]
        [object]$AccessToken,
        [Parameter(Mandatory=$false)]
        [string]$ApiKey,
        [Parameter(Mandatory=$false)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [Parameter(Mandatory=$false)]
        [string]$Thumbprint,
        [Parameter(Mandatory=$false)]
        [switch]$Insecure
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    # $PSDefaultParameterValues is module-scoped on PS 7 -- the caller's SSL bypass
    # does not propagate into this module. Clone the global values so that
    # Invoke-RestMethod sees -SkipCertificateCheck when -Insecure is set.
    if ($Insecure)
    {
        Import-Module -Name "$PSScriptRoot\sslhandling.psm1" -Scope Local
        Disable-SslVerification
        if ($global:PSDefaultParameterValues) { $PSDefaultParameterValues = $global:PSDefaultParameterValues.Clone() }
    }

    $local:Url = "https://$Appliance/service/$ServicePath/signalr/negotiate?negotiateVersion=1"
    $local:Headers = @{
        "Accept" = "application/json";
        "Content-type" = "application/json"
    }

    if ($AccessToken)
    {
        $local:Headers["Authorization"] = "Bearer $AccessToken"
    }
    elseif ($ApiKey)
    {
        $local:Headers["Authorization"] = "A2A $ApiKey"
    }

    Write-Verbose "Negotiating SignalR connection at $local:Url"

    try
    {
        if ($Certificate)
        {
            $local:Response = Invoke-RestMethod -Certificate $Certificate -Method POST `
                -Headers $local:Headers -Uri $local:Url
        }
        elseif ($Thumbprint)
        {
            $local:Response = Invoke-RestMethod -CertificateThumbprint $Thumbprint -Method POST `
                -Headers $local:Headers -Uri $local:Url
        }
        else
        {
            $local:Response = Invoke-RestMethod -Method POST -Headers $local:Headers -Uri $local:Url
        }
    }
    catch
    {
        Import-Module -Name "$PSScriptRoot\sg-utilities.psm1" -Scope Local
        Out-SafeguardExceptionIfPossible $_
    }

    if (-not $local:Response.connectionToken)
    {
        throw "SignalR negotiate failed -- no connectionToken in response"
    }

    # Verify SSE transport is available
    $local:HasSse = $false
    foreach ($local:Transport in $local:Response.availableTransports)
    {
        if ($local:Transport.transport -eq "ServerSentEvents")
        {
            $local:HasSse = $true
            break
        }
    }
    if (-not $local:HasSse)
    {
        throw "SignalR server does not support ServerSentEvents transport"
    }

    $local:TokenPreview = $local:Response.connectionToken
    if ($local:TokenPreview.Length -gt 8)
    {
        $local:TokenPreview = $local:TokenPreview.Substring(0, 8) + "..."
    }
    Write-Verbose "Obtained connectionToken: $local:TokenPreview"

    $local:Response.connectionToken
}

function Send-SignalRHandshake
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$Appliance,
        [Parameter(Mandatory=$true)]
        [string]$ConnectionToken,
        [Parameter(Mandatory=$false)]
        [string]$ServicePath = "event",
        [Parameter(Mandatory=$false)]
        [object]$AccessToken,
        [Parameter(Mandatory=$false)]
        [string]$ApiKey,
        [Parameter(Mandatory=$false)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [Parameter(Mandatory=$false)]
        [string]$Thumbprint,
        [Parameter(Mandatory=$false)]
        [switch]$Insecure
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    # $PSDefaultParameterValues is module-scoped on PS 7 -- clone from global
    if ($Insecure)
    {
        Import-Module -Name "$PSScriptRoot\sslhandling.psm1" -Scope Local
        Disable-SslVerification
        if ($global:PSDefaultParameterValues) { $PSDefaultParameterValues = $global:PSDefaultParameterValues.Clone() }
    }

    $local:EncodedToken = [System.Uri]::EscapeDataString($ConnectionToken)
    $local:Url = "https://$Appliance/service/$ServicePath/signalr?id=$local:EncodedToken"
    $local:HandshakePayload = '{"protocol":"json","version":1}' + [char]0x1E
    $local:BodyBytes = [System.Text.Encoding]::UTF8.GetBytes($local:HandshakePayload)

    $local:Headers = @{
        "Accept" = "application/json";
        "Content-type" = "application/json"
    }

    if ($AccessToken)
    {
        $local:Headers["Authorization"] = "Bearer $AccessToken"
    }
    elseif ($ApiKey)
    {
        $local:Headers["Authorization"] = "A2A $ApiKey"
    }

    Write-Verbose "Sending SignalR handshake to $local:Url"

    try
    {
        if ($Certificate)
        {
            Invoke-RestMethod -Certificate $Certificate -Method POST `
                -Headers $local:Headers -Uri $local:Url -Body $local:BodyBytes | Out-Null
        }
        elseif ($Thumbprint)
        {
            Invoke-RestMethod -CertificateThumbprint $Thumbprint -Method POST `
                -Headers $local:Headers -Uri $local:Url -Body $local:BodyBytes | Out-Null
        }
        else
        {
            Invoke-RestMethod -Method POST -Headers $local:Headers -Uri $local:Url `
                -Body $local:BodyBytes | Out-Null
        }
    }
    catch
    {
        Import-Module -Name "$PSScriptRoot\sg-utilities.psm1" -Scope Local
        Out-SafeguardExceptionIfPossible $_
    }

    Write-Verbose "SignalR handshake sent successfully"
}

function Open-SignalRSseStream
{
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$Url,
        [Parameter(Mandatory=$false)]
        [hashtable]$Headers,
        [Parameter(Mandatory=$false)]
        [switch]$Insecure,
        [Parameter(Mandatory=$false)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    Write-Verbose "Opening SSE stream: $Url"

    if ($PSVersionTable.PSEdition -eq "Core")
    {
        # PS 7+: HttpWebRequest SSL callback is broken on .NET 10+.
        # Use HttpClient with DangerousAcceptAnyServerCertificateValidator.
        $local:Handler = New-Object System.Net.Http.HttpClientHandler
        if ($Insecure)
        {
            $local:Handler.ServerCertificateCustomValidationCallback = `
                [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
        }
        if ($Certificate)
        {
            $local:Handler.ClientCertificates.Add($Certificate) | Out-Null
        }

        $local:Client = New-Object System.Net.Http.HttpClient($local:Handler)
        $local:Client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

        $local:Request = New-Object System.Net.Http.HttpRequestMessage(
            [System.Net.Http.HttpMethod]::Get, $Url)
        $local:Request.Headers.TryAddWithoutValidation("Accept", "text/event-stream") | Out-Null
        if ($Headers)
        {
            foreach ($local:Key in $Headers.Keys)
            {
                $local:Request.Headers.TryAddWithoutValidation($local:Key, $Headers[$local:Key]) | Out-Null
            }
        }

        $local:Response = $local:Client.SendAsync($local:Request,
            [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
        $local:Response.EnsureSuccessStatusCode() | Out-Null
        $local:Stream = $local:Response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
        $local:Reader = New-Object System.IO.StreamReader($local:Stream)

        @{
            Reader = $local:Reader
            Disposables = @($local:Reader, $local:Stream, $local:Response, $local:Request, $local:Client, $local:Handler)
        }
    }
    else
    {
        # PS 5.1: HttpWebRequest with ServicePointManager callback (set by Disable-SslVerification)
        $local:WR = [System.Net.HttpWebRequest]::Create($Url)
        $local:WR.Method = "GET"
        $local:WR.Accept = "text/event-stream"
        $local:WR.KeepAlive = $true
        $local:WR.Timeout = [System.Threading.Timeout]::Infinite
        $local:WR.ReadWriteTimeout = [System.Threading.Timeout]::Infinite
        if ($Headers)
        {
            foreach ($local:Key in $Headers.Keys)
            {
                $local:WR.Headers.Add($local:Key, $Headers[$local:Key])
            }
        }
        if ($Certificate)
        {
            $local:WR.ClientCertificates.Add($Certificate) | Out-Null
        }

        $local:WebResponse = $local:WR.GetResponse()
        $local:Stream = $local:WebResponse.GetResponseStream()
        $local:Reader = New-Object System.IO.StreamReader($local:Stream)

        @{
            Reader = $local:Reader
            Disposables = @($local:Reader, $local:Stream, $local:WebResponse)
        }
    }
}

function Read-SignalRSseDataBlock
{
    # Reads one SSE data block from a StreamReader. Accumulates "data:" lines until
    # a blank line boundary. Skips SSE comments (lines starting with ":").
    # Returns the accumulated data string, or $null if the stream ended.
    [CmdletBinding()]
    [OutputType([string])]
    Param(
        [Parameter(Mandatory=$true)]
        [System.IO.StreamReader]$Reader
    )

    $local:Buffer = ""
    while ($true)
    {
        $local:Line = $Reader.ReadLine()
        if ($null -eq $local:Line)
        {
            if ($local:Buffer.Length -gt 0)
            {
                return $local:Buffer
            }
            return $null
        }
        if ($local:Line.StartsWith(":"))
        {
            continue
        }
        elseif ($local:Line.StartsWith("data:"))
        {
            $local:Value = $local:Line.Substring(5)
            if ($local:Value.StartsWith(" "))
            {
                $local:Value = $local:Value.Substring(1)
            }
            if ($local:Buffer.Length -gt 0)
            {
                $local:Buffer += "`n"
            }
            $local:Buffer += $local:Value
        }
        elseif ($local:Line -eq "" -and $local:Buffer.Length -gt 0)
        {
            return $local:Buffer
        }
    }
}

function Read-SignalRHandshakeResponse
{
    # Reads and validates the SignalR handshake response from an SSE stream.
    # Throws on handshake error or if the stream closes before handshake completes.
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [System.IO.StreamReader]$Reader
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:RecordSep = [char]0x1E
    $local:HandshakeData = Read-SignalRSseDataBlock -Reader $Reader

    if ($null -eq $local:HandshakeData)
    {
        throw "SSE stream closed before handshake completed"
    }

    $local:HsFrames = $local:HandshakeData.Split($local:RecordSep)
    foreach ($local:HsFrame in $local:HsFrames)
    {
        $local:HsFrame = $local:HsFrame.Trim()
        if ($local:HsFrame.Length -eq 0) { continue }
        $local:HsParsed = ConvertFrom-Json $local:HsFrame
        if ($local:HsParsed.error)
        {
            throw "SignalR handshake error: $($local:HsParsed.error)"
        }
    }

    Write-Verbose "SignalR handshake complete"
}

function Read-SignalREvents
{
    # Reads SignalR event frames from an SSE stream and dispatches them.
    # When no Handler or HandlerScript is specified, emits PSCustomObjects to the pipeline.
    # Returns when the stream ends or a SignalR close frame is received.
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [System.IO.StreamReader]$Reader,
        [Parameter(Mandatory=$false)]
        [hashtable]$EventFilter,
        [Parameter(Mandatory=$false)]
        [ScriptBlock]$Handler,
        [Parameter(Mandatory=$false)]
        [string]$HandlerScript
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:RecordSep = [char]0x1E

    while ($true)
    {
        $local:DataBlock = Read-SignalRSseDataBlock -Reader $Reader
        if ($null -eq $local:DataBlock)
        {
            Write-Verbose "SSE stream ended (server closed connection)"
            return
        }

        $local:Frames = $local:DataBlock.Split($local:RecordSep)

        foreach ($local:Frame in $local:Frames)
        {
            $local:Frame = $local:Frame.Trim()
            if ($local:Frame.Length -eq 0) { continue }

            try
            {
                $local:Msg = ConvertFrom-Json $local:Frame
            }
            catch
            {
                Write-Verbose "Failed to parse SignalR frame: $local:Frame"
                continue
            }

            # SignalR message types: 1=Invocation, 6=Ping, 7=Close
            if ($local:Msg.type -eq 6)
            {
                Write-Verbose "Received SignalR ping"
                continue
            }
            elseif ($local:Msg.type -eq 7)
            {
                Write-Verbose "Received SignalR close frame"
                return
            }
            elseif ($local:Msg.type -eq 1 -and $local:Msg.target -eq "NotifyEventAsync")
            {
                $local:EventData = $local:Msg.arguments[0]
                $local:EvName = $local:EventData.Name
                $local:EvBody = $local:EventData

                # Apply event name filter
                if ($EventFilter -and -not $EventFilter.ContainsKey($local:EvName))
                {
                    Write-Verbose "Skipping filtered event: $local:EvName"
                    continue
                }

                Write-Verbose "Event received: $local:EvName"

                if ($Handler)
                {
                    try
                    {
                        & $Handler $local:EvName $local:EvBody
                    }
                    catch
                    {
                        Write-Warning "Event handler error for '$($local:EvName)': $_"
                    }
                }
                elseif ($HandlerScript)
                {
                    try
                    {
                        & $HandlerScript $local:EvName $local:EvBody
                    }
                    catch
                    {
                        Write-Warning "Handler script error for '$($local:EvName)': $_"
                    }
                }
                else
                {
                    New-Object PSObject -Property @{
                        EventName = $local:EvName
                        EventBody = $local:EvBody
                    }
                }
            }
        }
    }
}

function Test-SignalRFatalError
{
    # Returns $true if the exception represents a fatal (4xx) HTTP error that
    # should not be retried. Handles both WebException (PS 5.1) and
    # HttpRequestException (PS 7+).
    [CmdletBinding()]
    [OutputType([bool])]
    Param(
        [Parameter(Mandatory=$true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    if ($ErrorRecord.Exception -is [System.Net.WebException])
    {
        $local:WebEx = $ErrorRecord.Exception
        if ($local:WebEx.Response)
        {
            $local:StatusCode = [int]$local:WebEx.Response.StatusCode
            if ($local:StatusCode -ge 400 -and $local:StatusCode -lt 500)
            {
                return $true
            }
        }
    }
    elseif ($ErrorRecord.Exception.GetType().FullName -eq "System.Net.Http.HttpRequestException")
    {
        $local:HrStatusCode = $ErrorRecord.Exception.StatusCode
        if ($null -ne $local:HrStatusCode)
        {
            $local:StatusInt = [int]$local:HrStatusCode
            if ($local:StatusInt -ge 400 -and $local:StatusInt -lt 500)
            {
                return $true
            }
        }
    }

    return $false
}