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