Private/Get-NSPFortinetBrowserToken.ps1

function Get-NSPFortinetBrowserToken {
    # The support portal's API token, read from a browser over the DevTools protocol on a loopback
    # port. The portal page gets a JWT after the FortiCloud SSO round trip, holds it in memory and
    # sends it as the Authorization header on every /app/ API call; it lasts 30 minutes. This
    # watches the page's network traffic for that header: once (-Wait) while you log in, or after
    # reloading the page, which renews it silently while the SSO session lasts (about 6 hours).
    # Cookies alone are not enough, and the session is never probed mid-login.
    #
    # Starts the browser on a dedicated profile when nothing listens on the port yet. Returns
    # Token, UserAgent and Expires.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][int]$Port,
        [string]$BrowserPath,
        [string]$ProfilePath,
        [int]$TimeoutSeconds = 60,
        [switch]$Wait
    )

    $base = "http://127.0.0.1:$Port"
    $portalUrl = 'https://support.fortinet.com/support/#/downloads/firmware'
    $getJson = { param([string]$Path) try { Invoke-RestMethod -Uri "$base/json/$Path" -TimeoutSec 2 -UseBasicParsing -ErrorAction Stop } catch { $null } }

    $version = & $getJson 'version'
    if (-not $version) {
        if (-not $BrowserPath) { throw 'The browser window used for the Fortinet support portal was closed. Run Connect-NSPFortinetSupport again.' }
        $null = New-Item -ItemType Directory -Force -Path $ProfilePath
        Start-Process -FilePath $BrowserPath -ArgumentList @("--remote-debugging-port=$Port", "--user-data-dir=`"$ProfilePath`"", '--no-first-run', '--no-default-browser-check', $portalUrl)
        $deadline = (Get-Date).AddSeconds(20)
        while (-not $version -and (Get-Date) -lt $deadline) {
            Start-Sleep -Milliseconds 500
            $version = & $getJson 'version'
        }
        if (-not $version) { throw "The browser did not open its DevTools port ($Port). Close any window already using the profile at '$ProfilePath' and try again." }
        $Wait = $true
    }
    $page = $null
    $deadline = (Get-Date).AddSeconds(10)
    while (-not $page -and (Get-Date) -lt $deadline) {
        # Invoke-RestMethod returns the target list as one array object; enumerate it explicitly.
        $targets = & $getJson 'list'
        $pages = @($targets | ForEach-Object { $_ } | Where-Object { $_.type -eq 'page' -and $_.webSocketDebuggerUrl })
        $page = @($pages | Where-Object url -like 'https://*.fortinet.com/*') + $pages | Select-Object -First 1
        if (-not $page) { Start-Sleep -Milliseconds 500 }
    }
    if (-not $page) { throw 'The browser has no open tab to use for the Fortinet support portal.' }

    $socket = [System.Net.WebSockets.ClientWebSocket]::new()
    $none = [Threading.CancellationToken]::None
    $socket.ConnectAsync([uri]$page.webSocketDebuggerUrl, $none).GetAwaiter().GetResult()
    $state = @{ Id = 0; Pending = $null }
    $send = {
        param([string]$Method, [hashtable]$Params = @{})
        $state.Id++
        $bytes = [Text.Encoding]::UTF8.GetBytes((@{ id = $state.Id; method = $Method; params = $Params } | ConvertTo-Json -Compress))
        $socket.SendAsync([ArraySegment[byte]]::new($bytes), [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $none).GetAwaiter().GetResult()
    }
    $buffer = [byte[]]::new(1MB)
    # Returns the raw message text. Events are not parsed as JSON: their header maps can repeat a
    # name in different case (Permissions-Policy, permissions-policy), which Windows PowerShell
    # 5.1's ConvertFrom-Json rejects.
    $receive = {
        param([int]$Milliseconds)
        # A receive that times out stays outstanding, and a socket allows only one: wait on it again.
        if (-not $state.Pending) { $state.Pending = $socket.ReceiveAsync([ArraySegment[byte]]::new($buffer), $none) }
        try { if (-not $state.Pending.Wait($Milliseconds)) { return $null } }
        catch { throw 'Lost the connection to the browser tab (was the window or tab closed?). Run Connect-NSPFortinetSupport again.' }
        $stream = [IO.MemoryStream]::new()
        $chunk = $state.Pending.Result
        $state.Pending = $null
        $stream.Write($buffer, 0, $chunk.Count)
        while (-not $chunk.EndOfMessage) {
            $chunk = $socket.ReceiveAsync([ArraySegment[byte]]::new($buffer), $none).GetAwaiter().GetResult()
            $stream.Write($buffer, 0, $chunk.Count)
        }
        [Text.Encoding]::UTF8.GetString($stream.ToArray())
    }
    $authPattern = [regex]'"[Aa]uthorization"\s*:\s*"(?<token>(?:Bearer )?eyJ[\w.-]+)"'

    $token = $null
    try {
        & $send 'Network.enable'
        if ($Wait -and $page.url -notlike 'https://support.fortinet.com/support/*') {
            if ($page.url -notlike 'https://*.fortinet.com/*') { & $send 'Page.navigate' @{ url = $portalUrl } }
            Write-Information "Log in to the Fortinet support portal in the browser window (waiting up to $TimeoutSeconds seconds)." -InformationAction Continue
        }
        # A reload makes the page fetch a new token (through SSO) and use it straight away. While
        # waiting for a login, an already logged-in page is reloaded too, so it needs no clicks.
        if (-not $Wait -or $page.url -like 'https://support.fortinet.com/support/*') { & $send 'Page.reload' }
        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
        while (-not $token -and (Get-Date) -lt $deadline) {
            # A short wait keeps the deadline honest while the page is idle (e.g. during login).
            $message = & $receive 2000
            if (-not $message) { continue }
            # The header as the page set it (requestWillBeSent, for portal API URLs) or as it went
            # on the wire (requestWillBeSentExtraInfo). Connect checks the token against the portal.
            $isRequest = $message.Contains('"method":"Network.requestWillBeSent"') -and $message.Contains('"url":"https://support.fortinet.com/app/')
            if ($isRequest -or $message.Contains('"method":"Network.requestWillBeSentExtraInfo"')) {
                $match = $authPattern.Match($message)
                if ($match.Success) { $token = $match.Groups['token'].Value }
            }
        }
    } finally {
        $socket.Dispose()
    }
    if (-not $token) {
        if ($Wait) { throw "No support portal login was seen within $TimeoutSeconds seconds." }
        throw 'The Fortinet support portal did not issue a new token (the SSO session has probably ended). Run Connect-NSPFortinetSupport again.'
    }
    [pscustomobject]@{ Token = $token; UserAgent = [string]$version.'User-Agent'; Expires = ConvertFrom-NSPFortinetToken -Token $token }
}