Private/Session.ps1
|
# The session: who is signed in where, with which tokens. It lives in module scope and in memory only (no disk, # no environment variable). Tokens are SecureStrings, read back to text only to write the Authorization header # and the refresh request. # # A refresh token is single-use: it rotates on every refresh, and presenting a spent one revokes the whole # session family on the server. So a session refreshes in exactly one place, this module's scope, under its # Lock (see Update-VMetricAccessToken), and no copy of a refresh token ever leaves it: a deployment running as a # job reads its parent session's current access token and never refreshes (New-VMetricJobSession), and a # tenant switch, which spends the refresh token it presents, retires the session it switched from # (Invoke-VMetricTenantSwitch). # # A session knows two addresses (see Private/Endpoint.ps1): ApiUrl, where every request goes # (https://api.example.com/api/), and ConsoleUrl, the console's origin, where the browser signs in. function ConvertTo-VMetricSecureString { [CmdletBinding()] [OutputType([System.Security.SecureString])] param( [Parameter(Mandatory)] [string] $String ) $secure = [System.Net.NetworkCredential]::new('', $String).SecurePassword $secure.MakeReadOnly() $secure } function ConvertFrom-VMetricSecureString { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Security.SecureString] $SecureString ) [System.Net.NetworkCredential]::new('', $SecureString).Password } function New-VMetricSession { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory session object; changes no state.')] [CmdletBinding()] [OutputType([hashtable])] param( # The API's URL (https://api.example.com/api/), or an origin serving it under /api/. [Parameter(Mandatory)] [Alias('Origin')] [string] $ApiUrl, # The console's origin; the API's origin when not given. [string] $ConsoleUrl, [Parameter(Mandatory)] [ValidateSet('Browser', 'DeviceCode', 'ApiToken')] [string] $AuthMethod, # A token response of POST /cli/token. [object] $TokenResponse, # An API token, used as the Bearer as it is. [System.Security.SecureString] $ApiToken, [switch] $SkipCertificateCheck ) $base = ConvertTo-VMetricApiBase -Url $ApiUrl $apiOrigin = ([uri]$base).GetLeftPart([System.UriPartial]::Authority) if (-not $ConsoleUrl) { $ConsoleUrl = $apiOrigin } $session = [hashtable]::Synchronized(@{ ApiUrl = $base ConsoleUrl = $ConsoleUrl.TrimEnd('/') # The API's origin, for messages. Origin = $apiOrigin AuthMethod = $AuthMethod Client = 'powershell' TenantId = $null TenantName = $null UserId = $null Email = $null AccessToken = $null AccessExpiresAt = $null RefreshToken = $null SkipCertificateCheck = $SkipCertificateCheck.IsPresent Generation = 0 ConnectedAt = [System.DateTimeOffset]::UtcNow Lock = [object]::new() # Set on a job's view (New-VMetricJobSession): it reads TokenSource's access token and never # refreshes. IsJobSnapshot = $false TokenSource = $null }) if ($ApiToken) { $copy = $ApiToken.Copy() $copy.MakeReadOnly() $session.AccessToken = $copy } if ($TokenResponse) { Update-VMetricSessionToken -Session $session -TokenResponse $TokenResponse } $session } $script:VMetricJobSessionEnded = 'The session this job runs on can no longer be used (it expired, or was signed out or switched to another tenant), and a job never refreshes it. Run Connect-VMetric again, or rerun without -AsJob.' function New-VMetricJobSession { # What a thread job gets instead of the session: a view of it, holding no token of its own. For every # request the job reads the session's current access token under the session's Lock (Get-VMetricBearerToken), # so a refresh in the parent reaches the job; the job itself never refreshes, so the refresh token is spent # in one place only. Signing out, or switching away from the session, ends the job's access too. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory session object; changes no state.')] [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) if ($Session.IsJobSnapshot -and $Session.TokenSource) { $Session = $Session.TokenSource } [System.Threading.Monitor]::Enter($Session.Lock) try { if (-not $Session.AccessToken) { throw (New-VMetricException -Message 'Your session has ended. Run Connect-VMetric to sign in again.' -Code 'SessionExpired' ` -Category AuthenticationError -Terminating) } $view = New-VMetricSession -ApiUrl $Session.ApiUrl -ConsoleUrl $Session.ConsoleUrl -AuthMethod $Session.AuthMethod ` -SkipCertificateCheck:([bool]$Session.SkipCertificateCheck) $view.AccessExpiresAt = $Session.AccessExpiresAt $view.TenantId = $Session.TenantId $view.TenantName = $Session.TenantName $view.UserId = $Session.UserId $view.Email = $Session.Email $view.IsJobSnapshot = $true $view.TokenSource = $Session } finally { [System.Threading.Monitor]::Exit($Session.Lock) } $view } function Update-VMetricSessionToken { # Applies a token response to a session: the new access token and its expiry, the rotated refresh token, # and the tenant and user it is for. The tokens it replaces are zeroed. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Updates the in-memory session only.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [Parameter(Mandatory)] [object] $TokenResponse ) $accessToken = [string](Get-VMetricMember -InputObject $TokenResponse -Name 'accessToken') if (-not $accessToken) { throw (New-VMetricException -Message 'The sign-in answer carried no access token.' -Code 'InvalidTokenResponse' -Category InvalidResult) } $expiresIn = Get-VMetricMember -InputObject $TokenResponse -Name 'expiresIn' if (-not $expiresIn -or [long]$expiresIn -le 0) { $expiresIn = 3600 } $previousAccess = $Session.AccessToken $Session.AccessToken = ConvertTo-VMetricSecureString -String $accessToken $Session.AccessExpiresAt = [System.DateTimeOffset]::UtcNow.AddSeconds([double]$expiresIn) if ($previousAccess) { $previousAccess.Dispose() } $refreshToken = [string](Get-VMetricMember -InputObject $TokenResponse -Name 'refreshToken') if ($refreshToken) { $previousRefresh = $Session.RefreshToken $Session.RefreshToken = ConvertTo-VMetricSecureString -String $refreshToken if ($previousRefresh) { $previousRefresh.Dispose() } } $tenant = Get-VMetricMember -InputObject $TokenResponse -Name 'tenant' if ($tenant) { $Session.TenantId = [string](Get-VMetricMember -InputObject $tenant -Name 'id') $Session.TenantName = [string](Get-VMetricMember -InputObject $tenant -Name 'name') } $user = Get-VMetricMember -InputObject $TokenResponse -Name 'user' if ($user) { $Session.UserId = [string](Get-VMetricMember -InputObject $user -Name 'id') $Session.Email = [string](Get-VMetricMember -InputObject $user -Name 'email') } $Session.Generation = [int]$Session.Generation + 1 } function Get-VMetricSessionKey { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) '{0}|{1}|{2}' -f $Session.ApiUrl, $Session.TenantId, $(if ($Session.AuthMethod -eq 'ApiToken') { 'api' } else { 'user' }) } function Get-VMetricSession { # The current session; with -Optional, $null when there is none. Without it, not being connected is a # terminating error that says what to run. [CmdletBinding()] [OutputType([hashtable])] param( [switch] $Optional ) if ($script:VMetricSession) { return $script:VMetricSession } if ($Optional) { return $null } throw (New-VMetricException -Message 'You are not connected. Run Connect-VMetric first.' -Code 'NotConnected' ` -Category AuthenticationError -RecommendedAction 'Run Connect-VMetric -Uri <console address>.' -Terminating) } function Set-VMetricCurrentSession { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Sets module state only; the public cmdlets that call it support ShouldProcess where it matters.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) $script:VMetricSessions[(Get-VMetricSessionKey -Session $Session)] = $Session $script:VMetricSession = $Session } function Clear-VMetricSessionToken { # Zeroes a session's tokens, under its Lock, so they can never be sent again. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, # Only the refresh token (a tenant switch spent it; the access token stays valid until it expires). [switch] $RefreshOnly ) $names = if ($RefreshOnly) { @('RefreshToken') } else { @('AccessToken', 'RefreshToken') } [System.Threading.Monitor]::Enter($Session.Lock) try { foreach ($name in $names) { if ($Session[$name]) { $Session[$name].Dispose() $Session[$name] = $null } } $Session.Generation = [int]$Session.Generation + 1 } finally { [System.Threading.Monitor]::Exit($Session.Lock) } } function Remove-VMetricSession { # Forgets a session: no longer held or current, its tokens zeroed. Nothing is revoked on the server. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes module state only; the public cmdlets that call it ask ShouldProcess.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) Clear-VMetricSessionToken -Session $Session foreach ($key in @($script:VMetricSessions.Keys)) { if ([object]::ReferenceEquals($script:VMetricSessions[$key], $Session)) { $script:VMetricSessions.Remove($key) } } if ([object]::ReferenceEquals($script:VMetricSession, $Session)) { $script:VMetricSession = $null } } function Register-VMetricSession { # Makes a new session current and holds it. A different session already held for the same API, tenant and # kind (signing in again) is ended on the server and forgotten. With -NotCurrent the session is only held # (Set-VMetricContext can switch to it), unless it replaces the current one, whose place it then takes. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [switch] $NotCurrent ) $key = Get-VMetricSessionKey -Session $Session $replacedCurrent = $false if ($script:VMetricSessions.Contains($key)) { $previous = $script:VMetricSessions[$key] if (-not [object]::ReferenceEquals($previous, $Session)) { $replacedCurrent = [object]::ReferenceEquals($previous, $script:VMetricSession) Stop-VMetricSessionOnServer -Session $previous -Quiet Remove-VMetricSession -Session $previous } } if ($NotCurrent -and -not $replacedCurrent) { $script:VMetricSessions[$key] = $Session return } Set-VMetricCurrentSession -Session $Session } function Get-VMetricCachedSession { # Every session held, current first. [CmdletBinding()] [OutputType([hashtable[]])] param() $list = [System.Collections.Generic.List[object]]::new() if ($script:VMetricSession) { $list.Add($script:VMetricSession) } foreach ($session in $script:VMetricSessions.Values) { if (-not [object]::ReferenceEquals($session, $script:VMetricSession)) { $list.Add($session) } } return , $list.ToArray() } function Clear-VMetricSessionState { # Forgets every session and zeroes its tokens. [CmdletBinding()] param() foreach ($session in (Get-VMetricCachedSession)) { Clear-VMetricSessionToken -Session $session } $script:VMetricSessions.Clear() $script:VMetricSession = $null } function ConvertTo-VMetricContextObject { # The public view of a session: VirtualMetric.Context. It never carries a token. [CmdletBinding()] [OutputType('VirtualMetric.Context')] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) $expiresOn = $null if ($Session.AccessExpiresAt) { $expiresOn = ([System.DateTimeOffset]$Session.AccessExpiresAt).LocalDateTime } $account = $Session.Email if ($Session.AuthMethod -eq 'ApiToken') { $account = 'API token' } [pscustomobject]@{ PSTypeName = 'VirtualMetric.Context' # Uri is the console's address, as before the API and the console could be apart. Uri = $Session.ConsoleUrl ConsoleUrl = $Session.ConsoleUrl ApiUrl = $Session.ApiUrl Account = $account TenantName = $Session.TenantName TenantId = $Session.TenantId UserId = $Session.UserId AuthMethod = $Session.AuthMethod Client = $Session.Client ExpiresOn = $expiresOn SkipCertificateCheck = [bool]$Session.SkipCertificateCheck IsCurrent = [object]::ReferenceEquals($Session, $script:VMetricSession) } } |