Public/Request-OAuthLocalhost.ps1

# TODO documentation of parameters
function Request-OAuthLocalhost {
    [CmdletBinding()]

    <#
    .SYNOPSIS
        Requesting oAuth v2 flow for an app via localhost
 
    .DESCRIPTION
        Apteco PS Modules - PowerShell OAuthV2 flow
 
    .PARAMETER ClientID
        The client id that will be sent in this flow
 
    .PARAMETER ClientSecret
        The client secret that will be sent in this flow - this should be kept secret!!!
 
    .PARAMETER Scope
        Optionally used parameter to request specific rights. The scope that will be sent in the first step of the oauth flow
 
    .PARAMETER State
        The state is optionally used and normally uses a random string in multiple steps of this flow to prevent CSRF attacks
 
    .PARAMETER AuthUrl
        The auth url that will be used to initiate this flow
 
    .PARAMETER TokenUrl
        The token url that will be used to exchange the code into a token
 
    .PARAMETER RedirectUrl
        The redirect url that will be used after the first login, sth. like http://localhost:54321/
        Please be sure to use the exact url with or without the slash in your app configuration
 
    .PARAMETER TimeoutForCode
        The timeout that you have time to complete the first step with logging in
 
    .PARAMETER SettingsFile
        The path to the json file where all settings from this flow are saved into
 
    .PARAMETER EncryptToken
        Should the token be saved encrypted in the resulting json file
 
    .PARAMETER SaveSeparateTokenFile
        Should the access token be saved in a separate file and not only in the json file?
 
    .PARAMETER TokenFile
        The path to the access token file, when the switch SaveSeparateTokenFile is set
 
    .PARAMETER SaveExchangedPayload
        Do you want to save the payload of the second call, which could contain important information
 
    .PARAMETER PayloadToSave
        If you want to save more information in the settingsfile, e.g. for refreshing the token, put it in here
 
    .EXAMPLE
        import-module PSOAuth -Verbose
        $oauthParam = [Hashtable]@{
            "ClientId" = "ssCNo32SNf"
            "ClientSecret" = "" # ask for this at Apteco, if you don't have your own app
            "AuthUrl" = "https://rest.cleverreach.com/oauth/authorize.php"
            "TokenUrl" = "https://rest.cleverreach.com/oauth/token.php"
            "SaveSeparateTokenFile" = $true
        }
        Request-OAuthLocalhost @oauthParam
 
    .EXAMPLE
        TODO SALESFORCE EXAMPLE
 
    .INPUTS
        String
 
    .OUTPUTS
        $null
 
    .NOTES
        Author: florian.von.bracht@apteco.de
 
    #>


    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 = "" # Supported since 0.0.6
        ,[Parameter(Mandatory=$false)][String]$State = "" # Supported since 0.0.6
        ,[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]@{}
        ,[Parameter(Mandatory=$false)][Switch]$SaveExchangedPayload = $false
    )

    begin {

        #-----------------------------------------------
        # SET LOGFILE
        #-----------------------------------------------

        # Set log file here, otherwise it could interrupt the process when launched headless from .net in System32
        Set-Logfile -Path "./psoauth.log"


        #-----------------------------------------------
        # 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
        If ( $Scope.length -gt 0 ) {
            $nvCollection.Add('scope', $Scope) # Set only the scope, if it is filled
        }
        If ( $State.length -gt 0 ) {
            $nvCollection.Add('state', $State) # Set only the state, if it is filled
        }

        # 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 "$( $uriRequest.Uri.OriginalString )"
        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"]
                    $state = $callbackUriSegments["state"]

                    #$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
            [Hashtable]@{
                "code" = $code
                "state" = $state
            }

        }

        # 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
        $webjob = Receive-Job -Job $job
        $code = $webjob.code

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

        # Check the state
        If ( $State.length -gt 0 ) {
            If ( $webjob.state -ne $State ) {
                throw "State of initial call does not match the returned state! Exit!"
                Exit 0
            }
        }


        #-----------------------------------------------
        # 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

        If ( $SaveExchangedPayload -eq $true ) {
            ConvertToJson -InputObject $response -Depth 99 | Set-Content -path ".\exchange.json" -Encoding UTF8 -Force
        }


        #-----------------------------------------------
        # 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
#>