Private/Connection.ps1
|
# The session's connection and every call to PagerArchive's API. # # $script:Connection holds: Uri (the app's origin), Issuer, ClientId, Scope, # the Authentik endpoints, AccessToken and ExpiresAt, RefreshToken (kept in # memory only for a session-only sign-in, and between saves), Store, Vault, # Version (of the saved copy) and AllowUnencrypted. function Get-PaOrigin { <# .SYNOPSIS The origin of a URL (scheme, host and port), checking its scheme. #> [CmdletBinding()] [OutputType([string])] param( # The URL. [Parameter(Mandatory)] [uri] $Uri, # Allow http://, with a warning. [switch] $AllowUnencryptedAuthentication, # What the URL is, for messages. [string] $What = 'PagerArchive' ) if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -notin @('http', 'https')) { throw (New-PaErrorRecord -ErrorId 'bad_uri' -Category InvalidArgument -Message "Give $What's URL in full, like https://pager.example.org.") } if ($Uri.Scheme -eq 'http' -and -not $AllowUnencryptedAuthentication) { throw (New-PaErrorRecord -ErrorId 'unencrypted_refused' -Category SecurityError ` -Message "$What's URL ($($Uri.GetLeftPart([System.UriPartial]::Authority))) uses plain HTTP, which would send your sign-in unencrypted. Use its https:// URL, or for a test copy on your own network add -AllowUnencryptedAuthentication.") } $Uri.GetLeftPart([System.UriPartial]::Authority) } function Get-PaServerSetting { <# .SYNOPSIS Reads what the module needs from the app's URL alone (R60), and the issuer's endpoints. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingAllowUnencryptedAuthentication', '', Justification = 'Only when the user passes -AllowUnencryptedAuthentication for a LAN test copy, with a warning (guidelines section 9).')] [CmdletBinding()] [OutputType([hashtable])] param( # The app's origin. [Parameter(Mandatory)] [string] $Origin, # Allow http://. [switch] $AllowUnencryptedAuthentication ) try { Write-Verbose -Message 'GET /auth/powershell' $settings = Invoke-RestMethod -Uri "$Origin/auth/powershell" -Method Get -TimeoutSec 30 -Verbose:$false -ErrorAction Stop } catch { throw (New-PaErrorRecord -ErrorId 'server_unreachable' -Category ConnectionError -Message "PagerArchive can't be reached at ${Origin}, or it doesn't offer PowerShell sign-in: $($_.Exception.Message)") } $issuerOrigin = Get-PaOrigin -Uri $settings.issuer -AllowUnencryptedAuthentication:$AllowUnencryptedAuthentication -What 'Authentik' try { Write-Verbose -Message 'GET /.well-known/openid-configuration (Authentik)' $discovery = Invoke-RestMethod -Uri ($settings.issuer.TrimEnd('/') + '/.well-known/openid-configuration') -Method Get -TimeoutSec 30 -Verbose:$false -ErrorAction Stop } catch { throw (New-PaErrorRecord -ErrorId 'authentik_unreachable' -Category ConnectionError -Message "Authentik can't be reached at ${issuerOrigin}: $($_.Exception.Message)") } $endpoints = @{ Authorize = $discovery.authorization_endpoint Token = $discovery.token_endpoint Revocation = $discovery.revocation_endpoint Device = $discovery.device_authorization_endpoint } # The module only ever talks to PagerArchive and its Authentik. foreach ($name in @($endpoints.Keys)) { if (-not $endpoints[$name] -or ([uri]$endpoints[$name]).GetLeftPart([System.UriPartial]::Authority) -ne $issuerOrigin) { throw (New-PaErrorRecord -ErrorId 'authentik_misconfigured' -Category InvalidData -Message "Authentik's $name endpoint isn't at $issuerOrigin, so the module won't use it.") } } @{ Issuer = $settings.issuer ClientId = $settings.client_id Scope = $settings.scope Endpoints = $endpoints } } function Get-PaConnection { <# .SYNOPSIS The session's connection, or the saved sign-in when this session has none. #> [CmdletBinding()] [OutputType([hashtable])] param() if ($script:Connection) { return $script:Connection } $saved = @(Read-PaSignIn) if ($saved.Count -eq 0) { throw (New-PaErrorRecord -ErrorId 'not_connected' -Category AuthenticationError -Message 'Not signed in to PagerArchive. Run Connect-PagerArchive -Uri <the app''s URL>.') } if ($saved.Count -gt 1) { $list = ($saved | ForEach-Object -Process { $_.uri }) -join ', ' throw (New-PaErrorRecord -ErrorId 'several_servers' -Category InvalidOperation -Message "You're signed in to several PagerArchive servers ($list). Choose one with Connect-PagerArchive -Uri <URL>.") } Open-PaSavedSignIn -Record $saved[0] } function Open-PaSavedSignIn { <# .SYNOPSIS Makes a saved sign-in this session's connection, renewing its access token. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingAllowUnencryptedAuthentication', '', Justification = 'Only when the user passes -AllowUnencryptedAuthentication for a LAN test copy, with a warning (guidelines section 9).')] [CmdletBinding()] [OutputType([hashtable])] param( # The saved sign-in's record (Read-PaSignIn). [Parameter(Mandatory)] [hashtable] $Record ) $allow = [bool]$Record.allowUnencryptedAuthentication $origin = Get-PaOrigin -Uri $Record.uri -AllowUnencryptedAuthentication:$allow if ($allow -and $origin.StartsWith('http:')) { Write-Warning -Message "Using your saved sign-in to $origin over plain HTTP, unencrypted." } $settings = Get-PaServerSetting -Origin $origin -AllowUnencryptedAuthentication:$allow $connection = @{ Uri = $origin Issuer = $settings.Issuer ClientId = $settings.ClientId Scope = $settings.Scope Endpoints = $settings.Endpoints AccessToken = $null ExpiresAt = [datetimeoffset]::MinValue RefreshToken = $null Store = $Record.store Vault = $Record.vault Version = $null AllowUnencrypted = $allow User = $null } Request-PaToken -Connection $connection $script:Connection = $connection $connection } function Request-PaToken { <# .SYNOPSIS Renews a connection's access token with its refresh token. .DESCRIPTION Authentik issues a new refresh token each time and revokes the old one (ADR 0009 section 4), so the new one is saved at once. Several sessions share one saved sign-in: the saved token is read first, and if Authentik refuses it because another session renewed meanwhile, the newer one is read and tried once more. A sign-in Authentik refuses has ended: the saved copy is deleted. #> [CmdletBinding()] param( # The connection to renew. [Parameter(Mandatory)] [hashtable] $Connection ) for ($attempt = 1; $attempt -le 3; $attempt++) { if ($Connection.Store -ne 'session') { $saved = Read-PaRefreshToken -Origin $Connection.Uri if (-not $saved) { throw (New-PaErrorRecord -ErrorId 'sign_in_ended' -Category AuthenticationError -Message 'Your PagerArchive sign-in has ended. Run Connect-PagerArchive.') } $Connection.RefreshToken = $saved.RefreshToken $Connection.Version = $saved.Version } $form = [ordered]@{ grant_type = 'refresh_token' refresh_token = $Connection.RefreshToken client_id = $Connection.ClientId } $reply = Invoke-PaTokenRequest -Uri $Connection.Endpoints.Token -Form $form if ($reply.Status -eq 200 -and $reply['access_token']) { Import-PaTokenReply -Connection $Connection -Reply $reply Save-PaSignIn -Connection $Connection return } if ($reply['error'] -ne 'invalid_grant') { throw (New-PaErrorRecord -ErrorId 'renewal_failed' -Status $reply.Status -Message "Authentik didn't renew your sign-in ($($reply['error']))." -Category AuthenticationError) } # Another session may have renewed it just now: try the newer one. if ($Connection.Store -ne 'session') { Start-Sleep -Milliseconds 300 $now = Read-PaSignIn -Origin $Connection.Uri if ($now -and $now.version -ne $Connection.Version) { continue } Remove-PaSignIn -Origin $Connection.Uri -WhatIf:$false -Confirm:$false } break } if ($script:Connection -and $script:Connection.Uri -eq $Connection.Uri) { $script:Connection = $null } throw (New-PaErrorRecord -ErrorId 'sign_in_ended' -Category AuthenticationError -Message 'Your PagerArchive sign-in has ended. Run Connect-PagerArchive.') } function Import-PaTokenReply { <# .SYNOPSIS Takes the tokens from a token reply into a connection. #> [CmdletBinding()] param( # The connection. [Parameter(Mandatory)] [hashtable] $Connection, # The token endpoint's reply. [Parameter(Mandatory)] [hashtable] $Reply ) $Connection.AccessToken = $Reply['access_token'] $Connection.ExpiresAt = [datetimeoffset]::UtcNow.AddSeconds([double]$Reply['expires_in']) if ($Reply['refresh_token']) { $Connection.RefreshToken = $Reply['refresh_token'] } } function ConvertTo-PaQueryString { <# .SYNOPSIS Builds a query string from names and values; arrays repeat the name. #> [CmdletBinding()] [OutputType([string])] param( # Names and values. [System.Collections.IDictionary] $Query ) if (-not $Query -or $Query.Count -eq 0) { return '' } $parts = foreach ($key in $Query.Keys) { foreach ($value in @($Query[$key])) { if ($null -ne $value -and "$value" -ne '') { [uri]::EscapeDataString($key) + '=' + [uri]::EscapeDataString([string]$value) } } } if (-not $parts) { return '' } '?' + ($parts -join '&') } function Invoke-PaApi { <# .SYNOPSIS Calls PagerArchive's API as the signed-in admin. .DESCRIPTION Renews the access token when it has under 60 seconds left. A refusal becomes an ErrorRecord (guidelines section 7), thrown for the caller to catch. HTTP errors don't throw inside Invoke-RestMethod (-SkipHttpErrorCheck), so no exception holding the request, and its Authorization header, ever reaches $Error. -Verbose shows the method, the path and the query's names only, because values could hold page text (P1). #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingAllowUnencryptedAuthentication', '', Justification = 'Only when the user passes -AllowUnencryptedAuthentication for a LAN test copy, with a warning (guidelines section 9).')] [CmdletBinding()] param( # GET, POST, PUT, PATCH or DELETE. [Parameter(Mandatory)] [string] $Method, # The path, e.g. /api/v1/admin/templates. [Parameter(Mandatory)] [string] $Path, # Query parameters; arrays repeat the name. [System.Collections.IDictionary] $Query, # A body, sent as JSON. [object] $Body, # A body sent as it is, as application/octet-stream (an icon file). [byte[]] $RawBody, # Statuses that aren't errors, besides 2xx (e.g. 409 for the import). [int[]] $Accept = @() ) $connection = Get-PaConnection for ($try = 1; $try -le 2; $try++) { if ($connection.ExpiresAt -lt [datetimeoffset]::UtcNow.AddSeconds(60)) { Request-PaToken -Connection $connection } $names = @(if ($Query) { $Query.Keys | Where-Object -FilterScript { $null -ne $Query[$_] -and "$($Query[$_])" -ne '' } }) $shown = if ($names.Count) { " ($($names -join ', '))" } else { '' } Write-Verbose -Message "$Method $Path$shown" $request = @{ Uri = $connection.Uri + $Path + (ConvertTo-PaQueryString -Query $Query) Method = $Method Authentication = 'Bearer' Token = $connection.AccessToken AllowUnencryptedAuthentication = [bool]$connection.AllowUnencrypted SkipHttpErrorCheck = $true StatusCodeVariable = 'status' TimeoutSec = 120 Verbose = $false Debug = $false ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { $request.Body = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 8 -Compress)) $request.ContentType = 'application/json; charset=utf-8' } if ($PSBoundParameters.ContainsKey('RawBody')) { $request.Body = $RawBody $request.ContentType = 'application/octet-stream' } try { $reply = Invoke-RestMethod @request } catch { throw (New-PaErrorRecord -ErrorId 'server_unreachable' -Category ConnectionError -Message "PagerArchive can't be reached at $($connection.Uri): $($_.Exception.GetBaseException().Message)") } Write-Debug -Message "$Method $Path answered $status" if ($status -lt 300 -or $status -in $Accept) { return $reply } $code = $null $message = $null if ($reply -is [psobject] -and $reply.PSObject.Properties['error']) { $message = $reply.error if ($reply.PSObject.Properties['code']) { $code = $reply.code } } # An access token Authentik has since refused, or one that expired # early: renew once and try again. if ($status -eq 401 -and $code -eq 'invalid_token' -and $try -eq 1) { $connection.ExpiresAt = [datetimeoffset]::MinValue continue } if (-not $code) { $code = "http_$status" } if (-not $message) { $message = "PagerArchive answered $status." } throw (New-PaErrorRecord -ErrorId $code -Status $status -Message $message) } } |