Private/SnowOAuthAuthorization.ps1
|
function Get-SnowOAuthProofKey { [CmdletBinding()] param() $verifierBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) $verifier = [Convert]::ToBase64String($verifierBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') $challengeBytes = [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier)) $stateBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) [pscustomobject]@{ Verifier = $verifier Challenge = [Convert]::ToBase64String($challengeBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') State = [Convert]::ToBase64String($stateBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } } function Get-SnowOAuthAuthorizationUri { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][uri]$RedirectUri, [Parameter(Mandatory)][string]$State, [Parameter(Mandatory)][string]$Challenge ) $query = @( 'response_type=code' 'client_id=' + [uri]::EscapeDataString($ClientId) 'redirect_uri=' + [uri]::EscapeDataString($RedirectUri.AbsoluteUri) 'state=' + [uri]::EscapeDataString($State) 'code_challenge=' + [uri]::EscapeDataString($Challenge) 'code_challenge_method=S256' ) -join '&' [uri]::new($BaseUri, "/oauth_auth.do?$query") } function Test-SnowOAuthState { [CmdletBinding()] param( [Parameter(Mandatory)][byte[]]$Left, [Parameter(Mandatory)][byte[]]$Right ) $difference = $Left.Length -bxor $Right.Length $length = [Math]::Max($Left.Length, $Right.Length) for ($i = 0; $i -lt $length; $i++) { $leftByte = if ($i -lt $Left.Length) { $Left[$i] } else { 0 } $rightByte = if ($i -lt $Right.Length) { $Right[$i] } else { 0 } $difference = $difference -bor ($leftByte -bxor $rightByte) } $difference -eq 0 } function ConvertFrom-SnowOAuthCallback { [CmdletBinding()] param( [Parameter(Mandatory)][string]$CallbackUrl, [Parameter(Mandatory)][uri]$RedirectUri, [Parameter(Mandatory)][string]$ExpectedState ) $callback = $null if (-not [uri]::TryCreate($CallbackUrl, [UriKind]::Absolute, [ref]$callback) -or $callback.GetLeftPart([UriPartial]::Path) -ne $RedirectUri.GetLeftPart([UriPartial]::Path)) { throw [System.ArgumentException]::new('The authorization callback URL does not match the registered redirect URI.') } $query = [System.Web.HttpUtility]::ParseQueryString($callback.Query) $state = $query['state'] if ([string]::IsNullOrEmpty($state)) { throw [System.InvalidOperationException]::new('The authorization callback did not include state.') } $expectedBytes = [Text.Encoding]::UTF8.GetBytes($ExpectedState) $actualBytes = [Text.Encoding]::UTF8.GetBytes($state) if (-not (Test-SnowOAuthState -Left $expectedBytes -Right $actualBytes)) { throw [System.InvalidOperationException]::new('The authorization callback state did not match. Sign-in was cancelled.') } if ($query['error']) { throw [System.InvalidOperationException]::new('ServiceNow authorization was not granted. Try connecting again.') } if ([string]::IsNullOrEmpty($query['code'])) { throw [System.InvalidOperationException]::new('The authorization callback did not include a code.') } [pscustomobject]@{ Code = $query['code'] } } function Open-SnowOAuthBrowser { [CmdletBinding()] param([Parameter(Mandatory)][uri]$Uri) try { switch (Get-SnowplowPlatform) { Windows { Start-Process -FilePath $Uri.AbsoluteUri -PassThru -ErrorAction Stop } MacOS { Start-Process -FilePath 'open' -ArgumentList $Uri.AbsoluteUri -PassThru -ErrorAction Stop } Linux { if (-not ($env:DISPLAY -or $env:WAYLAND_DISPLAY) -or -not (Get-Command xdg-open -ErrorAction SilentlyContinue)) { return $false } Start-Process -FilePath 'xdg-open' -ArgumentList $Uri.AbsoluteUri -PassThru -ErrorAction Stop } default { return $false } } } catch { $false } } function Read-SnowOAuthCallbackUrl { [CmdletBinding()] param() try { $callbackUrl = Read-Host 'Paste the complete redirect URL shown after sign-in' } catch { throw [System.InvalidOperationException]::new('Interactive sign-in is unavailable. Use -ClientCredentials with a dedicated service account for unattended sessions.') } if ([string]::IsNullOrWhiteSpace($callbackUrl)) { throw [System.InvalidOperationException]::new('No authorization callback URL was provided. For unattended sessions, use -ClientCredentials with a dedicated service account.') } $callbackUrl } function Invoke-SnowOAuthAuthorization { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][ValidateRange(1024, 65535)][int]$RedirectPort, [switch]$NoBrowser, [ValidateRange(1, 600)][int]$TimeoutSeconds = 120 ) $redirectUri = [uri]::new("http://127.0.0.1:$RedirectPort/callback") $proofKey = Get-SnowOAuthProofKey $authorizationUri = Get-SnowOAuthAuthorizationUri -BaseUri $BaseUri -ClientId $ClientId ` -RedirectUri $redirectUri -State $proofKey.State -Challenge $proofKey.Challenge if ($NoBrowser) { Write-Host "Open this URL in a browser to sign in:`n$authorizationUri" $callbackUrl = Read-SnowOAuthCallbackUrl } else { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $RedirectPort) try { $listener.Start() } catch { $listener.Stop() throw [System.InvalidOperationException]::new("Could not listen on loopback redirect port $RedirectPort. Choose another port with -RedirectPort.") } try { $browserProcess = Open-SnowOAuthBrowser -Uri $authorizationUri if ($browserProcess) { $callbackTask = $listener.AcceptTcpClientAsync() $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $browserFailed = $false while (-not $callbackTask.IsCompleted) { $hasExited = $browserProcess.PSObject.Properties['HasExited'] if ($hasExited -and $browserProcess.HasExited -and $browserProcess.PSObject.Properties['ExitCode'] -and $browserProcess.ExitCode -ne 0) { $browserFailed = $true break } $remaining = [TimeSpan]::FromSeconds($TimeoutSeconds) - $stopwatch.Elapsed if ($remaining -le [TimeSpan]::Zero) { break } $waitTime = [TimeSpan]::FromMilliseconds([Math]::Min(100, $remaining.TotalMilliseconds)) $null = $callbackTask.Wait($waitTime) } if ($browserFailed) { Write-Host "Could not open a browser. Open this URL to sign in:`n$authorizationUri" $callbackUrl = Read-SnowOAuthCallbackUrl } elseif (-not $callbackTask.IsCompleted) { throw [System.TimeoutException]::new('Timed out waiting for the ServiceNow sign-in callback. Run Connect-Snowplow again.') } else { $client = $callbackTask.GetAwaiter().GetResult() try { $client.ReceiveTimeout = 5000 $reader = [System.IO.StreamReader]::new($client.GetStream()) $requestLine = $reader.ReadLine() $headersComplete = $false while ($null -ne ($header = $reader.ReadLine())) { if ($header.Length -eq 0) { $headersComplete = $true break } } $requestParts = if ($requestLine) { $requestLine -split ' ' } else { @() } if (-not $headersComplete -or $requestParts.Count -lt 2 -or -not $requestParts[1].StartsWith('/')) { throw [System.InvalidOperationException]::new('The authorization callback request was invalid.') } $callbackUrl = ([uri]::new($redirectUri, $requestParts[1])).AbsoluteUri $responseText = '<!doctype html><html><head><title>Snowplow</title></head><body>You can close this window and return to PowerShell.</body></html>' $responseBytes = [Text.Encoding]::UTF8.GetBytes($responseText) $responseHeader = "HTTP/1.1 200 OK`r`nContent-Type: text/html; charset=utf-8`r`nContent-Length: $($responseBytes.Length)`r`nConnection: close`r`n`r`n" $stream = $client.GetStream() $headerBytes = [Text.Encoding]::ASCII.GetBytes($responseHeader) try { $stream.Write($headerBytes, 0, $headerBytes.Length) $stream.Write($responseBytes, 0, $responseBytes.Length) } catch { $null = $_ } } finally { $client.Close() } } } else { Write-Host "Could not open a browser. Open this URL to sign in:`n$authorizationUri" $callbackUrl = Read-SnowOAuthCallbackUrl } } finally { $listener.Stop() } } $callback = ConvertFrom-SnowOAuthCallback -CallbackUrl $callbackUrl -RedirectUri $redirectUri -ExpectedState $proofKey.State [pscustomobject]@{ Code = $callback.Code Verifier = $proofKey.Verifier RedirectUri = $redirectUri } } function Get-SnowOAuthTokenKey { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId ) $bytes = [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes("$($BaseUri.AbsoluteUri)|$ClientId")) [Convert]::ToHexString($bytes).ToLowerInvariant() } function Get-SnowOAuthTokenStorePath { [CmdletBinding()] param() Join-Path (Get-SnowplowDirectories).Data 'oauth-tokens.json' } function Read-SnowOAuthRefreshToken { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId ) $path = Get-SnowOAuthTokenStorePath if (-not (Test-Path -LiteralPath $path)) { return $null } try { $entries = @(Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -ErrorAction Stop) $entry = $entries | Where-Object Key -eq (Get-SnowOAuthTokenKey -BaseUri $BaseUri -ClientId $ClientId) | Select-Object -First 1 if ($entry -and $entry.RefreshToken) { ConvertTo-SecureString -String ([string]$entry.RefreshToken) -AsPlainText -Force } } catch { throw [System.IO.InvalidDataException]::new('The saved OAuth token store could not be read. Remove it and sign in again.') } } function Write-SnowOAuthRefreshToken { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][securestring]$RefreshToken ) $path = Get-SnowOAuthTokenStorePath $directory = Split-Path -Path $path -Parent if (-not (Test-Path -LiteralPath $directory)) { $null = [System.IO.Directory]::CreateDirectory($directory) } Protect-SnowplowPath -Path $directory -Type Directory $entries = if (Test-Path -LiteralPath $path) { try { @(Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -ErrorAction Stop) } catch { throw [System.IO.InvalidDataException]::new('The saved OAuth token store could not be read. Remove it and sign in again.') } } else { @() } $key = Get-SnowOAuthTokenKey -BaseUri $BaseUri -ClientId $ClientId $entries = @($entries | Where-Object Key -ne $key) $plainToken = [System.Net.NetworkCredential]::new('', $RefreshToken).Password $entries += [pscustomobject]@{ Key = $key; RefreshToken = $plainToken } $entries | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $path -Encoding utf8NoBOM Protect-SnowplowPath -Path $path -Type File } function Clear-SnowOAuthRefreshToken { [CmdletBinding()] param( [Parameter(Mandatory)][uri]$BaseUri, [Parameter(Mandatory)][string]$ClientId ) $path = Get-SnowOAuthTokenStorePath if (-not (Test-Path -LiteralPath $path)) { return } try { $key = Get-SnowOAuthTokenKey -BaseUri $BaseUri -ClientId $ClientId $entries = @(Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -ErrorAction Stop | Where-Object Key -ne $key) if ($entries.Count) { $entries | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $path -Encoding utf8NoBOM Protect-SnowplowPath -Path $path -Type File } else { Remove-Item -LiteralPath $path -Force } } catch { throw [System.IO.InvalidDataException]::new('The saved OAuth token store could not be updated.') } } |