Public/Request-OAuthLocalhost.ps1

# TODO documentation of parameters
function Request-OAuthLocalhost {
    [CmdletBinding()]
    param (
         [Parameter(Mandatory=$true)][String]$ClientId
        ,[Parameter(Mandatory=$true)][String]$ClientSecret
        ,[Parameter(Mandatory=$true)][Uri]$AuthUrl
        ,[Parameter(Mandatory=$true)][Uri]$TokenUrl
        ,[Parameter(Mandatory=$false)][String]$Scope = "" # TODO not yet implemented
        #,[Parameter(Mandatory=$false)][String]$State = "" # TODO not yet implemented
        ,[Parameter(Mandatory=$false)][Uri]$RedirectUrl = "http://localhost:$( Get-Random -Minimum 49152 -Maximum 65535 )/"
        ,[Parameter(Mandatory=$false)][String]$SettingsFile = "./settings.json"
        ,[Parameter(Mandatory=$false)][String]$TokenFile = "./oauth.token"
        #,[Parameter(Mandatory=$false)][String]$CallbackFile = "$( $env:TEMP )\crcallback.txt"
        ,[Parameter(Mandatory=$false)][Switch]$SaveSeparateTokenFile = $false
        ,[Parameter(Mandatory=$false)][int]$TimeoutForCode = 360
        ,[Parameter(Mandatory=$false)][Switch]$EncryptToken = $false
        ,[Parameter(Mandatory=$false)][PSCustomObject]$PayloadToSave = [PSCustomObject]@{}  # If you want to save more information in the settingsfile, e.g. for refreshing the token, put it in here
    )

    begin {

        #-----------------------------------------------
        # ASK FOR SETTINGSFILE
        #-----------------------------------------------
        <#
        # Default file
        $settingsFileDefault = "./settings.json"
 
        # Ask for another path
        $settingsFile = Read-Host -Prompt "Where do you want the settings file to be saved? Just press Enter for this default [$( $settingsFileDefault )]"
 
        # ALTERNATIVE: The file dialog is not working from Visual Studio Code, but is working from PowerShell ISE or "normal" PowerShell Console
        #$settingsFile = Set-FileName -initialDirectory "$( $scriptPath )" -filter "JSON files (*.json)|*.json"
 
        # If prompt is empty, just use default path
        if ( $settingsFile -eq "" -or $null -eq $settingsFile) {
            $settingsFile = $settingsFileDefault
        }
        #>


        # Check if filename is valid
        if(Test-Path -LiteralPath $SettingsFile -IsValid ) {
            Write-Log "SettingsFile '$( $SettingsFile )' is valid"
        } else {
            Write-Log "SettingsFile '$( $SettingsFile )' contains invalid characters"
        }


        #-----------------------------------------------
        # ASK FOR TOKENFILE
        #-----------------------------------------------
        <#
        # Default file
        $tokenFileDefault = "./oauth.token"
 
        # Ask for another path
        $tokenFile = Read-Host -Prompt "Where do you want the token file to be saved? Just press Enter for this default [$( $tokenFileDefault )]"
 
        # ALTERNATIVE: The file dialog is not working from Visual Studio Code, but is working from PowerShell ISE or "normal" PowerShell Console
        #$settingsFile = Set-FileName -initialDirectory "$( $scriptPath )" -filter "JSON files (*.json)|*.json"
 
        # If prompt is empty, just use default path
        if ( $tokenFile -eq "" -or $null -eq $tokenFile) {
            $tokenFile = $tokenFileDefault
        }
        #>


        # Check if filename is valid
        if(Test-Path -LiteralPath $tokenFile -IsValid ) {
            Write-Log "SettingsFile '$( $tokenFile )' is valid"
        } else {
            Write-Log "SettingsFile '$( $tokenFile )' contains invalid characters"
        }


    }

    process {

        #-----------------------------------------------
        # CONFIRM FOR NEXT STEPS
        #-----------------------------------------------

        # Confirm you want to proceed
        $proceed = $Host.UI.PromptForChoice("New Token", "This will create a NEW token. Previous tokens will be invalid immediatly. Please confirm you are sure to proceed?", @('&Yes'; '&No'), 1)

        # Leave if answer is not yes
        If ( $proceed -eq 0 ) {
            Write-Log -message "Asked for confirmation of new token creation. Answer was 'yes'"
        } else {
            Write-Log -message "Asked for confirmation of new token creation. Answer was 'No'"
            Write-Log -message "Leaving the script now"
            exit 0
        }


        #-----------------------------------------------
        # SETTINGS FOR THE TOKEN CREATION
        #-----------------------------------------------

        #$clientCred = New-Object PSCredential $ClientId,$ClientSecret


        #-----------------------------------------------
        # OAUTHv2 PROCESS - STEP 1
        #-----------------------------------------------

        # STEP 1: Prepare the first call to let the user log into the service
        # SOURCE: https://powershellmagazine.com/2019/06/14/pstip-a-better-way-to-generate-http-query-strings-in-powershell/
        $nvCollection  = [System.Web.HttpUtility]::ParseQueryString([String]::Empty)
        $nvCollection.Add('response_type','code')
        $nvCollection.Add('client_id',$ClientId)
        $nvCollection.Add('grant',"basic")
        $nvCollection.Add('redirect_uri', $RedirectUrl) # a dummy url like apteco.de is needed

        # Create the url
        $uriRequest = [System.UriBuilder]$AuthUrl
        $uriRequest.Query = $nvCollection.ToString()

        # Open the default browser with the generated url
        Write-Log -message "Opening the browser now to allow the access to the account"
        Write-Log -message "Please finish the process in your browser now"
        Write-Log -message "NOTE:"
        Write-Log -message " APTECO WILL NOT GET ACCESS TO YOUR DATA THROUGH THE APP!"
        Write-Log -message " ONLY THIS LOCAL GENERATED TOKEN CAN BE USED FOR ACCESS!"
        Start-Process $uriRequest.Uri.OriginalString


        #-----------------------------------------------
        # PREPARE WEBSERVER LISTENER FOR CALLBACK
        #-----------------------------------------------

        $webserverProcess = [scriptblock]{

            param(
                [uri]$redirect
            )

            Add-Type -AssemblyName System.Web

            $http = [System.Net.HttpListener]::new()

            # Hostname and port to listen on
            $http.Prefixes.Add($redirect)

            # Start the Http Server
            $http.Start()

            # Log ready message to terminal
            if ($http.IsListening) {
                #Write-Information -MessageData " HTTP Server Ready on '$( $http.Prefixes )'"
            } else {
                throw "There was an error starting the HTTP server, pleasy retry or choose another port"
            }

            # Let the webserver listen, this loop gets only executed when a request takes place
            #$r = $null
            $closeHttpListener = $false
            $code = ""
            do {

                # Get Request Url
                # When a request is made in a web browser the GetContext() method will return a request object
                $context = $http.GetContext()

                # Raw url
                if ($context.Request.HttpMethod -eq 'GET' -and $context.Request.RawUrl -eq '/') {

                    # We can log the request to the terminal
                    #write-host "$($context.Request.UserHostAddress) => $($context.Request.Url)" -f 'mag'

                    # the html/data you want to send to the browser
                    # you could replace this with: [string]$html = Get-Content "C:\some\path\index.html" -Raw
                    [string]$html = "Waiting for Code."

                    #resposed to the request
                    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) # convert htmtl to bytes
                    $context.Response.ContentLength64 = $buffer.Length
                    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
                    $context.Response.OutputStream.Close() # close the response

                }

                # If the url contains the code
                if ( $context.request.RawUrl -like "*code=*" ) {

                    #Write-Verbose "Got a code" -verbose

                    # Looking for code in query
                    $callbackUri = [uri]$context.Request.Url
                    $callbackUriSegments = [System.Web.HttpUtility]::ParseQueryString($callbackUri.Query)
                    $code = $callbackUriSegments["code"]

                    #$r = $context
                    $closeHttpListener = $true

                    # We can log the request to the terminal
                    #write-host "$($context.Request.UserHostAddress) => $($context.Request.Url)" -f 'mag'

                    # the html/data you want to send to the browser
                    # you could replace this with: [string]$html = Get-Content "C:\some\path\index.html" -Raw
                    [string]$html = "<h1>Received code: $( $code )</h1>"

                    #resposed to the request
                    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) # convert htmtl to bytes
                    $context.Response.ContentLength64 = $buffer.Length
                    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
                    $context.Response.OutputStream.Close() # close the response

                }

                <#
                a few examples
                $context.Request.HttpMethod gives you the method like GET
                $context.Request.RawUrl
                $context.Request.UserHostAddress
                $context.Request.Url
                #>


                # powershell will continue looping and listen for new requests...

            } until ( $closeHttpListener -eq $true ) #$http.IsListening

            # return
            $code

        }

        # Start the webserver in the background
        $u = $RedirectUrl #"http://localhost:$( Get-Random -Minimum 49152 -Maximum 65535 )/"
        Write-Log -Message "Listening on: '$( $u )'" #-InformationAction Continue
        $job = Start-Job -Name "ReceiveCodeViaHTTP" -ArgumentList $u -ScriptBlock $webserverProcess #| Wait-Job

        # Work out the maximum waiting time
        If ( $TimeoutForCode -le 0 ) {
            $maxSeconds = 360 # 5 minutes
            Write-Log "Using default waiting time of $( $maxSeconds ) seconds"
        } else {
            $maxSeconds = $TimeoutForCode
        }

        # Show a progress bar and wait for a result
        $waitingStart = [datetime]::Now
        Do {

            # Show the progress
            $ts = New-TimeSpan -Start $waitingStart -End ( [datetime]::now )
            $secondsRemaining = [math]::Ceiling($maxSeconds - $ts.TotalSeconds)
            Write-Progress -Activity "Waiting for callback/redirect" -Status "$( $secondsRemaining ) seconds left" -SecondsRemaining $secondsRemaining -PercentComplete ([math]::Round($secondsRemaining/$maxSeconds*100))

            # Wait
            Start-Sleep -Milliseconds 500

        } While ( $ts.TotalSeconds -lt $maxSeconds -and $job.State -eq "Running")

        # Kill the job in case of error or timeout, if it not completed yet
        If ( $job.State -ne "Completed" ) {
            try {
                $job.StopJob()
            } catch {

            }
        }

        # Look for a result
        $code = Receive-Job -Job $job

        # Check the code
        If ( $code.Length -gt 0 ) {
            #Write-Host $code
        } else {
            throw "Timeout reached or no usable code received"
        }


        #-----------------------------------------------
        # OAUTHv2 PROCESS - STEP 2
        #-----------------------------------------------

        # Prepare the second call to exchange the code quickly for a token
        $postParams = [Hashtable]@{
            Method = "Post"
            Uri = $tokenUrl
            Body = [Hashtable]@{
                "client_id" = $ClientId
                "client_secret" = $ClientSecret
                "redirect_uri" = $RedirectUrl #$redirectUri
                "grant_type" = "authorization_code"
                "code" = $code
            }
            Verbose = $true
        }
        $response = Invoke-RestMethod @postParams

        Write-Log -message "Got a token with scope '$( $response.scope )'"

        # Trying an API call
        <#
        try {
 
            $headers = @{
                "Authorization" = "Bearer $( $response.access_token )"
            }
            $ttl = Invoke-RestMethod -Uri "https://rest.cleverreach.com/v3/debug/ttl.json" -Method Get -ContentType "application/json; charset=utf-8" -Headers $headers
 
            Write-Log -message "Used token for API call successfully. Token expires at '$( $ttl.date.toString() )'"
 
        } catch {
 
            Write-Log -message "API call was not successful. Aborting the whole script now!" -severity ( [Logseverity]::WARNING )
            throw $_.Exception
 
        }
        #>


        # Clear the variables straight away
        #$clientCred = $null


        #-----------------------------------------------
        # SAVE THE TOKENS
        #-----------------------------------------------

        # TODO the saving could be put into a separate function

        # Encrypt tokens, if wished
        $refreshToken = ""
        If ( $EncryptToken -eq $true) {
            $accessToken = Get-PlaintextToSecure $response.access_token
            If ( $null -ne $response.refresh_token ) {
                $refreshToken = Get-PlaintextToSecure $response.refresh_token
            }
        } else {
            $accessToken = $response.access_token
            If ( $null -ne $response.refresh_token ) {
                $refreshToken = $response.refresh_token
            }
        }

        # Parse the switch
        $separateTokenFile = $false
        If ( $SaveSeparateTokenFile -eq $true ) {
            $separateTokenFile = $true
        }

        # The settings to save for refreshing
        $set = @{
            "accesstoken" = $accessToken
            "refreshtoken" = $refreshToken
            "tokenFile" = [IO.Path]::GetFullPath([IO.Path]::Combine((Get-Location -PSProvider "FileSystem").ProviderPath, $TokenFile))
            "unixtime" = Get-Unixtime
            "saveSeparateTokenFile" = $separateTokenFile
            "payload" = $PayloadToSave
            #"refreshTokenAutomatically" = $true
            #"refreshTtl" = 604800 # seconds; refresh one week before expiration
        }

        # create json object
        $json = ConvertTo-Json -InputObject $set -Depth 99 # -compress

        # TODO implement PSNotify here for email notifications

        # rename settings file if it already exists
        If ( Test-Path -Path $SettingsFile ) {
            $backupPath = "$( $SettingsFile ).$( $timestamp.ToString("yyyyMMddHHmmss") )"
            Write-Log -message "Moving previous settings file to $( $backupPath )" -severity ( [Logseverity]::WARNING )
            Move-Item -Path $SettingsFile -Destination $backupPath
        } else {
            Write-Log -message "There was no settings file existing yet"
        }

        # print settings to console
        #$json

        # save settings to file
        $json | Set-Content -path $SettingsFile -Encoding UTF8


        #-----------------------------------------------
        # SAVE THE TOKENS AS SEPARATE FILE UNENCRYPTED
        #-----------------------------------------------

        If ( $SaveSeparateTokenFile -eq $true ) {
            Write-Log -message "Saving token to '$( $TokenFile )'"
            $response.access_token | Set-Content -path "$( $TokenFile )" -Encoding UTF8 -Force
        }


    }

    end {

    }

}


#-----------------------------------------------
# TESTING HASHTABLES
#-----------------------------------------------
<#
$leftHt = [hashtable]@{
    "firstname" = "Florian"
    "lastname" = "Friedrichs"
}
 
$rightHt = [hashtable]@{
    "lastname" = "von Bracht"
    "Street" = "Schaumainkai 87"
}
 
Join-Hashtable -Left $leftHt -right $rightHt -verbose -AddKeysFromRight
#>