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