Private/Auth.ps1

# Sign-in. Two interactive flows, both approved in the console by a signed-in user who picks the tenant:
# - the browser, with PKCE (RFC 7636, S256) and a loopback redirect to 127.0.0.1 (RFC 8252);
# - a device code (RFC 8628), for Linux without a display, SSH sessions, or when no browser opens.
# Both end at POST /api/v1/cli/token, which answers {accessToken, refreshToken, expiresIn, tenant, user}.

function ConvertTo-VMetricBase64Url {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [byte[]] $Bytes
    )

    [System.Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function New-VMetricRandomToken {
    # URL-safe random text: ByteCount bytes from the operating system's cryptographic generator, base64url.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates a string; changes no state.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [ValidateRange(16, 96)]
        [int] $ByteCount = 32
    )

    ConvertTo-VMetricBase64Url -Bytes ([System.Security.Cryptography.RandomNumberGenerator]::GetBytes($ByteCount))
}

function Get-VMetricPkceChallenge {
    # RFC 7636 S256: BASE64URL(SHA256(ASCII(code_verifier))).
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $Verifier
    )

    $bytes = [System.Text.Encoding]::ASCII.GetBytes($Verifier)
    ConvertTo-VMetricBase64Url -Bytes ([System.Security.Cryptography.SHA256]::HashData($bytes))
}

function New-VMetricPkcePair {
    # A code verifier from 64 random bytes (86 characters, inside RFC 7636's 43 to 128) and its S256 challenge.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates strings; changes no state.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    $verifier = New-VMetricRandomToken -ByteCount 64
    [pscustomobject]@{
        Verifier  = $verifier
        Challenge = Get-VMetricPkceChallenge -Verifier $verifier
        Method    = 'S256'
    }
}

function Test-VMetricStateMatch {
    # Compares the state the browser brought back with the one this sign-in sent, in constant time.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Actual,

        [Parameter(Mandatory)]
        [string] $Expected
    )

    if ([string]::IsNullOrEmpty($Actual)) {
        return $false
    }
    $actualBytes = [System.Text.Encoding]::UTF8.GetBytes($Actual)
    $expectedBytes = [System.Text.Encoding]::UTF8.GetBytes($Expected)
    if ($actualBytes.Length -ne $expectedBytes.Length) {
        return $false
    }
    $difference = 0
    for ($i = 0; $i -lt $actualBytes.Length; $i++) {
        $difference = $difference -bor ($actualBytes[$i] -bxor $expectedBytes[$i])
    }
    return ($difference -eq 0)
}

function Test-VMetricHeadless {
    # True where a browser cannot be shown: over SSH, or on Linux with neither X11 nor Wayland.
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    if ($env:SSH_CONNECTION -or $env:SSH_CLIENT -or $env:SSH_TTY) {
        return $true
    }
    if ($IsLinux -and -not $env:DISPLAY -and -not $env:WAYLAND_DISPLAY) {
        return $true
    }
    return $false
}

function Open-VMetricBrowser {
    # Opens the system browser: Start-Process on Windows, open on macOS, xdg-open on Linux. False when that
    # cannot be done, so the caller falls back to the device code.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string] $Url
    )

    # Start-Process opens whatever it is given, a file or another protocol's handler as readily as a page: only
    # an http or https URL goes to it. (Its origin was validated long before; this is the last line.)
    $parsed = $null
    if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref] $parsed) -or ($parsed.Scheme -ne 'https' -and $parsed.Scheme -ne 'http')) {
        Write-Verbose 'Not opening a browser for an address that is not a web page.'
        return $false
    }
    try {
        if ($IsWindows) {
            $null = Start-Process -FilePath $parsed.AbsoluteUri -ErrorAction Stop
            return $true
        }
        $opener = if ($IsMacOS) { 'open' } else { 'xdg-open' }
        $command = Get-Command -Name $opener -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        if (-not $command) {
            Write-Verbose "No $opener on this machine."
            return $false
        }
        $process = Start-Process -FilePath $command.Source -ArgumentList ('"{0}"' -f $parsed.AbsoluteUri) -PassThru -ErrorAction Stop
        if ($process.WaitForExit(5000)) {
            return ($process.ExitCode -eq 0)
        }
        return $true
    }
    catch {
        Write-Verbose "Could not open a browser: $($_.Exception.Message)"
        return $false
    }
}

function Start-VMetricLoopbackListener {
    # An HttpListener on http://127.0.0.1:<free port>/ for the browser's redirect. Only this machine can reach
    # it, and it lives only while the sign-in waits.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Opens a short-lived loopback listener for the sign-in the user asked for.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    for ($attempt = 1; $attempt -le 5; $attempt++) {
        $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
        $probe.Start()
        $port = ([System.Net.IPEndPoint]$probe.LocalEndpoint).Port
        $probe.Stop()

        $listener = [System.Net.HttpListener]::new()
        $listener.Prefixes.Add("http://127.0.0.1:$port/")
        try {
            $listener.Start()
            return [pscustomobject]@{
                Listener    = $listener
                Port        = $port
                RedirectUri = "http://127.0.0.1:$port/callback"
                Pending     = $null
            }
        }
        catch {
            $listener.Close()
            if ($attempt -eq 5) {
                throw (New-VMetricException -Message "Could not listen on 127.0.0.1 for the sign-in to come back: $($_.Exception.Message) Try Connect-VMetric -UseDeviceAuthentication." `
                        -Code 'LoopbackUnavailable' -Category ResourceUnavailable -InnerException $_.Exception)
            }
        }
    }
}

function Stop-VMetricLoopbackListener {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Closes the listener this module opened.')]
    [CmdletBinding()]
    param(
        [AllowNull()]
        [pscustomobject] $Loopback
    )

    if (-not $Loopback -or -not $Loopback.Listener) {
        return
    }
    try {
        if ($Loopback.Listener.IsListening) {
            $Loopback.Listener.Stop()
        }
        $Loopback.Listener.Close()
    }
    catch {
        Write-Verbose "Closing the loopback listener: $($_.Exception.Message)"
    }
}

function Receive-VMetricLoopbackRequest {
    # The next request the listener gets, or $null when the deadline (UTC) passes first. Waits in short slices
    # so Ctrl+C stops the wait.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [pscustomobject] $Loopback,

        [Parameter(Mandatory)]
        [datetime] $Deadline
    )

    if (-not $Loopback.Pending) {
        $Loopback.Pending = $Loopback.Listener.GetContextAsync()
    }
    while (-not $Loopback.Pending.Wait(250)) {
        if ([datetime]::UtcNow -ge $Deadline) {
            return $null
        }
    }
    $context = $Loopback.Pending.Result
    $Loopback.Pending = $null

    $query = @{}
    $parameters = $context.Request.QueryString
    foreach ($key in $parameters.AllKeys) {
        if ($key) {
            $query[$key] = $parameters[$key]
        }
    }
    [pscustomobject]@{
        Method  = $context.Request.HttpMethod
        Path    = $context.Request.Url.AbsolutePath
        Query   = $query
        Context = $context
    }
}

function Send-VMetricLoopbackResponse {
    # Answers the browser with a small page that says what happened. Nothing from the request is echoed.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject] $Request,

        [int] $StatusCode = 200,

        [Parameter(Mandatory)]
        [string] $Title,

        [Parameter(Mandatory)]
        [string] $Message
    )

    $encodedTitle = [System.Net.WebUtility]::HtmlEncode($Title)
    $encodedMessage = [System.Net.WebUtility]::HtmlEncode($Message)
    $html = @"
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>VirtualMetric: $encodedTitle</title>
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;background:#f4f4f4;color:#161616}
main{background:#fff;padding:32px 40px;max-width:480px;box-shadow:0 1px 3px rgba(0,0,0,.2)}h1{font-size:20px;font-weight:600;margin:0 0 12px}p{margin:0;line-height:1.5}
@media (prefers-color-scheme:dark){body{background:#161616;color:#f4f4f4}main{background:#262626}}</style></head>
<body><main><h1>$encodedTitle</h1><p>$encodedMessage</p></main></body></html>
"@


    $response = $Request.Context.Response
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($html)
        $response.StatusCode = $StatusCode
        $response.ContentType = 'text/html; charset=utf-8'
        $response.Headers['Cache-Control'] = 'no-store'
        $response.Headers['Referrer-Policy'] = 'no-referrer'
        $response.Headers['X-Content-Type-Options'] = 'nosniff'
        $response.Headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'"
        $response.ContentLength64 = $bytes.Length
        $response.OutputStream.Write($bytes, 0, $bytes.Length)
    }
    catch {
        Write-Verbose "Could not answer the browser: $($_.Exception.Message)"
    }
    finally {
        $response.Close()
    }
}

function Wait-VMetricAuthorizationCode {
    # Waits for the browser to come back to /callback with the code. A request whose state is not this
    # sign-in's is answered and ignored, so a stray or forged request can neither complete nor end it; the
    # state is checked before anything else, an error included. An error from the console with this state
    # ends the sign-in at once (Cancel in the console answers error=access_denied); so does the deadline.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [pscustomobject] $Loopback,

        [Parameter(Mandatory)]
        [string] $State,

        [int] $TimeoutSec = 300
    )

    $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSec)
    while ($true) {
        $request = Receive-VMetricLoopbackRequest -Loopback $Loopback -Deadline $deadline
        if (-not $request) {
            throw (New-VMetricException -Message "The sign-in was not completed within $([Math]::Round($TimeoutSec / 60)) minutes. Run Connect-VMetric again, or use -UseDeviceAuthentication." `
                    -Code 'SignInTimedOut' -Category OperationTimeout)
        }
        if ($request.Method -ne 'GET' -or $request.Path -ne '/callback') {
            Send-VMetricLoopbackResponse -Request $request -StatusCode 404 -Title 'Not found' -Message 'This address only receives the VirtualMetric sign-in.'
            continue
        }
        $query = $request.Query
        if (-not (Test-VMetricStateMatch -Actual ([string]$query['state']) -Expected $State)) {
            Send-VMetricLoopbackResponse -Request $request -StatusCode 400 -Title 'Sign-in not recognized' `
                -Message 'This sign-in does not belong to the PowerShell session waiting on this machine. Start the sign-in again from PowerShell.'
            Write-Warning 'Ignored a sign-in response whose state did not match this sign-in.'
            continue
        }
        if ($query['error']) {
            $reason = [string]$query['error']
            if ($reason -eq 'access_denied') {
                Send-VMetricLoopbackResponse -Request $request -Title 'Sign-in cancelled' -Message 'You can close this tab and return to PowerShell.'
                throw (New-VMetricException -Message 'Sign-in was cancelled in the browser.' -Code 'SignInCancelled' -Category OperationStopped)
            }
            Send-VMetricLoopbackResponse -Request $request -Title 'Sign-in not completed' -Message 'You can close this tab and return to PowerShell.'
            # An OAuth error code is a short run of printable ASCII (RFC 6749 section 4.1.2.1); anything else is
            # not shown as one.
            if ($reason -notmatch '^[\x20\x21\x23-\x5B\x5D-\x7E]{1,64}$') {
                $reason = 'unknown_error'
            }
            $description = ConvertTo-VMetricSafeText -Text ([string]$query['error_description'])
            if ($description.Length -gt 200) {
                $description = $description.Substring(0, 200) + '...'
            }
            $text = if ($description) { "$reason, $description" } else { $reason }
            throw (New-VMetricException -Message "The sign-in failed in the browser ($text). Run Connect-VMetric again." -Code 'SignInFailed' -Category AuthenticationError)
        }
        if (-not $query['code']) {
            Send-VMetricLoopbackResponse -Request $request -StatusCode 400 -Title 'Sign-in incomplete' -Message 'The console sent no authorization code. Start the sign-in again from PowerShell.'
            continue
        }
        Send-VMetricLoopbackResponse -Request $request -Title 'Signed in to VirtualMetric' -Message 'You can close this tab and return to PowerShell.'
        return [string]$query['code']
    }
}

function Invoke-VMetricTokenRequest {
    # POST <API>/v1/cli/token with one of its grants.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ApiUrl,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Body,

        [switch] $SkipCertificateCheck
    )

    Invoke-VMetricRequest -Method POST -Path '/cli/token' -Anonymous -ApiUrl $ApiUrl -SkipCertificateCheck:$SkipCertificateCheck -NoConflictRetry -Body $Body
}

function Invoke-VMetricBrowserSignIn {
    # The browser flow: the console's authorize page, then the code exchanged at the API. Answers the token
    # response, or $null when no browser could be opened (the caller then falls back to the device code).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ApiUrl,

        # The console's origin, validated (Resolve-VMetricEndpoint).
        [Parameter(Mandatory)]
        [string] $ConsoleUrl,

        [string] $TenantHint,

        [switch] $SkipCertificateCheck,

        [int] $TimeoutSec = 300
    )

    $pkce = New-VMetricPkcePair
    $state = New-VMetricRandomToken -ByteCount 32
    $loopback = Start-VMetricLoopbackListener
    try {
        $query = [ordered]@{
            client_id             = 'powershell'
            redirect_uri          = $loopback.RedirectUri
            code_challenge        = $pkce.Challenge
            code_challenge_method = 'S256'
            state                 = $state
        }
        if ($TenantHint) {
            $query['tenant'] = $TenantHint
        }
        $pairs = foreach ($key in $query.Keys) {
            '{0}={1}' -f $key, [System.Uri]::EscapeDataString([string]$query[$key])
        }
        $url = "$($ConsoleUrl.TrimEnd('/'))/cli/authorize?" + ($pairs -join '&')

        if (-not (Open-VMetricBrowser -Url $url)) {
            Write-Information 'No browser could be opened here; signing in with a device code instead.'
            return $null
        }
        Write-Information "Sign in to $ConsoleUrl in the browser window that opened (waiting up to $([Math]::Round($TimeoutSec / 60)) minutes; Ctrl+C stops). If no window opened, go to:$([Environment]::NewLine) $url"
        $code = Wait-VMetricAuthorizationCode -Loopback $loopback -State $state -TimeoutSec $TimeoutSec
    }
    finally {
        Stop-VMetricLoopbackListener -Loopback $loopback
    }

    Invoke-VMetricTokenRequest -ApiUrl $ApiUrl -SkipCertificateCheck:$SkipCertificateCheck -Body ([ordered]@{
            grantType    = 'authorization_code'
            code         = $code
            codeVerifier = $pkce.Verifier
            redirectUri  = $loopback.RedirectUri
        })
}

function Write-VMetricDeviceCodePrompt {
    # The one place the module writes to the host directly: the person signing in has to see the code even
    # when the output streams are redirected or silenced.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'The device code prompt must reach the person signing in whatever the streams are redirected to.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $DeviceCode,

        # The console's origin, validated (Resolve-VMetricEndpoint): its device page is where the code is
        # entered.
        [Parameter(Mandatory)]
        [string] $ConsoleUrl
    )

    $code = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $DeviceCode -Name 'userCode'))
    if ($code.Length -gt 32) {
        $code = $code.Substring(0, 32)
    }
    if ($code.Length -eq 8 -and -not $code.Contains('-')) {
        $code = $code.Substring(0, 4) + '-' + $code.Substring(4)
    }
    $page = "$($ConsoleUrl.TrimEnd('/'))/cli/device"
    Write-Host "To sign in, open $page and enter the code $code"
    if ($code) {
        Write-Host "Or open $($page)?code=$([System.Uri]::EscapeDataString($code))"
    }
}

function Invoke-VMetricDeviceSignIn {
    # The device code flow (RFC 8628): print the code, then poll on the interval the server gave, five seconds
    # slower after each slow_down, until the code is approved, denied or expires.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ApiUrl,

        [Parameter(Mandatory)]
        [string] $ConsoleUrl,

        [string] $TenantHint,

        [switch] $SkipCertificateCheck
    )

    $request = [ordered]@{ client = 'powershell' }
    if ($TenantHint) {
        $request['tenantHint'] = $TenantHint
    }
    $device = Invoke-VMetricRequest -Method POST -Path '/cli/device/code' -Anonymous -ApiUrl $ApiUrl `
        -SkipCertificateCheck:$SkipCertificateCheck -NoConflictRetry -Body $request
    $deviceCode = [string](Get-VMetricMember -InputObject $device -Name 'deviceCode')
    if (-not $deviceCode) {
        throw (New-VMetricException -Message 'The server answered no device code.' -Code 'InvalidDeviceCodeResponse' -Category InvalidResult)
    }

    Write-VMetricDeviceCodePrompt -DeviceCode $device -ConsoleUrl $ConsoleUrl

    $interval = [int](Get-VMetricMember -InputObject $device -Name 'interval')
    if ($interval -lt 1) {
        $interval = 5
    }
    $expiresIn = [int](Get-VMetricMember -InputObject $device -Name 'expiresIn')
    if ($expiresIn -lt 1) {
        $expiresIn = 900
    }
    $deadline = [datetime]::UtcNow.AddSeconds($expiresIn)
    $transientFailures = 0
    $grant = [ordered]@{
        grantType  = 'urn:ietf:params:oauth:grant-type:device_code'
        deviceCode = $deviceCode
    }

    while ($true) {
        Wait-VMetricInterval -Seconds $interval
        if ([datetime]::UtcNow -ge $deadline) {
            throw (New-VMetricException -Message 'The code expired before it was approved. Run Connect-VMetric again.' -Code 'expired_token' -Category AuthenticationError)
        }
        try {
            return (Invoke-VMetricTokenRequest -ApiUrl $ApiUrl -SkipCertificateCheck:$SkipCertificateCheck -Body $grant)
        }
        catch {
            if (Test-VMetricFlowControl -ErrorRecord $_) {
                throw
            }
            $failure = $_.Exception
            $code = ''
            $status = 0
            if ($failure -is [VMetricException]) {
                $code = $failure.Code
                $status = $failure.StatusCode
            }
            if ($code -eq 'authorization_pending') {
                Write-Verbose 'Waiting for the code to be approved in the console.'
            }
            elseif ($code -eq 'slow_down' -or $status -eq 429) {
                $interval += 5
                Write-Verbose "Polling every $interval s."
            }
            elseif ($code -eq 'expired_token') {
                throw (New-VMetricException -Message 'The code expired before it was approved. Run Connect-VMetric again.' -Code 'expired_token' `
                        -Category AuthenticationError -InnerException $failure)
            }
            elseif ($code -eq 'access_denied') {
                throw (New-VMetricException -Message 'The sign-in was denied in the console.' -Code 'access_denied' -Category PermissionDenied -InnerException $failure)
            }
            elseif (($code -eq 'ConnectionFailed' -or $status -ge 500) -and $transientFailures -lt 5) {
                $transientFailures++
                Write-Verbose "Polling failed ($($failure.Message)); trying again."
            }
            else {
                throw
            }
        }
    }
}

function Initialize-VMetricApiTokenSession {
    # Checks an API token against the server and names its tenant (GET /tenant/info, which needs SETTINGS_READ;
    # a token without it is still valid, and its tenant stays unnamed). A refused token is an error.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session
    )

    try {
        $info = Invoke-VMetricRequest -Method GET -Path '/tenant/info' -Session $Session -NoConflictRetry
    }
    catch {
        if (Test-VMetricFlowControl -ErrorRecord $_) {
            throw
        }
        $failure = $_.Exception
        if ($failure -is [VMetricException] -and $failure.StatusCode -eq 403) {
            Write-Verbose 'The token cannot read its tenant''s details (SETTINGS_READ); the tenant stays unnamed.'
            return
        }
        if ($failure -is [VMetricException] -and $failure.StatusCode -eq 401) {
            throw (New-VMetricException -Message "$($Session.Origin) refused the API token: it may be expired, revoked, or for another server." `
                    -Code 'ApiTokenRefused' -Category AuthenticationError -StatusCode 401 -TraceId $failure.TraceId -InnerException $failure)
        }
        throw
    }
    $Session.TenantId = [string](Get-VMetricMember -InputObject $info -Name 'id')
    $Session.TenantName = [string](Get-VMetricMember -InputObject $info -Name 'tenantName')
}

function Stop-VMetricSessionOnServer {
    # POST /cli/logout for a signed-in session (an API token has nothing to revoke). A failure is a warning:
    # the session then ends on its own when it expires.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'The public cmdlets that call it ask ShouldProcess or are replacing the session the user signed in to.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [switch] $Quiet
    )

    if ($Session.AuthMethod -eq 'ApiToken' -or -not $Session.AccessToken) {
        return
    }
    try {
        $null = Invoke-VMetricRequest -Method POST -Path '/cli/logout' -Session $Session -NoConflictRetry
    }
    catch {
        if (Test-VMetricFlowControl -ErrorRecord $_) {
            throw
        }
        $text = "Could not end the session for tenant '$(ConvertTo-VMetricSafeText -Text ([string]$Session.TenantName))' on the server ($($_.Exception.Message)); it ends on its own when it expires."
        if ($Quiet) {
            Write-Verbose $text
        }
        else {
            Write-Warning $text
        }
    }
}

function Get-VMetricTenantList {
    # GET /api/v1/cli/tenants: the tenants the signed-in user may switch to.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session
    )

    $answer = Invoke-VMetricRequest -Method GET -Path '/cli/tenants' -Session $Session
    $items = Get-VMetricMember -InputObject $answer -Name 'items'
    if ($null -eq $items -and $answer -is [array]) {
        $items = $answer
    }
    return , (ConvertTo-VMetricArray -InputObject $items)
}

function Resolve-VMetricTenant {
    # A tenant the user may switch to, by id or by name (ignoring case).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [Parameter(Mandatory)]
        [string] $Tenant
    )

    $tenants = Get-VMetricTenantList -Session $Session
    $byId = @($tenants | Where-Object { [string](Get-VMetricMember -InputObject $_ -Name 'id') -eq $Tenant })
    if ($byId.Count -eq 1) {
        return $byId[0]
    }
    $byName = @($tenants | Where-Object { [string](Get-VMetricMember -InputObject $_ -Name 'name') -eq $Tenant })
    if ($byName.Count -eq 1) {
        return $byName[0]
    }
    if ($byName.Count -gt 1) {
        $ids = ($byName | ForEach-Object { ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $_ -Name 'id')) }) -join ', '
        throw (New-VMetricException -Message "Several tenants are named '$(ConvertTo-VMetricSafeText -Text $Tenant)' ($ids); give the id instead." -Code 'AmbiguousTenant' -Category InvalidArgument)
    }
    $names = ($tenants | ForEach-Object { ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $_ -Name 'name')) } | Sort-Object) -join ', '
    throw (New-VMetricException -Message "You have no access to a tenant '$(ConvertTo-VMetricSafeText -Text $Tenant)'. Tenants you can use: $names." -Code 'TenantNotFound' -Category ObjectNotFound)
}

function Test-VMetricSessionTenant {
    # Whether a session is for a tenant given by id or name.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [Parameter(Mandatory)]
        [string] $Tenant
    )

    return ([string]$Session.TenantId -eq $Tenant -or [string]$Session.TenantName -eq $Tenant)
}

function Switch-VMetricSignInTenant {
    # Connect-VMetric -Tenant, when the sign-in was approved for another tenant: the switch to the tenant asked
    # for. Only a switch that succeeds changes the current context. When it fails, the approved sign-in is held
    # but not made current, since a script that goes on must not act in a tenant it did not ask for, and the
    # error says how to use it; a sign-in the switch did spend is forgotten.
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [Parameter(Mandatory)]
        [string] $Tenant
    )

    $signedInName = ConvertTo-VMetricSafeText -Text ([string]$Session.TenantName)
    try {
        $target = Resolve-VMetricTenant -Session $Session -Tenant $Tenant
        $targetName = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $target -Name 'name'))
        Write-Information "Signed in to tenant '$signedInName'; switching to '$targetName'."
        $switched = Invoke-VMetricTenantSwitch -Session $Session -TenantId ([string](Get-VMetricMember -InputObject $target -Name 'id'))
    }
    catch {
        if (Test-VMetricFlowControl -ErrorRecord $_) {
            throw
        }
        $inner = $_.Exception
        $kept = ''
        if ($Session.RefreshToken) {
            Register-VMetricSession -Session $Session -NotCurrent
            $kept = " The sign-in for tenant '$signedInName' is kept, not in use: Set-VMetricContext -Tenant '$signedInName' switches to it, and Disconnect-VMetric ends it."
        }
        else {
            Clear-VMetricSessionToken -Session $Session
        }
        $code = 'TenantSwitchFailed'
        $category = [System.Management.Automation.ErrorCategory]::NotSpecified
        if ($inner -is [VMetricException]) {
            $code = $inner.Code
            $category = $inner.Category
        }
        throw (New-VMetricException -Message "Signed in to tenant '$signedInName', but could not switch to '$(ConvertTo-VMetricSafeText -Text $Tenant)': $($inner.Message)$kept" `
                -Code $code -Category $category -InnerException $inner)
    }
    # The switch spent the sign-in's refresh token, and the server ended that session: only the new one is kept.
    Clear-VMetricSessionToken -Session $Session
    $switched
}

function Invoke-VMetricTenantSwitch {
    # A new session for another tenant, minted with a session's refresh token (the tenant_switch grant). The
    # grant spends that refresh token, and the server ends the session it switched from: the old session can
    # never refresh again (presenting a spent token would revoke the whole session family), so its refresh
    # token is zeroed here, under its Lock, and the caller replaces it with the new session. A refused switch
    # spends nothing: the session is left as it was.
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [Parameter(Mandatory)]
        [string] $TenantId
    )

    if (-not $Session.RefreshToken) {
        throw (New-VMetricException -Message 'Switching tenants needs a browser or device code sign-in; an API token is bound to its own tenant.' `
                -Code 'TenantSwitchUnavailable' -Category InvalidOperation)
    }
    [System.Threading.Monitor]::Enter($Session.Lock)
    try {
        $answer = Invoke-VMetricTokenRequest -ApiUrl $Session.ApiUrl -SkipCertificateCheck:([bool]$Session.SkipCertificateCheck) -Body ([ordered]@{
                grantType    = 'tenant_switch'
                refreshToken = $Session.RefreshToken
                tenantId     = $TenantId
            })
        # Answered: the server has spent the token, whatever the answer holds.
        Clear-VMetricSessionToken -Session $Session -RefreshOnly
    }
    finally {
        [System.Threading.Monitor]::Exit($Session.Lock)
    }
    New-VMetricSession -ApiUrl $Session.ApiUrl -ConsoleUrl $Session.ConsoleUrl -AuthMethod $Session.AuthMethod -TokenResponse $answer `
        -SkipCertificateCheck:([bool]$Session.SkipCertificateCheck)
}