Public/Request-OAuthApp.ps1

# TODO documentation of parameters

function Request-OAuthApp {
    [CmdletBinding()]

    <#
    .SYNOPSIS
        Requesting oAuth v2 flow for an app via app link
 
    .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 Protocol
        The app protocol that should be used like apttoken54321://localhost/
 
    .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-OAuthApp @oauthParam -Verbose
 
    .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)][String]$Protocol = "apttoken$( Get-RandomString -length 6 -ExcludeSpecialChars )"
        ,[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)][Switch]$EncryptToken = $false
        ,[Parameter(Mandatory=$false)][PSCustomObject]$PayloadToSave = [PSCustomObject]@{}
        ,[Parameter(Mandatory=$false)][Switch]$SaveExchangedPayload = $false    # Do you want to save the payload of the second call, which could contain important information
    )

    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 {

        try {

            #-----------------------------------------------
            # 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
            $CallbackFile = "$( $env:TEMP )\callback.txt"


            #-----------------------------------------------
            # PREPARE REGISTRY
            #-----------------------------------------------

            # current path - will get back to this at the end
            $currentLocation = Get-Location

            # Switch to registry - choose the current user to not need admin rights
            $root = "Registry::HKEY_CURRENT_USER\Software\Classes" # User registry - needs no elevated rights
            # $root = "Registry::HKEY_CLASSES_ROOT" # Global registry - needs admin rights
            Write-Log -message "Putting new registry entries into '$( $root )' with custom protocol '$( $Protocol )'"
            Set-Location -Path $root

            # Remove the registry entries, if already existing
            If ( Test-Path -path $Protocol ) {
                Write-Log -message "Custom protocol folder was already existing. Removing it now."
                Remove-Item -Path $Protocol -Force
            }

            # Create the base entries now
            New-Item -Path $Protocol
            New-ItemProperty -Path $Protocol -Name "(Default)" -PropertyType String -Value "URL:$( $Protocol )"
            New-ItemProperty -Path $Protocol -Name "URL Protocol" -PropertyType String -Value ""

            # Create more keys and properties for sub items
            Set-Location -Path ".\$( $Protocol )"
            New-Item -Path ".\DefaultIcon"
            New-Item -Path ".\shell\open\command" -force # Creates the items recursively
            New-ItemProperty -Path ".\shell\open\command" -Name "(Default)" -PropertyType String -Value """powershell.exe"" -File ""$( $Script:moduleRoot )\bin\callback.ps1"" ""%1"""

            # Go back to original path
            Set-Location -path $currentLocation.Path

            Write-Log -message "Created the registry entries"


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

            # Prepare redirect URI
            $redirectUri = "$( $Protocol )://localhost"

            # 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', $redirectUri) # 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()

            # Remove callback file if it exists
            If ( Test-Path -Path $CallbackFile ) {
                "Removing callback file '$( $CallbackFile )'"
                Remove-Item $CallbackFile -Force
            }

            # 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

            # Wait
            Write-Log -message "Waiting for the callback file '$( $CallbackFile )'"
            Do {
                Write-Host "." -NoNewline
                Start-Sleep -Milliseconds 500
            } Until ( Test-Path -Path $callbackFile )

            Write-Log -message "Callback file found '$( $CallbackFile )'"

            # Read and parse callback file
            $callback = Get-Content -Path $callbackFile -Encoding utf8
            $callbackUri = [uri]$callback
            $callbackUriSegments = [System.Web.HttpUtility]::ParseQueryString($callbackUri.Query)
            $code = $callbackUriSegments["code"]

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

            # Check the state
            If ( $State.length -gt 0 ) {
                If ( $callbackUriSegments["state"] -ne $State ) {
                    throw "State of initial call does not match the returned state! Exit!"
                    Exit 0
                } else {
                    Write-Log "State was accepted!"
                }
            }


            #-----------------------------------------------
            # 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" = $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 ) {
                ConvertTo-Json -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
            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
            }

        } catch {

            Write-Log "ERROR: $( $_.Exception.Message )" -Severity ERROR
            throw $_.Exception

        } finally {

            #-----------------------------------------------
            # HOUSEKEEPING OF REGISTRY
            #-----------------------------------------------

            Write-Log -message "Removing temporary registry entries"

            # current path - will get back to this at the end
            $currentLocation = Get-Location

            # Switch to registry - choose the current user to not need admin rights
            $root = "Registry::HKEY_CURRENT_USER\Software\Classes" # User registry - needs no elevated rights
            # $root = "Registry::HKEY_CLASSES_ROOT" # Global registry - needs admin rights

            # Switch to root path of registry
            Set-Location -Path $root

            # Remove item now
            Remove-Item $Protocol -Recurse

            # Go back to original path
            Set-Location -path $currentLocation.Path


            #-----------------------------------------------
            # HOUSEKEEPING OF FILES
            #-----------------------------------------------

            Write-Log -message "Removing callback file now"

            # Remove callback file
            Remove-Item $CallbackFile -Force


            #-----------------------------------------------
            # LOG
            #-----------------------------------------------

            Write-Log -Severity INFO -Message "You can close your browser window now!"

        }

    }

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