Private/Invoke-HaloAuthorisationCodeFlow.ps1
|
#Requires -Version 7 function Get-HaloPKCEChallenge { <# .SYNOPSIS Generates a PKCE code verifier and S256 code challenge. .DESCRIPTION Returns a random RFC 7636 code verifier and its base64url-encoded SHA256 code challenge. .OUTPUTS A PSCustomObject with Verifier and Challenge properties. #> [CmdletBinding()] [OutputType([PSCustomObject])] param () $Verifier = ConvertTo-HaloBase64Url -Bytes ([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) $Hash = [System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::ASCII.GetBytes($Verifier)) return [PSCustomObject]@{ Verifier = $Verifier Challenge = ConvertTo-HaloBase64Url -Bytes $Hash } } function ConvertTo-HaloBase64Url { <# .SYNOPSIS Encodes bytes as unpadded base64url. .DESCRIPTION Encodes a byte array using the URL-safe base64 alphabet without padding, as required by PKCE. .OUTPUTS The base64url-encoded string. #> [CmdletBinding()] [OutputType([String])] param ( # The bytes to encode. [Parameter( Mandatory = $True )] [Byte[]]$Bytes ) return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function Get-HaloAuthorisationResultPage { <# .SYNOPSIS Returns the HTML shown in the browser after the Halo sign-in redirect. .DESCRIPTION Builds a small self-contained page telling the user whether sign-in succeeded. Browsers only allow script to close tabs that script opened, so window.close() is attempted but the message covers the usual case where it is ignored. .OUTPUTS The HTML document as a string. #> [CmdletBinding()] [OutputType([String])] param ( # Whether the sign-in succeeded. [Parameter( Mandatory = $True )] [Bool]$Success ) if ($Success) { $Title = 'Signed in to Halo' $Message = 'You can close this tab and return to PowerShell.' $Colour = '#1a7f37' $Script = '<script>setTimeout(function () { window.close(); }, 1500);</script>' } else { $Title = 'Halo sign-in failed' $Message = 'Check the PowerShell window for details.' $Colour = '#cf222e' $Script = '' } $Template = @' <!doctype html> <html lang="en"><head><meta charset="utf-8"><title>{{TITLE}}</title> <style> body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, sans-serif; background: #f6f8fa; color: #1f2328; } .card { text-align: center; padding: 2rem 2.5rem; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.12); } h1 { margin: 0 0 .5rem; font-size: 1.4rem; color: {{COLOUR}}; } p { margin: 0; } @media (prefers-color-scheme: dark) { body { background: #0d1117; color: #e6edf3; } .card { background: #161b22; } } </style></head> <body><div class="card"><h1>{{TITLE}}</h1><p>{{MESSAGE}}</p></div>{{SCRIPT}}</body></html> '@ return $Template.Replace('{{TITLE}}', $Title).Replace('{{MESSAGE}}', $Message).Replace('{{COLOUR}}', $Colour).Replace('{{SCRIPT}}', $Script) } function Wait-HaloAuthorisationCode { <# .SYNOPSIS Opens the Halo sign-in page and waits for the authorisation code redirect. .DESCRIPTION Starts a temporary HTTP listener on the redirect URI, opens the login URI in the default browser and waits for Halo to redirect back with an authorisation code. Validates the returned state value. .OUTPUTS The authorisation code. #> [CmdletBinding()] [OutputType([String])] param ( # The fully built Halo authorise URI including query parameters. [Parameter( Mandatory = $True )] [URI]$LoginURI, # The redirect URI registered on the Halo API application. [Parameter( Mandatory = $True )] [URI]$RedirectURI, # The state value sent with the request, used to validate the response. [Parameter( Mandatory = $True )] [String]$State, # How long to wait for the user to complete sign-in, in seconds. [Int]$TimeoutSeconds = 300 ) if (-not $RedirectURI.AbsolutePath.EndsWith('/')) { throw 'RedirectURI must have a trailing slash so the Halo redirect URI matches the listener prefix.' } $Prefix = $RedirectURI.GetLeftPart([System.UriPartial]::Path) $Listener = [System.Net.HttpListener]::New() $Listener.Prefixes.Add($Prefix) try { $Listener.Start() } catch { throw ('Could not listen on {0} for the Halo sign-in redirect. Check the port is free or pass a different -RedirectURI (it must match the redirect URI on the Halo API application). {1}' -f $Prefix, $_.Exception.Message) } try { Write-Information -MessageData ('Opening your browser to sign in to Halo. If it does not open, browse to:{0}{1}' -f [Environment]::NewLine, $LoginURI.AbsoluteUri) -InformationAction Continue try { Start-Process -FilePath $LoginURI.AbsoluteUri } catch { Write-Warning ('Could not open a browser automatically: {0}' -f $_.Exception.Message) } $Deadline = (Get-Date).AddSeconds($TimeoutSeconds) $PendingContext = $Listener.GetContextAsync() while (-not $PendingContext.Wait(500)) { if ((Get-Date) -gt $Deadline) { throw ('Timed out after {0} seconds waiting for the Halo sign-in to complete.' -f $TimeoutSeconds) } } $Context = $PendingContext.Result $ResponseParameters = [System.Web.HttpUtility]::ParseQueryString($Context.Request.Url.Query) $Success = (-not [String]::IsNullOrEmpty($ResponseParameters['code'])) -and ($ResponseParameters['state'] -eq $State) $PageBytes = [System.Text.Encoding]::UTF8.GetBytes((Get-HaloAuthorisationResultPage -Success $Success)) $Context.Response.ContentType = 'text/html; charset=utf-8' $Context.Response.ContentLength64 = $PageBytes.Length $Context.Response.OutputStream.Write($PageBytes, 0, $PageBytes.Length) $Context.Response.OutputStream.Close() $Context.Response.Close() # Give the browser a moment to read the page before the listener is torn down. Start-Sleep -Milliseconds 500 } finally { $Listener.Stop() $Listener.Close() } if ($ResponseParameters['error']) { throw ('Halo returned ''{0}'' during sign-in: {1}' -f $ResponseParameters['error'], $ResponseParameters['error_description']) } if ($ResponseParameters['state'] -cne $State) { throw 'The Halo sign-in response state did not match the request.' } if ([String]::IsNullOrEmpty($ResponseParameters['code'])) { throw 'The Halo sign-in response did not include an authorisation code.' } return $ResponseParameters['code'] } function Invoke-HaloAuthorisationCodeFlow { <# .SYNOPSIS Signs in to Halo through the browser using the authorisation code flow with PKCE. .DESCRIPTION Builds the authorise request from the connection information, waits for the user to sign in through the browser and exchanges the returned code for tokens. .OUTPUTS The parsed token endpoint response. #> [CmdletBinding()] [OutputType([Object])] param ( # The connection information built by Connect-HaloAPI. [Parameter( Mandatory = $True )] [Object]$ConnectionInformation ) $PKCE = Get-HaloPKCEChallenge $State = ConvertTo-HaloBase64Url -Bytes ([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16)) $LoginURIBuilder = [System.UriBuilder]::New($ConnectionInformation.AuthoriseURI) $LoginQuery = [System.Web.HttpUtility]::ParseQueryString($LoginURIBuilder.Query) $LoginQuery['response_type'] = 'code' $LoginQuery['client_id'] = $ConnectionInformation.ClientID $LoginQuery['redirect_uri'] = $ConnectionInformation.RedirectURI $LoginQuery['scope'] = $ConnectionInformation.AuthScopes $LoginQuery['state'] = $State $LoginQuery['code_challenge'] = $PKCE.Challenge $LoginQuery['code_challenge_method'] = 'S256' $LoginURIBuilder.Query = $LoginQuery.ToString() $WaitParams = @{ LoginURI = $LoginURIBuilder.Uri RedirectURI = $ConnectionInformation.RedirectURI State = $State } if ($ConnectionInformation.LoginTimeout) { $WaitParams.TimeoutSeconds = $ConnectionInformation.LoginTimeout } $Code = Wait-HaloAuthorisationCode @WaitParams $TokenRequestParams = @{ Uri = $ConnectionInformation.AuthURI Method = 'POST' Body = @{ grant_type = 'authorization_code' client_id = $ConnectionInformation.ClientID code = $Code redirect_uri = $ConnectionInformation.RedirectURI code_verifier = $PKCE.Verifier } ContentType = 'application/x-www-form-urlencoded' Headers = $ConnectionInformation.AdditionalHeaders } $TokenResponse = Invoke-WebRequest @TokenRequestParams return ConvertFrom-Json -InputObject $TokenResponse.Content } |