Private/Invoke-AuthorizationPKCEFlow.ps1

class SpotifyToken {
    [string] $token
    [string[]] $scopes
    [datetime] $expiration
}

# reference: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
function Invoke-AuthorizationPKCEFlow {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string[]] $Scopes,

        [Parameter(Mandatory=$false)]
        [string] $ClientId,

        [Parameter(Mandatory=$false)]
        [string] $RedirectURI,

        [ValidateScript({Test-Path $_})]
        [Parameter(Mandatory=$false)]
        [string] $ConfigFile
    )

    # ensure we have required configuration value(s)
    $cId  = Get-ClientId    -Params $PSBoundParameters
    $rURI = Get-RedirectURI -Params $PSBoundParameters

    # generate code verifier
    $possible = [string[]][char[]]('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
    $verifier = [String]::Join("", (Get-Random -Count 128 -InputObject $possible))
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($verifier)
    $hasher = [System.Security.Cryptography.SHA256]::Create()
    $hashBytes = $hasher.ComputeHash($bytes)
    #$hash = [System.BitConverter]::ToString($hashBytes).Replace('-','').ToLower()
    # https://stackoverflow.com/questions/63482575/
    $b64hash = [System.Convert]::ToBase64String($hashBytes).
        Replace('=', '').
        Replace('+', '-').
        Replace('/', '_')

    # initiate flow
    $urlParams = @{
        client_id             = $cId
        response_type         = 'code'
        redirect_uri          = $rURI
        scope                 = [string]::Join(" ", $scopes)
        code_challenge_method = 'S256'
        code_challenge        = $b64hash
    }
    $uParams = foreach ($param in $urlParams.Keys) { 
        [string]::Format( "{0}={1}", $param, $urlParams.$param ) 
    }
    $uri = [string]::Format("{0}?{1}", $script:AUTH_URI, [string]::Join("&", $uParams))

    $params = @{
        URI = $uri
        MaximumRedirection = 0
        ErrorAction = 'SilentlyContinue'
    }
    if ($PSVersionTable.PSVersion.Major -gt 5) {
        $params.Add('SkipHttpErrorCheck', $true)
    }
    $response = Invoke-WebRequest @params
    $authPage = $response.Headers.Location[0]

    # prompt user to authenticate
    Start-Process $authPage
    Write-Information 'Opening authentication page in your web browser...'

    # start our listener to catch redirected code
    $timeout = 30
    $sb = {
        $srv = [System.Net.HttpListener]::New()
        if ($rURI[-1] -ne '/') { $rURI += '/' }
        try {
            $srv.Prefixes.Add($rURI)
            $srv.Start()
            $context = $srv.GetContext()
            $query = $context.Request.QueryString
            $response = $context.Response
            $response.StatusCode = 200
            $response.ContentType = 'text/html'
            $data = '<html><head><script>window.close();</script></head><body>Hello! Goodbye!</body></html>'
            $buffer = [System.Text.Encoding]::UTF8.GetBytes($data)
            $response.ContentLength64 = $buffer.Length
            $response.OutputStream.Write($buffer, 0, $buffer.Length)
            $response.Close()
            $code = $query.Get('code')
        }
        finally {
            $srv.Close()
        }
        Write-Output $code
    }

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    $pinfo.FileName = (Get-Process -Id $PID).Path
    $pinfo.RedirectStandardOutput = $true
    $pinfo.UseShellExecute = $false
    $pinfo.Arguments = @(
        "-NonInteractive", 
        "-Command", [string]::Format("{0}`n{1}", "`$rURI = '$rURI'", $sb.ToString()))
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo
    $p.Start() | Out-Null
    $counter = 0
    while ($counter -lt $timeout) {
        if ($p.HasExited) { break }
        Start-Sleep -Seconds 1
    }
    if (! $p.HasExited) {
        Stop-Process -Force -Id $p.Id
        throw "Authentication timed out after $timeout seconds"
    }

    $code = $p.StandardOutput.ReadToEnd().Trim()
    Write-Debug "Received code: $code"

    Write-Information "Authentication complete"

    # get access token
    $params = @{
        URI = $script:TOKEN_URI
        Method = 'Post'
        ContentType = 'application/x-www-form-urlencoded'
        Body = @{
            grant_type    = 'authorization_code'
            code          = $code
            redirect_uri  = $rURI
            client_id     = $cId
            code_verifier = $verifier
        }
    }
    try {
        $tokenResponse = (Invoke-WebRequest @params).Content | ConvertFrom-Json
    }
    catch {
        throw 'Failed to acquire access token'
    }

    $script:TOKENS.Add([SpotifyToken]@{
        token      = $tokenResponse.access_token
        scopes     = $Scopes
        expiration = ([datetime]::Now).AddSeconds($tokenResponse.expires_in)
    }) | Out-Null
 
    return $tokenResponse.access_token
}