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 } } |