OktaPS.psm1

## CLASSES ##

class Actor {
    [ValidateNotNullOrEmpty()]
    [string]$id
    [string]$type
    [string]$alternateId
    [string]$displayName
    [hashtable]$detailEntry

    Actor([object]$hashtable) {
        $this.id = $hashtable.id
        $this.type = $hashtable.type
        $this.alternateId = $hashtable.alternateId
        $this.displayName = $hashtable.displayName
        $this.detailEntry = $hashtable.detailEntry
    }

    [string] ToString() {
        return $this.displayName
    }
}

class Target {
    [ValidateNotNullOrEmpty()]
    [string]$id
    [string]$type
    [string]$alternateId
    [string]$displayName
    [object]$detailEntry

    Target([object]$hashtable) {
        $this.id = $hashtable.id
        $this.type = $hashtable.type
        $this.alternateId = $hashtable.alternateId
        $this.displayName = $hashtable.displayName
        $this.detailEntry = $hashtable.detailEntry
    }

    [string] ToString() {
        return $this.displayName
    }
}

class UserAgent {
    [string]$browser
    [string]$os
    [string]$rawUserAgent

    UserAgent([object]$hashtable) {
        $this.browser = $hashtable.browser
        $this.os = $hashtable.os
        $this.rawUserAgent = $hashtable.rawUserAgent
    }
}

class Request {
    [IpAddress[]]$ipChain

    Request([object]$hashtable) {
        $this.ipChain = $hashtable.ipChain
    }
}

class Geolocation {
    [string]$lat
    [string]$lon

    Geolocation([object]$hashtable) {
        $this.lat = $hashtable.lat
        $this.lon = $hashtable.lon
    }
}

class GeographicalContext {
    [Geolocation]$geolocation
    [string]$city
    [string]$state
    [string]$country
    [string]$postalCode

    GeographicalContext([object]$hashtable) {
        $this.geolocation = $hashtable.geolocation
        $this.city = $hashtable.city
        $this.state = $hashtable.state
        $this.country = $hashtable.country
        $this.postalCode = $hashtable.postalCode
    }
}

class Client {
    [string]$id
    [UserAgent]$userAgent
    [GeographicalContext]$geographicalContext
    [string]$zone
    [string]$ipAddress
    [string]$device

    Client([object]$hashtable) {
        $this.id = $hashtable.id
        $this.userAgent = $hashtable.userAgent
        $this.geographicalContext = $hashtable.geographicalContext
        $this.zone = $hashtable.zone
        $this.ipAddress = $hashtable.ipAddress
        $this.device = $hashtable.device
    }
}

class Outcome {
    [string]$result
    [string]$reason

    Outcome([object]$hashtable) {
        $this.result = $hashtable.result
        $this.reason = $hashtable.reason
    }

    [string] ToString() {
        return $this.result
    }
}

class Transaction {
    [string]$id
    [string]$type
    [hashtable]$detail

    Transaction([object]$hashtable) {
        $this.id = $hashtable.id
        $this.type = $hashtable.type
        $this.detail = $hashtable.detail
    }
}

class DebugContext {
    [hashtable]$debugData

    DebugContext([object]$hashtable) {
        $this.debugData = $hashtable.debugData
    }
}

class Issuer {
    [string]$id
    [string]$type

    Issuer([object]$hashtable) {
        $this.id = $hashtable.id
        $this.type = $hashtable.type
    }
}

class AuthenticationContext {
    [string]$authenticationProvider
    [int]$authenticationStep
    [string]$credentialProvider
    [string]$credentialType
    [Issuer]$issuer
    [string]$externalSessionId
    [string]$interface

    AuthenticationContext([object]$hashtable) {
        $this.authenticationProvider = $hashtable.authenticationProvider
        $this.authenticationStep = $hashtable.authenticationStep
        $this.credentialProvider = $hashtable.credentialProvider
        $this.credentialType = $hashtable.credentialType
        $this.issuer = $hashtable.issuer
        $this.externalSessionId = $hashtable.externalSessionId
        $this.interface = $hashtable.interface
    }
}

class SecurityContext {
    [int]$asNumber
    [string]$asOrg
    [string]$isp
    [string]$domain
    [bool]$isProxy

    SecurityContext([object]$hashtable) {
        $this.asNumber = $hashtable.asNumber
        $this.asOrg = $hashtable.asOrg
        $this.isp = $hashtable.isp
        $this.domain = $hashtable.domain
        $this.isProxy = $hashtable.isProxy
    }
}

class IpAddress {
    [string]$ip
    [GeographicalContext]$geographicalContext
    [string]$version
    [string]$source

    IpAddress([object]$hashtable) {
        $this.ip = $hashtable.ip
        $this.geographicalContext = $hashtable.geographicalContext
        $this.version = $hashtable.version
        $this.source = $hashtable.source
    }
}

class LogEvent {
    [string]$uuid
    [datetime]$published
    [string]$eventType
    [string]$version
    [string]$severity
    [string]$legacyEventType
    [string]$displayMessage
    [Actor]$actor
    [Client]$client
    [Request]$request
    [Outcome]$outcome
    [Target[]]$target
    [Transaction]$transaction
    [DebugContext]$debugContext
    [AuthenticationContext]$authenticationContext
    [SecurityContext]$securityContext

    LogEvent([object]$hashtable) {
        $this.uuid = $hashtable.uuid
        $this.published = $hashtable.published
        $this.eventType = $hashtable.eventType
        $this.version = $hashtable.version
        $this.severity = $hashtable.severity
        $this.legacyEventType = $hashtable.legacyEventType
        $this.displayMessage = $hashtable.displayMessage
        $this.actor = $hashtable.actor
        $this.target = $hashtable.target
        $this.client = $hashtable.client
        $this.request = $hashtable.request
        $this.outcome = $hashtable.outcome
        $this.transaction = $hashtable.transaction
        $this.debugContext = $hashtable.debugContext
        $this.authenticationContext = $hashtable.authenticationContext
        $this.securityContext = $hashtable.securityContext
    }
}


class OktaDateTime {
    [DateTime] $Value

    OktaDateTime([DateTime] $value) {
        $this.Value = $value
    }

    static [OktaDateTime] Parse([string] $value) {
        return [OktaDateTime]::new([DateTime]::Parse($value))
    }

    [string] ToString() {
        return $this.Value.ToString()
    }
}


enum GroupType {
    OKTA_GROUP
    APP_GROUP
    BUILT_IN
}

class OktaGroup {
    [hashtable]          $_embedded
    [hashtable]          $_links
    [DateTime]           $created
    [ValidateNotNullOrEmpty()]
    [string]             $id
    [DateTime]           $lastMembershipUpdated
    [DateTime]           $lastUpdated
    [string[]]           $objectClass
    [PSCustomObject]     $profile
    [GroupType]          $type
    [string[]]           $source
    
    OktaUser([string]$name) {
        Get-OktaGroup -Name $name -ErrorAction Stop
    }

    OktaGroup([object]$hashtable) {
        # $this._embedded = $hashtable._embedded
        # $this._links = $hashtable._links
        $this.created               = $hashtable.created ?? [DateTime]::MinValue
        $this.id                    = $hashtable.id
        $this.lastMembershipUpdated = $hashtable.lastMembershipUpdated ?? [DateTime]::MinValue
        $this.lastUpdated           = $hashtable.lastUpdated ?? [DateTime]::MinValue
        $this.objectClass           = $hashtable.objectClass
        $this.profile               = $hashtable.profile
        $this.type                  = $hashtable.type ?? "OKTA_GROUP"
        $this.source                = $hashtable.source.id
    }
}


Class OktaUser {
    [ValidateNotNullorEmpty()]
    [string] $id
    [string] $status
    [string] $firstName
    [string] $lastName
    [string] $login
    [string] $enabled
    [datetime] $created
    [datetime] $activated
    [datetime] $statusChanged
    [datetime] $lastLogin
    [datetime] $lastUpdated
    [datetime] $passwordChanged 
    [string] $type
    # [OktaGroup] $_groups
    [string] $_links
    [hashtable] $_profile

    OktaUser([string]$identity) {
        $that = Get-OktaUser -Identity $identity -ErrorAction Stop
        foreach($key in $that.psobject.properties.name) {
            try {
                $this.$key = $that.$key
            } catch {
                $this | Add-Member -NotePropertyName $key -NotePropertyValue $that.$key
            }
        }
    }

    OktaUser(
        [string]$id,
        [string]$status,
        [string]$firstName,
        [string]$lastName,
        [string]$login,
        [datetime]$created,
        [datetime]$activated,
        [datetime]$statusChanged,
        [datetime]$lastLogin,
        [datetime]$lastUpdated,
        [datetime]$passwdChanged
        # [string]$type
    ){
        $this.id            = $id
        $this.status        = $status 
        $this.firstName     = $firstName
        $this.lastName      = $lastName
        $this.login         = $login
        $this.enabled       = $status -in @("STAGED", "PROVISIONED", "ACTIVE", "RECOVERY", "PASSWORD_EXPIRED", "LOCKED_OUT", "SUSPENDED") ? $True : $False
        $this.created       = $created
        $this.activated     = $activated
        $this.statusChanged = $statusChanged
        $this.lastLogin     = $lastLogin
        $this.lastUpdated   = $lastUpdated
        $this.passwordChanged   = $passwdChanged
        # $this.type = $type
    }
}


## PRIVATE MODULE FUNCTIONS AND DATA ##

Function Clear-OktaAuthentication {
    Remove-Variable -Scope Script -Name "Okta*"
}

Function Connect-OktaAPI {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]
        $OktaDomain,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $API
    )

    # use IWR to create a web session variable
    $null = Invoke-WebRequest -Uri $OktaDomain -SessionVariable OktaSSO
    $OktaSSO.Headers.Add("Authorization", "SSWS $API")

    # test API key is valid
    Try {
        $null = Invoke-WebRequest -Uri "$OktaDomain/api/v1/users/me" -Method "GET" -WebSession $OktaSSO
    } Catch {
        throw
    }

    Set-OktaAuthentication -AuthorizationMode "SSWS" -Session $OktaSSO -Domain $OktaDomain

    Return
}

Function Connect-OktaCredential {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]
        $OktaDomain,

        # Parameter help description
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCredential]
        $Credential
    )

    $okta_authn = Invoke-RestMethod -Uri "$OktaDomain/api/v1/authn" -Method "POST" -Body (@{
        "username" = $Credential.GetNetworkCredential().username
        "password" = $Credential.GetNetworkCredential().password
        "option" = @{
            "multiOptionalFactorEnroll" = "false"
            "warnBeforePasswordExpired" = "false"
        }
    } | ConvertTo-Json) -ContentType "application/json" -SessionVariable OktaSSO

    switch ($okta_authn.status) {
        SUCCESS { 
            $session_token = $okta_authn.sessionToken
        }

        MFA_REQUIRED {
            Write-Host "MFA is required"

            # if more than one mfa, prompt, or select the first one
            # https://developer.okta.com/docs/reference/api/factors/#supported-factors-for-providers
            $factorList = $okta_authn._embedded.factors
            If($factorList.count -gt 1) {
                $availableFactors = $factorList | ForEach-Object { $_.provider.ToLower() + "::" + $_.factorType }
                $chosenFactorIndex = Read-OktaFactorPrompt -AvailableFactors $availableFactors
            } else {
                $chosenFactorIndex = 0
            }
            $chosenFactor = $factorList[$chosenFactorIndex].provider.ToLower() + "::" + $factorList[$chosenFactorIndex].factorType
            switch ($chosenFactor) {
                "okta::push" {
                    $session_token = Send-OktaFactorProviderOkta -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken
                }

                "okta::token:software:totp" {
                    $totp = Read-Host "Enter code from Okta Verify app"
                    $session_token = Send-OktaFactorProviderOkta -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken -Passcode $totp
                }

                "duo::web" {
                    $session_token = Send-OktaFactorProviderDuo -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken
                }

                Default {
                    Write-Error "Unknown factor type: $factorType"
                    Return
                }
            }
        }

        Default {
            Write-Host "Authentication failed, unknown error."
            Return $okta.status
        }
    }

    # Trade session token for a session cookie
    $null = Invoke-WebRequest -Method "GET" -Uri "$OktaDomain/login/sessionCookieRedirect?token=$session_token&redirectUrl=$OktaDomain" -SkipHttpErrorCheck -WebSession $OktaSSO -MaximumRedirection 5
    $session = Invoke-RestMethod -Method "GET" -Uri "$OktaDomain/api/v1/sessions/me" -WebSession $OktaSSO

    # Get an XSRF Token
    #
    $OktaAdminDomain = Get-OktaAdminDomain -Domain $OktaDomain
    $dashboard = Invoke-WebRequest -Method "GET" -Uri "$OktaAdminDomain/admin/dashboard" -WebSession $OktaSSO
    If($dashboard.content -match '(?:id="_xsrfToken".*?>)(?<xsrfToken>.*?)(?:<)') {
        If($Matches.xsrfToken.Length -gt 0) {
            $Script:OktaXSRF = $Matches.xsrfToken
        } else {
            Write-Warning "XSRF token length is 0. Some Okta endpoints might not be available."
        }
    } else {
        Write-Warning "Unable to get XSRF token. Some Okta endpoints might not be available."
    }

    Set-OktaAuthentication -AuthorizationMode "Credential" -Session $OktaSSO -Domain $OktaDomain -ExpiresAt $session.expiresAt -Username $Credential.GetNetworkCredential().username
}


Function Connect-OktaPrivateKey {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]
        $OktaDomain,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $ClientId,

        # Parameter help description
        [Parameter(Mandatory)]
        [String[]]
        $Scopes,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $PrivateKey
    )

    $OAuthUrl = "$OktaDomain/oauth2/v1/token"
    $payload = @{
        "aud" = $OAuthUrl
        "iss" = $ClientId
        "sub" = $ClientId
    }

    $jwt = New-JsonWebToken -Claims $payload -HashAlgorithm SHA256 -PrivateKey $PrivateKey

    # Get an limited lifetime access token
    $auth = Invoke-RestMethod -Method "POST" -Uri $OAuthUrl -Body @{
        "grant_type" = "client_credentials"
        "scope" = $Scopes -join " "
        "client_assertion_type" = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
        "client_assertion" = $jwt
    }

    # use IWR to create a web session variable
    $null = Invoke-WebRequest -Uri $OktaDomain -SessionVariable OktaSSO
    $OktaSSO.Headers.Add("Authorization", "$($auth.token_type) $($auth.access_token)")

    Set-OktaAuthentication -AuthorizationMode "PrivateKey" -Session $OktaSSO -Domain $OktaDomain -ExpiresIn $auth.expires_in

    Return
}

Function ConvertTo-OktaActor {
    [CmdletBinding()]
    param (
        [Parameter()]
        [Object]
        $Actor
    )

    $Actor | ForEach-Object {
        If($null -eq $_) {
            return 
        }
        
        [Actor]@{
            id = $_.id
            type = $_.type
            alternateId = $_.alternateId
            displayName = $_.displayName
            detailEntry = $_.detailEntry
        }
    }
}


Function ConvertTo-OktaApp {
    [CmdletBinding()]
    param (
        [Parameter()]
        [PSCustomObject[]]
        $InputObject
    )

    $InputObject | ForEach-Object {
        Add-Member -InputObject $_ -TypeName "Okta.App"
    }

    Return $InputObject
}

Function ConvertTo-OktaAppUser {
    # Takes Okta User API response and formats it in Okta.AppUser object. Pulls profile values up to top level.
    [CmdletBinding()]
    param (
        [Parameter()]
        [PSCustomObject[]]
        $InputObject
    )

    $OktaAppUserCollection = @(foreach($OktaAppUser in $InputObject) {
        $OktaAppUserObject = [PSCustomObject]@{
            PSTypeName      = 'Okta.AppUser'
            id              = $OktaAppUser.id
            externalId      = $OktaAppUser.externalId
            created         = $OktaAppUser.created
            lastUpdated     = $OktaAppUser.lastUpdated
            scope           = $OktaAppUser.scope
            status          = $OktaAppUser.status
            statusChanged   = $OktaAppUser.statusChanged
            passwordChanged = $OktaAppUser.passwordChanged
            syncstate       = $OktaAppuser.syncState
            username        = $OktaAppUser.credentials.userName
            _links          = $OktaAppUser._links
        }

        $properties = $OktaAppUser.psobject.properties.name
        $appProfile    = $OktaAppUser.profile.psobject.properties.name
        # pull profile attributes up one level
        $appProfile | ForEach-Object {
            # Append profile_ to attribute name if exists in properties
            If($_ -in $properties) {
                $_ = "profile_$($_)"
            }

            $OktaAppUserObject | Add-Member -MemberType NoteProperty -Name $_ -Value $OktaAppUser.profile.$_
        }

        $OktaAppUserObject
    })

    Return $OktaAppUserCollection
}

Function ConvertTo-OktaGroup {
    [CmdletBinding()]
    param (
        [Parameter()]
        [Object[]]
        $InputObject
    )

    Process {
        foreach($OktaGroup in $InputObject) {
            try {
                $OktaGroupObject = [OktaGroup]$OktaGroup
            } catch {
                throw [System.ArgumentException] "Invalid input object. Could not create OktaGroup object."
            }

            # pull profile values to top-level for convenience
            # if profile attr collides with exisitng OktaGroup property, prepend "profile_" before the attr
            $groupProfile = @{}
            $properties = $OktaGroupObject.psobject.properties.name
            $attributes = $OktaGroup.profile.psobject.properties
            $attributes | Foreach-Object { 
                If($_.Name -in $properties) {
                    $groupProfile["profile_$($_.Name)"] = $_.Value
                } else {
                    $groupProfile[$_.Name] = $_.Value
                }
            }

            # -NotePropertyMembers faster than individually looping -NotePropertyName
            $OktaGroupObject | Add-Member -NotePropertyMembers $groupProfile

            $OktaGroupObject
        }
    }
}


Function ConvertTo-OktaLogEvent {
    [CmdletBinding()]
    param (
        [Parameter()]
        [Object[]]
        $LogEvent
    )

    $LogEvent | ForEach-Object {
        If($null -eq $_) {
            return 
        }

        $actor = ConvertTo-OktaActor $_.actor
        $target = ConvertTo-OktaTarget $_.target

        [LogEvent]@{
            uuid = $_.uuid
            published = $_.published.ToLocalTime()
            eventType = $_.eventType
            version = $_.version
            severity = $_.severity
            legacyEventType = $_.legacyEventType
            displayMessage = $_.displayMessage
            actor = $actor
            target = @($target)
            client = [Client]$_.client
            request = [Request]$_.request
            outcome = [Outcome]$_.outcome
            # transaction = [Transaction]$_.transaction
            # debugContext = [DebugContext]$_.debugContext
            authenticationContext = [AuthenticationContext]$_.authenticationContext
            securityContext = [SecurityContext]$_.securityContext
        }
    }
}


Function ConvertTo-OktaTarget {
    [CmdletBinding()]
    param (
        [Parameter()]
        [Object]
        $Target
    )

    $Target | ForEach-Object {
        If($null -eq $_) {
            return 
        }

        [Target]@{
            id = $_.id
            type = $_.type
            alternateId = $_.alternateId
            displayName = $_.displayName
            detailEntry = $_.detailEntry
        }
    }
}


Function ConvertTo-OktaUser {
    # Takes Okta User API response and formats it in OktaUser object. Pulls profile values up to top level.
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline, Mandatory)]
        [PSCustomObject[]]
        $InputObject
    )

    Process {
        foreach($OktaUser in $InputObject) {
            try {
                $OktaUserObject = [OktaUser]::new(
                    $OktaUser.id,
                    $OktaUser.status,
                    $OktaUser.profile.firstName,
                    $OktaUser.profile.lastName,
                    $OktaUser.profile.login,
                    ($OktaUser.created ?? [DateTime]::MinValue),
                    ($OktaUser.activated ?? [DateTime]::MinValue),
                    ($OktaUser.statusChanged ?? [DateTime]::MinValue),
                    ($OktaUser.lastLogin ?? [DateTime]::MinValue),
                    ($OktaUser.lastUpdated ?? [DateTime]::MinValue),
                    ($OktaUser.passwordChanged ?? [DateTime]::MinValue)
                    # $OktaUser.type
                )
            } catch {
                throw [System.ArgumentException] "Invalid input object. Could not create OktaUser object."
            }

            # pull profile values to top-level for convinience
            # if profile attr collides with exisitng OktaUser property, prepend "profile_" before the attr
            # raw profile attr are also set in _profile
            $userProfile = @{}
            $properties = $OktaUserObject.psobject.properties.name
            $attributes = $OktaUser.profile.psobject.properties | Where-Object { $_.name -notin @("firstName", "lastName", "login")}
            $attributes| Foreach-Object { 
                If($_.Name -in $properties) {
                    $userProfile["profile_$($_.Name)"] = $_.Value
                } else {
                    $userProfile[$_.Name] = $_.Value
                }
            }

            # -NotePropertyMembers faster than individually looping -NotePropertyName
            $OktaUserObject | Add-Member -NotePropertyMembers $userProfile

            $OktaUserObject
        }
    }
}


Function Get-OktaAdminDomain {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]
        $Domain
    )

    $Uri = [System.Uri]$Domain
    $domainParts = $Uri.Host.Split('.')
    $domainParts[0] = $domainParts[0] + "-admin"
    $OktaAdminDomain = $domainParts -join '.'

    Return "https://$OktaAdminDomain"
}

Function Get-OktaConfig {
    [CmdletBinding()]
    param (
        [Parameter()]
        [ArgumentCompleter({ OktaConfigPathArgumentCompleter @args })]
        [String]
        $Path
    )

    # If a path is provided, use it
    If(-not [String]::IsNullOrEmpty($Path)) {
        If(Test-Path $Path) {
            Return $Path
        }

        # if the path is absolute, no use in searching for it
        If([System.IO.Path]::IsPathRooted($Path)) {
            Write-Error "Could not find Okta config file at $Path"
            Return
        }
    }

    $config = OktaConfigPathArgumentCompleter -wordToComplete $Path
    
    If($config.Count -eq 0) {
        Write-Error "Could not find Okta config file"
        Return
    }
    
    Return $config[0].FullName
}


Function Get-OktaPrivateVariables {
    Get-Variable -Name "Okta*" -Scope Script
}

Function Invoke-OktaRequest {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [String]
        $Method = "GET",

        [Parameter(Mandatory)]
        [String]
        $Endpoint,

        [Parameter()]
        [Hashtable]
        $Headers,

        [Parameter()]
        [Hashtable]
        $Query,

        [Parameter()]
        [Hashtable]
        $Body,

        [Parameter()]
        [Switch]
        $PassThru,

        [Parameter()]
        [Switch]
        $NoPagination
    )

    $webrequest_parameters = @{}
    $built_headers = @{}

    # Check cache for valid session cookies and expiration
    If($Script:OktaSSO) {
        If(-not (Test-OktaAuthentication)) {
            Update-OktaAuthentication
        }
    } else {
        Connect-Okta
    }

    $webrequest_parameters['WebSession'] = $Script:OktaSSO

    If($Script:OktaXSRF) {
        $built_headers['X-Okta-XsrfToken'] = $Script:OktaXSRF
    }

    # Query parameters
    If($Query) {
        $url_builder = @{}
        Foreach($k in $Query.Keys) {
            $url_builder[$k] = $Query[$k]
        }
        $querystring = New-HttpQueryString -QueryParameter $url_builder

        $Endpoint = $Endpoint + "?" + $querystring
    }

    # Body
    If($Body) {
        $built_headers['Accept'] = "application/json"
        $built_headers['Content-Type'] = "application/json"

        $webrequest_parameters['Body'] = $Body | ConvertTo-Json -Depth 99
    }

    # Build request headers
    Foreach($k in $Headers.Keys) {
        $built_headers[$k] = $Headers[$k]
        Write-Debug "Adding header to request ${k}: $Headers[$k]"
    }

    # Request
    # TODO: Add ability to send request to OktaDomain or OktaAdminDomain (default)
    $request_uri = "$Script:OktaAdminDomain/$Endpoint"

    if ($PSCmdlet.ShouldProcess($request_uri)) {
        # supports pagination
        $next = $True
        $return = while($next) {
            $Script:OktaDebugLastRequestUri = $request_uri
            try {
                $response = Invoke-WebRequest -Method $Method -Uri $request_uri -Headers $built_headers -SkipHeaderValidation @webrequest_parameters
            } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
                If($_.Exception.Response.StatusCode -eq 429) {
                    Write-Debug "X-Rate-Limit-Limit: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Limit'))"
                    Write-Debug "X-Rate-Limit-Remaining: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Remaining'))"
                    Write-Debug "X-Rate-Limit-Reset: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Reset'))"

                    $limit_reset = [System.DateTimeOffset]::FromUnixTimeSeconds($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Reset')[0])
                    $offset = $limit_reset.Offset.TotalSeconds
                    Write-Host "Okta Rate Limit Exceeded. $($limit_reset.LocalDateTime.ToString())"

                    # TODO: wait until time elapses and continue
                    Start-Sleep -Seconds $offset
                }
            } catch {
                Write-Host "Unknown error occurred."
                Throw $_
            }

            # Response
            If($PassThru) {
                $response

            } elseif(($response.StatusCode -ge 200) -and ($response.StatusCode -le 299)) {
                $response.Content | ConvertFrom-Json
                
            } else {
                # uncaught status code, return the raw and exit
                Return $response
            }

            # pagination
            If($response.RelationLink.ContainsKey('next') -and ($NoPagination -eq $False)) {
                $request_uri = $response.RelationLink['next']
            } else {
                $next = $False
            }
        }
    }
    
    Return $return 
}

function New-HttpQueryString {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Hashtable]
        $QueryParameter
    )

    $join = @(
        foreach($k in $QueryParameter.Keys) {
            # https://stackoverflow.com/questions/46336763/c-sharp-net-how-to-encode-url-space-with-20-instead-of
            $fv_pair = "{0}={1}" -f $k, [System.Uri]::EscapeDataString($QueryParameter[$k])
            $fv_pair
        }
    )
    
    $querystring = $join -join '&'

    return $querystring
}

# Copyright 2020 Anthony Guimelli
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Adapted from https://github.com/anthonyg-1/PSJsonWebToken
# Removed unused parameters and added only private key signing

function New-JsonWebToken {
    <#
    .SYNOPSIS
        Generates a JSON Web Token.
    .DESCRIPTION
        Generates a signed JSON Web Token (JWS) with options for specifying a JWK URI in the header.
    .PARAMETER Claims
        The claims for the token expressed as a hash table.
    .PARAMETER HashAlgorithm
        The hash algorthim for the signature. Acceptable values are SHA256, SHA384, and SHA512. Default value is SHA256.
    .PARAMETER PrivateKey
        The private key to use for signing the token.
    .OUTPUTS
        System.String

            The JSON Web Token is returned as a base64 URL encoded string.
    .LINK
        https://tools.ietf.org/html/rfc7519
        https://tools.ietf.org/html/rfc7515
        https://tools.ietf.org/html/rfc7517
#>

    [CmdletBinding()]
    [OutputType([System.String])]
    Param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [System.Collections.Hashtable]$Claims,

        [Parameter(Mandatory = $false, Position = 1)]
        [ValidateSet("SHA256", "SHA384", "SHA512")]
        [String]$HashAlgorithm = "SHA256",

        [Parameter(Mandatory = $true, Position = 2)]
        [ValidateNotNullOrEmpty()]
        [String]$PrivateKey
    )

    PROCESS {
        [string]$jwt = ""

        #1. Construct header:
        $rsaAlg = @{
            "SHA256" = "RS256"
            "SHA384" = "RS384"
            "SHA512" = "RS512"
        }

        $header = [ordered]@{typ = "JWT"; alg = $rsaAlg[$HashAlgorithm] ?? "RS256"} | ConvertTo-JwtPart

        #2. Construct payload for RSA:
        $payload = New-JwtPayloadString -Claims $Claims

        #3. Concatenate encoded header and payload seperated by a full stop:
        $jwtSansSig = "{0}.{1}" -f $header, $payload

        #4. Generate signature for concatenated header and payload:
        [string]$rsaSig = ""
        try 
        {
            $rsaSig = New-JwtRsaSignature -JsonWebToken $jwtSansSig -HashAlgorithm $HashAlgorithm -PrivateKey $PrivateKey

            # $rsaSig = New-JwtSignature -JsonWebToken $jwtSansSig -HashAlgorithm $HashAlgorithm -SigningCertificate $SigningCertificate
        }
        catch 
        {
            $cryptographicExceptionMessage = $_.Exception.Message
            $CryptographicException = New-Object -TypeName System.Security.Cryptography.CryptographicException -ArgumentList $cryptographicExceptionMessage
            Write-Error -Exception $CryptographicException -Category SecurityError -ErrorAction Stop

            # Write-Error -Exception $_.Exception -Category InvalidArgument -ErrorAction Stop
        }

        #5. Construct jws:
        $jwt = "{0}.{1}" -f $jwtSansSig, $rsaSig

        return $jwt
    }
}

function New-JwtPayloadString
{
    [CmdletBinding()]
    [OutputType([System.String])]
    Param
    (
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)][HashTable]$Claims,

        [Parameter(Mandatory=$false,Position=1)]
        [ValidateRange(1,300)]
        [System.Int32]$NotBeforeSkew
    )
    PROCESS
    {
        [string]$payload = ""

        $_claims = [ordered]@{}

        $now = Get-Date
        $currentEpochTime = Convert-DateTimeToEpoch -DateTime $now

        $notBefore = $currentEpochTime
        if ($PSBoundParameters.ContainsKey("NotBeforeSkew"))
        {
            $notBefore = Convert-DateTimeToEpoch -DateTime ($now.AddSeconds(-$NotBeforeSkew))
        }

        $futureEpochTime = Convert-DateTimeToEpoch -DateTime ($now.AddSeconds($TimeToLive))

        $_claims.Add("iat", $currentEpochTime)
        $_claims.Add("nbf", $notBefore)
        $_claims.Add("exp", $futureEpochTime)

        foreach ($entry in $Claims.GetEnumerator())
        {
            if (-not($_claims.Contains($entry.Key)))
            {
                $_claims.Add($entry.Key, $entry.Value)
            }
        }

        $payload = $_claims | ConvertTo-JwtPart

        return $payload
    }
}

function Convert-DateTimeToEpoch
{
    <#
    .SYNOPSIS
        Converts a System.DateTime to an epoch (unix) time stamp.
    .EXAMPLE
        Convert-DateTimeToEpoch

        Returns the current datetime as epoch.
    .EXAMPLE
        $iat = Convert-DateTimeToEpoch
        $nbf = (Get-Date).AddMinutes(-3) | Convert-DateTimeToEpoch
        $exp = (Get-Date).AddMinutes(10) | Convert-DateTimeToEpoch

        $jwtPayload = @{sub="username@domain.com";iat=$iat;nbf=$nbf;exp=$exp}

        $jwtPayloadSerializedAndEncoded = $jwtPayload | ConvertTo-JwtPart

        Generates JWT payload with an iat claim of the current datetime, an nbf claim skewed three minutes in the past, and an expiration of ten minutes in the future from the current datetime.
    .PARAMETER DateTime
        A System.DateTime. Default value is current date and time.
    .INPUTS
        System.DateTime
    .OUTPUTS
        System.Int64
    .LINK
        https://en.wikipedia.org/wiki/Unix_time
        ConvertTo-JwtPart
    #>

    [CmdletBinding()]
    [Alias('GetEpoch')]
    [OutputType([System.Int64])]
    Param
    (
        [Parameter(Mandatory=$false,
                   ValueFromPipeline=$true,
                   Position=0)][ValidateNotNullOrEmpty()][Alias("Date")][DateTime]$DateTime=(Get-Date)
    )

    PROCESS
    {
        $dtut = $DateTime.ToUniversalTime()

        [TimeSpan]$ts = New-TimeSpan -Start  (Get-Date "01/01/1970") -End $dtut

        [Int64]$secondsSinceEpoch = [Math]::Floor($ts.TotalSeconds)

        return $secondsSinceEpoch
    }
}

function ConvertTo-JwtPart
{
    <#
    .SYNOPSIS
        Converts an object to a base 64 URL encoded compressed JSON string.
    .DESCRIPTION
        Converts an object to a base 64 URL encoded compressed JSON string. Useful when constructing a JWT header or payload from a InputObject prior to serialization.
    .PARAMETER InputObject
        Specifies the object to convert to a JWT part. Enter a variable that contains the object, or type a command or expression that gets the objects. You can also pipe an object to ConvertTo-JwtPart.
    .EXAMPLE
        $jwtHeader = @{typ="JWT";alg="HS256"}
        $encodedHeader = $jwtHeader | ConvertTo-JwtPart

        Constructs a JWT header from the hashtable defined in the $jwtHeader variable, serializes it to JSON, and base 64 URL encodes it.
    .EXAMPLE
        $header = @{typ="JWT";alg="HS256"}
        $payload = @{sub="someone.else@company.com";title="person"}

        $encodedHeader = $header | ConvertTo-JwtPart
        $encodedPayload = $payload | ConvertTo-JwtPart

        $jwtSansSignature = "{0}.{1}" -f $encodedHeader, $encodedPayload

        $hmacSignature = New-JwtHmacSignature -JsonWebToken $jwtSansSignature -Key "secret"

        $jwt = "{0}.{1}" -f $jwtSansSignature, $hmacSignature

        Constructs a header and payload from InputObjects, serializes and encodes them and obtains an HMAC signature from the resulting joined values.
    .INPUTS
        System.Object
    .OUTPUTS
        System.String
    .LINK
        New-JwtHmacSignature
        New-JsonWebToken
        Test-JsonWebToken
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 0)]
        [ValidateNotNullOrEmpty()][System.Object]$InputObject
    )
    BEGIN {
        $argumentExceptionMessage = "Unable to serialize and base64 URL encode passed InputObject."
        $ArgumentException = New-Object -TypeName ArgumentException -ArgumentList $argumentExceptionMessage
    }
    PROCESS {
        [string]$base64UrlEncodedString = ""
        try {
            $base64UrlEncodedString = $InputObject | ConvertTo-Json -Depth 25 -Compress | ConvertTo-Base64UrlEncodedString
        }
        catch {
            Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
        }

        return $base64UrlEncodedString
    }
}

Function New-JwtRsaSignature
{
    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory=$true,ValueFromPipeline=$false,Position=0)]
        [ValidateLength(16,131072)][Alias("JWT", "Token")][String]$JsonWebToken,

        [Parameter(Position=1,Mandatory=$true)]
        [ValidateSet("SHA256","SHA384","SHA512")]
        [String]$HashAlgorithm,

        [Parameter(Mandatory=$true,Position=2)]
        [String]$PrivateKey
    )

    BEGIN
    {
        $decodeExceptionMessage = "Unable to decode JWT."
        $ArgumentException = New-Object -TypeName ArgumentException -ArgumentList $decodeExceptionMessage
    }

    PROCESS
    {
        [string]$stringSig = ""

        # Test JWT structure:
        [bool]$isValidJwt = Test-JwtStructure -JsonWebToken $JsonWebToken
        if (-not($isValidJwt))
        {
            Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
        }

        # JWT should only have 2 parts right now:
        if (($JsonWebToken.Split(".").Count) -ne 2)
        {
            Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
        }

        # Create an instance of the RSAPKCS1SignatureFormatter class that will ultimately be used to generate the signature:
        $rsaSigFormatter = [System.Security.Cryptography.RSAPKCS1SignatureFormatter]::new()

        # Create an instance of the RSACryptoServiceProvider class that will be used to import the private key in a usable format:
        $rsaProvider = [System.Security.Cryptography.RSACryptoServiceProvider]::new()

        # Split the private key into blocks and convert the second block to a byte array:
        $privateKeyBlocks = $privateKey.Split("-", [System.StringSplitOptions]::RemoveEmptyEntries)
        $privateKeyBytes = [System.Convert]::FromBase64String($privateKeyBlocks[1])
        
        $rsaProvider.ImportPkcs8PrivateKey($privateKeyBytes, [ref] $null)
        
        # Set the RSA key to use for signing:
        $rsaSigFormatter.SetKey($rsaProvider)

        # Set the RSA hash algorithm based on the RsaHashAlgorithm passed:
        $rsaSigFormatter.SetHashAlgorithm($HashAlgorithm.ToString())

        # Convert the incoming string $JsonWebToken into a byte array:
        [byte[]]$message = [System.Text.Encoding]::UTF8.GetBytes($JsonWebToken)

        # The byte array that will contain the resulting hash to be signed:
        [byte[]]$messageDigest = $null

        # Create a SHA256, SHA384 or SHA512 hash and assign it to the messageDigest variable:
        switch ($HashAlgorithm)
        {
            "SHA256"
            {
                $shaAlg = [System.Security.Cryptography.SHA256]::Create()
                $messageDigest = $shaAlg.ComputeHash($message)
                break
            }
            "SHA384"
            {
                $shaAlg = [System.Security.Cryptography.SHA384]::Create()
                $messageDigest = $shaAlg.ComputeHash($message)
                break
            }
            "SHA512"
            {
                $shaAlg = [System.Security.Cryptography.SHA512]::Create()
                $messageDigest = $shaAlg.ComputeHash($message)
                break
            }
            default
            {
                $shaAlg = [System.Security.Cryptography.SHA512]::Create()
                $messageDigest = $shaAlg.ComputeHash($message)
                break
            }
        }

        # Create the signature:
        [byte[]]$sigBytes = $null
        try
        {
            $sigBytes = $rsaSigFormatter.CreateSignature($messageDigest)
        }
        catch
        {
            $signingErrorMessage = "Unable to sign $JsonWebToken with certificate with thumbprint {0}. Ensure that CSP for this certificate is 'Microsoft Enhanced RSA and AES Cryptographic Provider' and try again. Additional error info: {1}" -f $thumbprint, $_.Exception.Message
            Write-Error -Exception ([CryptographicException]::new($signingErrorMessage)) -Category SecurityError -ErrorAction Stop
        }

        # Return the Base64 URL encoded signature:
        $stringSig = ConvertTo-Base64UrlEncodedString -Bytes $sigBytes

        return $stringSig
    }
}

function Test-JwtStructure 
{
    <#
    .SYNOPSIS
        Tests a JWT for structural validity.
    .DESCRIPTION
        Validates that a JSON Web Token is structurally valid by returing a boolean indicating if the passed JWT is valid or not.
    .PARAMETER JsonWebToken
        Contains the JWT to structurally validate.
    .PARAMETER VerifySignaturePresent
        Determines if the passed JWT has three parts (signature being the third).
    .EXAMPLE
        $jwtSansSig = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1Njc4OTAsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlfQ"
        Test-JwtStructure -JsonWebToken $jwtSansSig

        Validates the structure of a JWT without a signature.
    .EXAMPLE
        $jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.VG6H-orYnMLknmJajHx1HW9SftqCWeqE3TQ1UArx3Mk"
        Test-JwtStructure -JsonWebToken $jwt

        Validates the structure of a JWT with a signature.
    .NOTES
        By default a passed JWT's header and payload should base 64 URL decoded JSON. The VerifySignaturePresent switch ensures that all three parts exist seperated by a full-stop (header, payload, signature).
    .OUTPUTS
        System.Boolean
    .LINK
        https://tools.ietf.org/html/rfc7519
        https://en.wikipedia.org/wiki/RSA_(cryptosystem)
        https://en.wikipedia.org/wiki/HMAC
    #>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param ( [
        Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0)]
        [ValidateLength(16, 131072)]
        [System.String]$JsonWebToken,

        [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1)]
        [Switch]$VerifySignaturePresent
    )
    PROCESS {
        $arrayCellCount = $JsonWebToken.Split(".") | Measure-Object | Select-Object -ExpandProperty Count

        if ($PSBoundParameters.ContainsKey("VerifySignaturePresent")) {
            if ($arrayCellCount -lt 3) {
                return $false
            }
            else {
                $jwtSignature = $JsonWebToken.Split(".")[2]

                if ($jwtSignature.Length -le 8) {
                    return $false
                }
            }
        }
        else {
            if ($arrayCellCount -lt 2) {
                return $false
            }
        }

        # Test deserialization against header:
        $jwtHeader = $JsonWebToken.Split(".")[0]

        if ($jwtHeader.Length -le 8) {
            return $false
        }

        [string]$jwtHeaderDecoded = ""
        try {
            $jwtHeaderDecoded = $jwtHeader | ConvertFrom-Base64UrlEncodedString
        }
        catch {
            return $false
        }

        $jwtHeaderDeserialized = $null
        try {
            $jwtHeaderDeserialized = $jwtHeaderDecoded | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            return $false
        }

        # Per RFC 7515 section 4.1.1, alg is the only required parameter in a JWT header:
        if ($null -eq $jwtHeaderDeserialized.alg) {
            return $false
        }

        # Test deserialization against payload:
        $jwtPayload = $JsonWebToken.Split(".")[1]

        if ($jwtPayload.Length -le 8) {
            return $false
        }

        [string]$jwtPayloadDecoded = ""
        try {
            $jwtPayloadDecoded = $jwtPayload | ConvertFrom-Base64UrlEncodedString
        }
        catch {
            return $false
        }

        try {
            $jwtPayloadDecoded | ConvertFrom-Json -ErrorAction Stop | Out-Null
        }
        catch {
            return $false
        }

        return $true
    }
}

function ConvertFrom-Base64UrlEncodedString
{
<#
    .SYNOPSIS
        Decodes a base 64 URL encoded string.
    .DESCRIPTION
        Decodes a base 64 URL encoded string such as a JWT header or payload.
    .PARAMETER InputString
        The string to be base64 URL decoded.
    .PARAMETER AsBytes
        Instructions this function to return the result as a byte array as opposed to a default string.
    .EXAMPLE
        "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | ConvertFrom-Base64UrlEncodedString

        Decodes a JWT header.
    .INPUTS
        System.String

            A string is received by the InputString parameter.
    .OUTPUTS
        System.String

            Returns a base 64 URL decoded string for the given input.
    .LINK
        https://tools.ietf.org/html/rfc4648#section-5
#>


    [CmdletBinding()]
    [OutputType([System.String], [System.Byte[]])]
    [Alias('b64d', 'Decode')]
    param (
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
        [string]$InputString,

        [Parameter(Position=1,Mandatory=$false,ValueFromPipeline=$false,ValueFromPipelineByPropertyName=$false)]
        [switch]$AsBytes
    )

    BEGIN
    {
        $argumentExceptionMessage = "The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters."
        $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $argumentExceptionMessage
    }
    PROCESS
    {
        try
        {
            $output = $InputString
            $output = $output.Replace('-', '+') # 62nd char of encoding
            $output = $output.Replace('_', '/') # 63rd char of encoding

            switch ($output.Length % 4) # Pad with trailing '='s
            {
                0 { break }# No pad chars in this case
                2 { $output += "=="; break } # Two pad chars
                3 { $output += "="; break } # One pad char
                default { Write-Error -Exception ([ArgumentException]::new("Illegal base64url string!")) -Category InvalidArgument -ErrorAction Stop }
            }

            # Byte array conversion:
            [byte[]]$convertedBytes = [Convert]::FromBase64String($output)
            if ($PSBoundParameters.ContainsKey("AsBytes"))
            {
                return $convertedBytes
            }
            else
            {
                # String to be returned:
                $decodedString = [System.Text.Encoding]::ASCII.GetString($convertedBytes)
                return $decodedString
            }
        }
        catch
        {
            Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
        }
    }
}

function ConvertTo-Base64UrlEncodedString
{
<#
    .SYNOPSIS
        Base 64 URL encodes an input string.
    .DESCRIPTION
        Base 64 URL encodes an input string required for the payload or header of a JSON Web Token (JWT).
    .PARAMETER InputString
        The string to be base64 URL encoded.
    .PARAMETER Bytes
        The byte array derived from a string to be base64 URL encoded.
    .EXAMPLE
        $jwtPayload = '{"role":"Administrator","sub":"first.last@company.com","jti":"545a310d890F47B9b1F5dc104f782ABD","iat":1551286711,"nbf":1551286711,"exp":1551287011}'
        ConvertTo-Base64UrlEncodedString -InputString $jwtPayload

        Base 64 URL encodes a JSON value.
    .INPUTS
        System.String

            A string is received by the InputString parameter.
    .OUTPUTS
        System.String

            Returns a base 64 URL encoded string for the given input.
    .LINK
        https://tools.ietf.org/html/rfc4648#section-5
#>


    [CmdletBinding()]
    [Alias('b64e', 'Encode')]
    [OutputType([System.String])]
    param (
        [Parameter(Position=0,ParameterSetName="String",Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
        [string]$InputString,

        [Parameter(Position=1,ParameterSetName="Byte Array",Mandatory=$false,ValueFromPipeline=$false,ValueFromPipelineByPropertyName=$false)]
        [byte[]]$Bytes
    )

    PROCESS
    {
        [string]$base64UrlEncodedString = ""

        if ($PSBoundParameters.ContainsKey("Bytes"))
        {
            try
            {

                $output = [Convert]::ToBase64String($Bytes)
                $output = $output.Split('=')[0] # Remove any trailing '='s
                $output = $output.Replace('+', '-') # 62nd char of encoding
                $output = $output.Replace('/', '_') # 63rd char of encoding

                $base64UrlEncodedString = $output
            }
            catch
            {
                $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $_.Exception.Message
                Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
            }
        }
        else
        {
            try
            {
                $encoder = [System.Text.UTF8Encoding]::new()

                [byte[]]$inputBytes = $encoder.GetBytes($InputString)

                $base64String = [Convert]::ToBase64String($inputBytes)

                [string]$base64UrlEncodedString = ""
                $base64UrlEncodedString = $base64String.Split('=')[0] # Remove any trailing '='s
                $base64UrlEncodedString = $base64UrlEncodedString.Replace('+', '-'); # 62nd char of encoding
                $base64UrlEncodedString = $base64UrlEncodedString.Replace('/', '_'); # 63rd char of encoding
            }
            catch
            {
                $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $_.Exception.Message
                Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop
            }
        }

        return $base64UrlEncodedString
    }
}


Function OktaConfigPathArgumentCompleter {
    [CmdletBinding()]
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    # Search in one of the following locations in order of precedence:
    # https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/
    # 1. Environment variables (in this case, cmdlet parameters)
    # 2. An okta.yaml file in a .okta folder in the application or project's root directory
    # 3. An okta.yaml file in a .okta folder in the current user's home directory (~/.okta/okta.yaml or %userprofile%\.okta\okta.yaml)

    $location = @(
        (Join-Path (Get-Location) ".okta"),
        (Join-Path $($env:HOME ?? $env:USERPROFILE) ".okta")
    )

    $configs = Foreach($path in $location) {
        Get-ChildItem -Path $path -Filter "okta.yaml" -Recurse
        Get-ChildItem -Path $path -Filter "okta.yml" -Recurse
        Get-ChildItem -Path $path -Filter "*.yaml" -Recurse | Where-Object { $_.name -notlike "okta.yaml" }
        Get-ChildItem -Path $path -Filter "*.yml" -Recurse | Where-Object { $_.name -notlike "okta.yml" }
    }

    $configs | Where-Object { $_.name -like "*$wordToComplete*" }
}


# Accepts an array of factors and prompts the user to select one, the default is the first one. Returns a string of the
# selected factor.
Function Read-OktaFactorPrompt {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]
        $AvailableFactors
    )

    $title = "Verify it's you with a security method"
    $question = "Select from the following options"
    $choices = @(Foreach($f in $AvailableFactors) {
        Switch($f) {
            "duo::web" {
                [System.Management.Automation.Host.ChoiceDescription]::new("&Duo Security", "Get a push notification from Duo")
            }
            "okta::push" {
                [System.Management.Automation.Host.ChoiceDescription]::new("&Okta Verify Push", "Get a push notification from Okta Verify")
            }
            "okta::token:software:totp" {
                [System.Management.Automation.Host.ChoiceDescription]::new("Okta Verify &Code", "Enter a code from Okta Verify")
            }
            Default {
                Write-Error "Unknown factor type: $f"
            }
        }
    })

    $index = $host.ui.PromptForChoice($title, $question, $choices, 0)

    Return $index
}


Function Send-OktaFactorProviderDuo {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [String]
        $VerifyUrl,

        [Parameter(Mandatory=$true)]
        [String]
        $StateToken
    )

    $okta_verify = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body (@{
        "stateToken" = $StateToken
    } | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO

    # Get Duo settings from Okta
    $duo = $okta_verify._embedded.factor._embedded.verification

    $duo_signature = $duo.signature.split(':')
    $duo_tx = $duo_signature[0]
    $duo_app = $duo_signature[1]

    # Get Duo session ID
    $duo_prompt = ""
    $duo_prompt_sid = ""
    While($duo_prompt.StatusCode -ne 302) {
        $duo_prompt_params = @{}
        if($duo_prompt_sid -ne "") {
            $duo_prompt_params = @{
                body = @{
                    "sid" = $duo_prompt_sid
                }
                ContentType = "application/x-www-form-urlencoded"
                MaximumRedirection = 0 
                SkipHttpErrorCheck = $True
                ErrorAction = "SilentlyContinue"
            }
        }

        $duo_prompt = Invoke-WebRequest -Method "POST" -Uri "https://$($duo.host)/frame/web/v1/auth?tx=$duo_tx&parent=http://0.0.0.0:3000/duo&v=2.1" -WebSession $OktaSSO @duo_prompt_params

        If($duo_prompt.StatusCode -eq 302) {
            $duo_prompt_sid = $duo_prompt.Headers.Location.split('=')[1]
            $duo_prompt_sid = [System.Web.HttpUtility]::UrlDecode($duo_prompt_sid)
        } else {
            $duo_prompt_sid = ($duo_prompt.Content | ConvertFrom-Html).SelectSingleNode("//input[@name='sid']").Attributes["value"].DeEntitizeValue
        }
    }

    # Send a Duo push to default phone1
    $duo_push = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)/frame/prompt" -Body @{
        "sid" = $duo_prompt_sid
        "device" = "phone1"
        "factor" = "Duo Push"
        "out_of_date" = "False"
    } -ContentType "application/x-www-form-urlencoded" -WebSession $OktaSSO -SkipHttpErrorCheck
    Write-Host "Push notification sent to: phone1"
    $duo_push_txid = $duo_push.response.txid

    $duo_approved = $false
    while(-not $duo_approved) {
        $duo_push = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)/frame/status" -WebSession $OktaSSO -Body @{
            sid = $duo_prompt_sid
            txid = $duo_push_txid
        }

        switch ($duo_push.response.status_code) {
            pushed {
                $duo_approved = $false
                Write-Verbose $duo_push.response.status
            }
            allow { 
                $duo_cookie = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)$($duo_push.response.result_url)" -WebSession $OktaSSO -Body @{ 
                    sid = $duo_prompt_sid
                }
                $duo_approved = $true
            }
            Default {
                $duo_approved = $false
                Write-Error "Failed to push 2fa: $($duo_push.response.status)"
            }
        }
    }

    $okta_callback = Invoke-RestMethod -Method "POST" -Uri $duo._links.complete.href -Body @{
        "id" = $okta_authn._embedded.factors[0].id
        "stateToken" = $okta_authn.stateToken
        "sig_response" = "$($duo_cookie.response.cookie):$duo_app"
    } -ContentType "application/x-www-form-urlencoded" -WebSession $OktaSSO

    # If($okta_callback) {
        $res = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body (@{
            "stateToken" = $StateToken
        } | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO

        $session_token = $res.sessionToken
    # }

    Return $session_token
}


Function Send-OktaFactorProviderOkta {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [String]
        $VerifyUrl,

        [Parameter(Mandatory=$true)]
        [String]
        $StateToken,

        [Parameter()]
        [String]
        $Passcode
    )

    # Verify request body, add the passcode if it's provided
    # create it outside of the loop since it is only set once
    $req_body = @{ "stateToken" = $StateToken }
    If($Passcode) {
        $req_body["passCode"] = $Passcode
    }

    $status = ""
    while($status -ne "SUCCESS") {
        switch($status) {
            "CANCELLED" {
                Write-Warning "Push notification cancelled"
                Return
            }
            "ERROR" {
                Write-Warning "Push notification error"
                Return
            }
            "FAILED" {
                Write-Warning "Push notification failed"
                Return
            }
            "REJECTED" {
                Write-Warning "Push notification rejected"
                Return
            }
            "TIMEOUT" {
                Write-Warning "Push notification timed out"
                Return
            }
            "TIME_WINDOW_EXCEEDED" {
                Write-Warning "Push notification time window exceeded"
                Return
            }
        }
        
        $okta_verify = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body ($req_body | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO

        $status = $okta_verify.status
    }

    $session_token = $okta_verify.sessionToken

    Return $session_token
}


Function Set-OktaAuthentication {
    [CmdletBinding(DefaultParameterSetName = "SSWS")]
    param (
        # Parameter help description
        [Parameter(Mandatory)]
        [ValidateSet("SSWS", "PrivateKey", "Credential")]
        [String]
        $AuthorizationMode,

        [Parameter(Mandatory)]
        [Microsoft.PowerShell.Commands.WebRequestSession]
        $Session,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $Domain,

        # Parameter help description
        [Parameter(ParameterSetName="PrivateKey", Mandatory)]
        [Int32]
        $ExpiresIn,

        # Parameter help description
        [Parameter(ParameterSetName="Credentials", Mandatory)]
        [System.DateTime]
        $ExpiresAt,

        [Parameter(ParameterSetName="Credentials", Mandatory)]
        [String]
        $Username
    )

    $Script:OktaAuthorizationMode = $AuthorizationMode
    Write-Verbose "Setting OktaAuthorizationMode to $Script:OktaAuthorizationMode"
    $Script:OktaSSO = $Session
    Write-Verbose "Setting OktaSSO web session"
    $Script:OktaDomain = $Domain
    Write-Verbose "Setting OktaDomain to $Script:OktaDomain"
    
    $Uri = [System.Uri]$Domain
    $domainParts = $Uri.Host.Split('.')
    $Script:OktaOrg = $domainParts[0]
    Write-Verbose "Setting OktaOrg to $Script:OktaOrg"

    $Script:OktaAdminDomain = Get-OktaAdminDomain -Domain $Domain
    Write-Verbose "Setting OktaAdminDomain to $Script:OktaAdminDomain"

    If($PSCmdlet.ParameterSetName -eq "Credentials") {
        $Script:OktaSSOExpirationUTC = $ExpiresAt
        Write-Verbose "Setting OktaSSOExpirationUTC to $Script:OktaSSOExpirationUTC"

        $Script:OktaUsername = $Username
        Write-Verbose "Setting OktaUsername to $Script:OktaUsername"
    }

    If($PSCmdlet.ParameterSetName -eq "PrivateKey") {
        $now = (Get-Date).AddSeconds($ExpiresIn).ToUniversalTime()
        $Script:OktaSSOExpirationUTC = $now
        Write-Verbose "Setting OktaSSOExpirationUTC to $Script:OktaSSOExpirationUTC"
    }
}


Function Set-OktaConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [String]
        $Path,

        [Parameter(Mandatory=$true)]
        $Config
    )

    If(Test-Path $Path) {
        Write-Warning "Okta config file already exists at $Path, would you like to overwrite it?" -WarningAction Inquire
    } else {
        $null = New-Item -ItemType File -Path $Path -Force
    }

    
    @{
        okta = @{
            client = $saveConfig
        }
    } | ConvertTo-Yaml -OutFile $Path -Force
}


Function Test-OktaAuthentication {
    [CmdletBinding()]
    param ()

    If(-not $Script:OktaSSO) {
        Write-Verbose "No Okta SSO session found"
        Return $False
    }

    If($Script:OktaAuthorizationMode -eq "SSWS") {
        Write-Verbose "API key does not expire"
        Return $True
    }

    $NowUTC = (Get-Date).ToUniversalTime()
    If($Script:OktaSSOExpirationUTC -gt $NowUTC) {
        Write-Verbose "Time left until expires: $(($Script:OktaSSOExpirationUTC - $NowUTC).tostring())"

        Return $True
    } else {
        Write-Verbose "Token expired: $Script:OktaSSOExpirationUTC UTC"

        Return $False
    }
}

Function Update-OktaAuthentication {
    Switch($Script:OktaAuthorizationMode) {
        "PrivateKey" {
            Connect-Okta
        }

        "Credential" {
            try {
                $session = Invoke-RestMethod -Method "POST" -Uri "$OktaDomain/api/v1/sessions/me/lifecycle/refresh" -WebSession $Script:OktaSSO -ContentType "application/json" -ErrorAction SilentlyContinue
            } catch {
                Write-Verbose "cached expiration expired, trying to renew session"
            }
            
            If($session.status -eq "ACTIVE") {
                $Script:OktaSSOExpirationUTC = $session.expiresAt
                Write-Verbose "session renewed, updated expiration to $Script:OktaSSOExpirationUTC UTC"
                Break
            }

            Write-Host "Okta session expired ($Script:OktaSSOExpirationUTC UTC)"
            # Remove-Variable OktaSSO,OktaSSOExpirationUTC -Scope Script
            Connect-Okta -OrgUrl $Script:OktaDomain -Credential $Script:OktaUsername
        }

        "SSWS" {
            Write-Verbose "API key does not expire, no refresh needed"
        }

        Default {
            Connect-Okta
        }
    }
}


Function Write-OktaAdminAPIWarning {
    If(-not $Script:SuppressAdminAPIWarning) {
        Write-Warning "This makes unoffical admin API calls to Okta, see README for more info. To stop these warnings, use `Set-OktaAdminAPIWarning -Disable`." -WarningAction Inquire
    }
}

## PUBLIC MODULE FUNCTIONS AND DATA ##

Function Add-OktaAppMember {
    [CmdletBinding()]
    param (
        [Parameter()]
        [OktaUser[]]
        $Identity,

        [Parameter()]
        [String]
        $App
    )
    
    begin {
        $OktaApp = Get-OktaApp -App $App -ErrorAction Stop
    }

    process {
        foreach($user in $Identity) {
            Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/apps/$($OktaApp.Id)/users" -Body @{
                id = $user.Id
                scope = "USER"
            }
        }
    }
}


Function Add-OktaGroupMember {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]
        $Group,

        [Parameter()]
        [OktaUser[]]
        $Members
    )

    $OktaGroup = Get-OktaGroup -Name $Group -ErrorAction Stop
    $GroupId = $OktaGroup.id

    Foreach($member in $Members) {
        If($member.id) {
            Write-Verbose "Adding $($member.login) to $($OktaGroup.Name)"
            Invoke-OktaRequest -Method "PUT" -Endpoint "api/v1/groups/$GroupId/users/$($member.id)"
        }
    }
}


# process to determine what auth method to use, recommended to only use one
# method and not save both api and credential to config file. so far not able
# to export a websession to file. I'm assuming this is because the [System.Net.CookieContainer]
# is only a pointer to the actual cookies stored somewhere else
#
# 1. check config for auth method in order of -session-, apikey, credentials
# 2. -if session is:-
# 2a. -valid, use-
# 2b. -expired, try to renew session with credentials-
# 3. if api key set, use api
# 4. if username is set, prompt for credentials
# 4a. -save session to config-
# 5. if -save param is set, overwrite config with new

Function Connect-Okta {
    [CmdletBinding(DefaultParameterSetName='SavedConfig')]
    param (
        # Okta organization url beginning with https://
        [Parameter(ParameterSetName = 'CredentialAuth', Mandatory=$True)]
        [Parameter(ParameterSetName = 'APIAuth', Mandatory=$True)]
        [Alias("OktaDomain")]
        [ValidatePattern("^https://", ErrorMessage="URL must begin with https://")]
        [String]
        $OrgUrl,

        # Okta admin credentials
        [Parameter(ParameterSetName = 'CredentialAuth', Mandatory=$True)]
        [PSCredential]
        $Credential,

        # Okta API key
        [Parameter(ParameterSetName = 'APIAuth', Mandatory=$True)]
        [String]
        $API,

        # Save authentication to .yaml config
        [Parameter(ParameterSetName = 'CredentialAuth')]
        [Parameter(ParameterSetName = 'CredentialAuthSave')]
        [Parameter(ParameterSetName = 'APIAuth')]
        [Parameter(ParameterSetName = 'APIAuthSave')]
        [Switch]
        $Save = $false,

        # Path to save .yaml config file, defaults to the user's home directory ~/.okta/okta.yaml
        [Parameter(ParameterSetName = 'CredentialAuthSave')]
        [Parameter(ParameterSetName = 'APIAuthSave')]
        [String]
        $SavePath = (Join-Path $($env:HOME ?? $env:USERPROFILE) ".okta\okta.yaml"),

        # Path to .yaml config file
        [Parameter(ParameterSetName = 'SavedConfig')]
        [ArgumentCompleter({ OktaConfigPathArgumentCompleter @args })]
        [String]
        $Config
    )

    Switch($PSCmdlet.ParameterSetName) {
        "SavedConfig" {
            $oktaYAMLPath = Get-OktaConfig -Path $Config
            If(-not [String]::IsNullOrEmpty($oktaYAMLPath)) {
                Write-Verbose "Connecting to Okta using config file: $oktaYAMLPath"

                $yaml = Get-Content $oktaYAMLPath | ConvertFrom-Yaml
                $yamlConfig = $yaml.okta.client

                If($yamlConfig.authorizationMode -eq "PrivateKey") {
                    $OrgUrl = $yamlConfig.orgUrl
                    $ClientId = $yamlConfig.clientId
                    $Scopes = $yamlConfig.scopes
                    $PrivateKey = $yamlConfig.privateKey
                    $AuthFlow = "PrivateKey"
        
                } ElseIf(($yamlConfig.authorizationMode -eq "SSWS") -or (-not [String]::IsNullOrEmpty($yamlConfig.token))) {
                    $OrgUrl = $yamlConfig.orgUrl
                    $API = $yamlConfig.Token
                    $AuthFlow = "SSWS"
        
                } ElseIf(-not [String]::IsNullOrEmpty($yamlConfig.username)) {
                    $OrgUrl = $yamlConfig.orgUrl
                    $Credential = Get-Credential $yamlConfig.username
                    $AuthFlow = "Credential"
                    Write-Verbose $OrgUrl
                } Else {
                    Write-Error "Unknown authorization mode: $($yamlConfig.authorizationMode)"
                    Write-Error "Defaulting to credential auth method"
                    $OrgUrl = Read-Host -Prompt "Enter your Okta organization url (with https://)"
                    $AuthFlow = "Credential"
                }
            } Else {
                $OrgUrl = Read-Host -Prompt "Enter your Okta organization url (with https://)"
                $AuthFlow = "Credential"
            }
        }

        "APIAuth" { $AuthFlow = "SSWS" }
        "CredentialAuth" { $AuthFlow = "Credential" }
    }

    Clear-OktaAuthentication
    
    Switch($AuthFlow) {
        "SSWS" {
            Write-Verbose "Using API auth method"
            Connect-OktaAPI -OktaDomain $OrgUrl -API $API -ErrorAction Stop

            $saveConfig = @{
                orgUrl = $OrgUrl
                authorizationMode = "SSWS"
                token = $API
            }
        }

        "PrivateKey" {
            Write-Verbose "Using OAuth 2.0 private key auth method"
            Connect-OktaPrivateKey -OktaDomain $OrgUrl -ClientId $ClientId -Scopes $Scopes -PrivateKey $PrivateKey -ErrorAction Stop
        }

        "Credential" {
            Write-Verbose "Using Credential auth method"
            Connect-OktaCredential -OktaDomain $OrgUrl -Credential $Credential -ErrorAction Stop

            $saveConfig = @{
                orgUrl = $OrgUrl
                username = $Credential.UserName
            }
        }

        Default {
            Write-Error "Unknown authentication flow: $AuthFlow"
        }
    }

    Write-Host "Connected to $OrgUrl"

    If($Save) {
        Set-OktaConfig -Path $SavePath -Config $saveConfig
    }
}


Function Disconnect-Okta {
    [CmdletBinding()]
    param ()

    Write-Verbose "Disconnecting Okta session"

    # do we need a valid session to delete?
    If($Script:OktaDomain) {
        Invoke-RestMethod -Method "DELETE" -Uri "$Script:OktaDomain/api/v1/sessions/me" -WebSession $Script:OktaSSO -ContentType "application/json" -ErrorAction SilentlyContinue
    }

    Clear-OktaAuthentication
}

Function Enable-OktaGroupRule {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]
        $Rule
    )

    $activatedRule = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/groups/rules/${ruleId}/lifecycle/activate"

    Return $activatedRule
}

Function Get-OktaApp {
    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName="ByApp")]
        [String]
        $App,

        [Parameter(ParameterSetName="ByIdentity")]
        [OktaUser]
        $Identity,

        [Parameter(ParameterSetName="ByGroup")]
        [String]
        $Group
    )
    
    If($PSCmdlet.ParameterSetName -like "ByApp") {
        # attempt Id search, fail attempt Name search
        If($App -like "0oa*") {
            $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps/$App" -ErrorAction SilentlyContinue
            Write-Verbose "Tried Id search, nothing found."
        }

        # attempt Name search
        If($null -eq $response) {
            $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps" -Query @{
                q = $App
            }
        }
    } ElseIf($PSCmdlet.ParameterSetName -like "ByIdentity") {
        Write-Error "Not Implemented"
        # TODO
    } ElseIf($PSCmdlet.ParameterSetName -like "ByGroup") {
        Write-Error "Not-Implemented"
        # TODO
    }

    If($response) {
        $OktaApp = ConvertTo-OktaApp -InputObject $response
        Return $OktaApp
    } else {
        Throw "No Okta app found."
    }
}

Function Get-OktaAppMember {
    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName="ByAppId", Mandatory)]
        [String]
        $App,

        [Parameter(ParameterSetName="ByOktaApp", ValueFromPipeline, Mandatory)]
        [PSTypeName("Okta.App")]
        $InputObject
    )

    If($PSCmdlet.ParameterSetName -eq "ByAppId") {
        $OktaApp = Get-OktaApp -App $App
    }

    $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps/$($OktaApp.id)/users" -Query @{ limit = 500 }

    $OktaUser = Convertto-OktaAppUser -InputObject $response
    Return $OktaUser
}

Function Get-OktaCount {
    # unofficial api
    Write-OktaAdminAPIWarning

    $return = [ordered]@{
        "users" = [ordered]@{
            "total" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=EVERYONE").count
            "active" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=ACTIVATED").count
            "password_reset" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=PASSWORD_RESET").count
            "locked_out" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=LOCKED_OUT").count
            "suspended" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=SUSPENDED").count
            "deactivated" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=DEACTIVATED").count
        }
    }

    $return
}

Function Get-OktaFactor {
    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName="ByIdentity", Position=0, ValueFromPipeline, Mandatory)]
        [OktaUser[]]
        $Identity
    )

    Begin {
        $i = 0
    }

    Process {
        Foreach($u in $Identity) {
            Write-Verbose "Looking up factor for $($u.login)"

            # count is not available when pipelining so we cannot show a progress bar, alternative to
            # show a count of processed objects
            $ObjectPct = $Identity.Count -gt 1 ? $i/($Identity.Count)*100 : -1

            Write-Progress -Activity "Getting enrolled factors" -Status $i -PercentComplete $ObjectPct
            Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/users/$($u.Id)/factors"
            $i++
        }
    } 

    End {

    }
}


Function Get-OktaGroup {
    [CmdletBinding(DefaultParameterSetName='AllGroups')]
    param (
        # Specifies an ID or name of an Okta group to retrieve. If searching by ID, it must be an exact match and only
        # one result will be returned. If searching by name, it will conduct a starts with query, and if multiple matches
        # all groups will be returned.
        [Parameter(ParameterSetName="GetGroup", Mandatory=$true, Position=0)]
        [String]
        $Name,

        # Specifies a specific type of group to search for. If no Type is specified, the default is to search for all
        # types. Does not apply when searching by ID.
        [Parameter()]
        [ValidateSet("OKTA_GROUP","APP_GROUP","BUILT_IN")]
        [String]
        $Type, 

        # Specifies the number of Group results in a page
        [Parameter(ParameterSetName="AllGroups")]
        [Parameter(ParameterSetName="GetGroup")]
        [Int]
        $Limit = 10000
    )

    $query = @{}
    $query["limit"] = $Limit

    If($Type) {
        $query["filter"] = "type eq `"$Type`""
    } 
    
    switch ($PsCmdlet.ParameterSetName) {
        "AllGroups" {
            $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups" -Query $query -ErrorAction SilentlyContinue
        }
        
        "GetGroup" {
            # try matching group id
            $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups/$Name" -ErrorAction SilentlyContinue
            If(-not $group) {
                # try matching group name
                $query["q"] = $Name
                $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups" -Query $query -ErrorAction SilentlyContinue
            }
        }
    }

    If(-not $group) {
        Throw "Group not found: $Name"
    }

    $GroupObject = Foreach($g in $group) {
        ConvertTo-OktaGroup -InputObject $g
    }

    Return $GroupObject
}


Function Get-OktaGroupMember {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [OktaGroup]
        $Group
    )

    $members = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/groups/$($Group.Id)/users"

    $OktaUsers = Foreach($m in $members) {
        ConvertTo-OktaUser -InputObject $m
    }
   
    Return $OktaUsers
}


Function Get-OktaLogs {
    <#
    .SYNOPSIS
        Fetches a list of ordered log events from your Okta organization's system log
    .DESCRIPTION
        The Okta System Log records system events that are related to your organization in order to provide an audit
        trail that can be used to understand platform activity and to diagnose problems.
    .NOTES
        Information or caveats about the function e.g. 'This function is not supported in Linux'
    .LINK
        https://developer.okta.com/docs/reference/api/system-log
    .EXAMPLE
        Get-OktaLogs -Limit 15
        Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines
    .EXAMPLE
        $logs = Get-OktaLogs -NoPrompt
        Use -NoPrompt when assigning the output of Get-OktaLogs to a variable
    .EXAMPLE
        Get-OktaUser anna.unstoppable | Get-OktaLogs
        Get logs for a specific user
    #>


    [CmdletBinding(DefaultParameterSetName="ByFilter")]
    param (
        # Filters the lower time bound of the log events published property for bounded queries or persistence time for polling queries
        [Parameter(ParameterSetName="ByFilter")]
        [Parameter(ParameterSetName="ByUser")]
        [datetime]
        $Since = (Get-Date).AddMinutes(-15),

        # Filters the upper time bound of the log events published property for bounded queries or persistence time for polling queries
        [Parameter(ParameterSetName="ByFilter")]
        [Parameter(ParameterSetName="ByUser")]
        [datetime]
        $Until = (Get-Date),

        # Filter Expression that filters the results
        [Parameter(ParameterSetName="ByFilter")]
        [String]
        $Filter,

        # Filters the log events results by one or more exact keywords
        [Parameter(ParameterSetName="ByFilter")]
        [String]
        $Keyword,

        # The order of the returned events that are sorted by published
        [Parameter(ParameterSetName="ByFilter")]
        [Parameter(ParameterSetName="ByUser")]
        [ValidateSet("ASCENDING", "DESCENDING")]
        [String]
        $Sort = "ASCENDING",

        # Sets the number of results that are returned in the response
        [Parameter(ParameterSetName="ByFilter")]
        [Parameter(ParameterSetName="ByUser")]
        [int]
        $Limit = 1000,

        # Not supported, Disable color output for consoles without ANSI support
        # [Parameter()]
        # [switch]
        # $NoColor,

        # Get logs for a specific user, cannot be combined with the Filter parameter
        [Parameter(ParameterSetName="ByUser", ValueFromPipeline)]
        [PSTypeName("OktaUser")]
        $OktaUser
    )

    Switch($PSCmdlet.ParameterSetName) {
        "ByUser" {
            $Filter = "actor.id eq ""$($OktaUser.id)"" or target.id eq ""$($OktaUser.id)"""
        }
    }
    
    $query = @{
        since = $Since.ToString("o")
        until = $Until.ToString("o")
        filter = $Filter
        q = $Keyword
        sortOrder = $Sort
        limit = $Limit
    }

    $response = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/logs" -Query $query
    Return ConvertTo-OktaLogEvent -LogEvent $response
}


Function Get-OktaUser {
    [CmdletBinding(DefaultParameterSetName='AllUsers')]
    [OutputType("OktaUser")]
    param (
        [Parameter(ParameterSetName = 'GetUser', Mandatory, Position=0, HelpMessage="Okta user ID, login, or short-login")]
        [String[]]
        $Identity,

        [Parameter(ParameterSetName = 'ListUserFilter', Mandatory, HelpMessage="Okta API filter criteria. https://developer.okta.com/docs/reference/api/users/#list-users-with-a-filter")]
        [String]
        $Filter,

        [Parameter(ParameterSetName = 'ListUserSearch', Mandatory, HelpMessage="Okta API search criteria. https://developer.okta.com/docs/reference/api/users/#list-users-with-search")]
        [String]
        $Search,

        [Parameter(ParameterSetName = 'ListUserFind', Mandatory, HelpMessage="Okta API search criteria. https://developer.okta.com/docs/reference/api/users/#find-users")]
        [String]
        $Find,

        [Parameter(ParameterSetName = 'GetUser')]
        [Parameter(ParameterSetName = 'ListUserFilter')]
        [Parameter(ParameterSetName = 'ListUserSearch')]
        [String]
        $Properties,

        [Parameter(ParameterSetName = 'GetUser')]
        [Parameter(ParameterSetName = 'ListUserFilter')]
        [Parameter(ParameterSetName = 'ListUserSearch')]
        [Int]
        $Limit = 10,

        # By default all users return a list of all users that do not have a status of DEPROVISIONED, this switch also returns DEPROVISIONED users
        [Parameter(ParameterSetName = 'AllUsers')]
        [Switch]
        $IncludeDeprovisioned
    )

    $request_args = @{}
    
    # by default Okta sends over all properties, so selecting only a subset of properties does not
    # have any performance gains of network bandwidth. can implement it in the future for memory
    # saving
    If($Properties -eq "*") {
        $groups_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$Identity/groups"
    } else {
        # less expensive request?
        # https://developer.okta.com/docs/reference/api/users/#content-type-header-fields
        $request_args['Headers'] = @{ "Content-Type" = "application/json; okta-response=omitCredentials,omitCredentialsLinks,omitTransitioningToStatus" }
    }

    switch ($PsCmdlet.ParameterSetName) {
        "GetUser" { 
            $user_query = Foreach($i in $Identity) {
                $query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$i" @request_args -ErrorAction Stop

                If($null -eq $query) {
                    Write-Error "Could not find user $i"
                    Continue
                }

                $groups_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$i/groups" -ErrorAction SilentlyContinue
                If($groups_query) {
                    $query | Add-Member -MemberType NoteProperty -Name "_groups" -Value $groups_query
                }

                $query
            }
        }

        "ListUserSearch" {
            $url_builder = @{}
            $url_builder['limit'] = $Limit
            $url_builder['search'] = $Search.Replace("'","`"") #okta query has to be in double-quotes
            $querystring = New-HttpQueryString -QueryParameter $url_builder
    
            $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop
        }

        "ListUserFilter" {
            $url_builder = @{}
            $url_builder['limit'] = $Limit
            $url_builder['filter'] = $Filter
            $querystring = New-HttpQueryString -QueryParameter $url_builder
    
            $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop
        }

        "ListUserFind" {
            $url_builder = @{}
            $url_builder['limit'] = $Limit
            $url_builder['q'] = $Find
            $querystring = New-HttpQueryString -QueryParameter $url_builder
    
            $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop
        }

        "AllUsers" {
            $url_builder = @{}
            $url_builder['limit'] = 200
            $querystring = New-HttpQueryString -QueryParameter $url_builder
            
            $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop

            If($IncludeDeprovisioned) {
                $url_builder['filter'] = 'status eq "DEPROVISIONED"'
                $querystring = New-HttpQueryString -QueryParameter $url_builder

                $user_query += Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop
            }
        }
    }
    
    # Return $user_query
 
    If($null -ne $user_query) {
        $OktaUser = ConvertTo-OktaUser -InputObject $user_query
        Return $OktaUser
    }

    Return
}


Function New-OktaGroup {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]
        $Name,

        [Parameter()]
        [String]
        $Description
    )

    $reqBody = @{
        "profile" = @{
            "name" = $Name
        }
    }

    If($Description) {
        $reqBody['profile']['description'] = $Description
    }

    $response = Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/groups" -Body $reqBody

    $GroupObject = ConvertTo-OktaGroup -InputObject $response
    Return $GroupObject
}

Function New-OktaGroupRule {
    [CmdletBinding()]
    param (
        [ValidateLength(1,50)]
        [Parameter(Mandatory)]
        [string]
        $Name,

        # Parameter help description
        [Parameter(Mandatory)]
        [string]
        $Expression,

        # Parameter help description
        [Parameter(Mandatory)]
        [String[]]
        $Group,

        # Parameter help description
        [Parameter()]
        [Switch]
        $Activate
    )

    $newRule = Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/groups/rules" -Body @{
        "type" = "group_rule"
        "name" = $Name
        "conditions" = @{
            # "people": {
            # "users": {
            # "exclude": [
            # "00u22w79JPMEeeuLr0g4"
            # ]
            # },
            # "groups": {
            # "exclude": []
            # }
            # },
            "expression" = @{
                "value" = $Expression.Replace("'","`"") #expression has to be in double-quotes
                "type" = "urn:okta:expression:1.0"
            }
        }
        "actions" = @{
            "assignUserToGroups" = @{
                "groupIds" = $Group
            }
        }
    }

    If($Activate) {
        $ruleId = $newRule.Id
        $activatedRule = Enable-OktaGroupRule -Rule $ruleId
        # $activatedRule = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/groups/rules/${ruleId}/lifecycle/activate"

        Return $activatedRule
    } else {
        Return $newRule
    }
}


Function New-OktaUser {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]
        $FirstName,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $LastName,

        # Parameter help description
        [Parameter(Mandatory)]
        [String]
        $Email,

        # Parameter help description
        [Parameter()]
        [Switch]
        $Activate
    )

    If($Activate) {
        $url_builder = @{}
        $url_builder['activate'] = $true
        $querystring = New-HttpQueryString -QueryParameter $url_builder
    }

    Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/users?$querystring" -Body @{
        "profile" = @{
            "firstName" = $FirstName
            "lastName"  = $LastName
            "email"     = $Email
            "login"     = $Email
        }
    } -Verbose -Debug
}

Function Remove-OktaGroupMember {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]
        $Group,
        
        [Parameter()]
        [String[]]
        $Members
    )

    $OktaGroup = Get-OktaGroup -Identity $Group -ErrorAction Stop
    $GroupId = $OktaGroup.id

    Foreach($memberId in $Members) {
        # Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/users/$member"
        Write-Verbose "Removing $memberId from $($OktaGroup.Name)"
        Invoke-OktaRequest -Method "DELETE" -Endpoint "/api/v1/groups/$GroupId/users/$memberId"
    }
    
}

Function Reset-OktaUserPassword {
    [CmdletBinding()]
    param (
        [Parameter(Position=0, ValueFromPipeline, Mandatory)]
        [OktaUser]
        $Identity,

        [Parameter()]
        [Switch]
        $AsPlainText
    )

    $response = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/users/$($Identity.id)/lifecycle/expire_password" -Query @{"tempPassword" = "true"}
    $tempPassword = $response.tempPassword

    If($AsPlainText) {
        Return $tempPassword
    } else {
        Return ($tempPassword | ConvertTo-SecureString -AsPlainText)
    }
}


Function Set-OktaAdminAPIWarning {
    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName="Enable", Mandatory)]
        [Switch]
        $Enable,

        [Parameter(ParameterSetName="Disable", Mandatory)]
        [Switch]
        $Disable
    )

    If($Enable) {
        $Script:SuppressAdminAPIWarning = $False
    }

    If($Disable) {
        $Script:SuppressAdminAPIWarning = $True
    }
}

Function Set-OktaUser {
    [CmdletBinding(DefaultParameterSetName="SingleProfileAttribute")]
    param(
        [Parameter(Mandatory=$true)]
        [OktaUser]$User,
        
        # Specify a single profile attribute name to set on the Okta user.
        [Parameter(ParameterSetName="SingleProfileAttribute", Mandatory=$true)]
        [String]
        $ProfileAttributeName,

        # Specify a single profile attribute value to set on the Okta user.
        [Parameter(ParameterSetName="SingleProfileAttribute", Mandatory=$true)]
        [String]
        $ProfileAttributeValue,

        # Specify a hashtable of profile attributes name value pairs to set on the Okta user.
        [Parameter(ParameterSetName="HashtableProfileAttribute", Mandatory=$true)]
        [Hashtable]
        $ProfileAttribute,

        # Indicates if unspecified properties should be deleted from the Okta user's profile.
        [Parameter()]
        [Switch]
        $Force
    )

    $Method = "POST"
    If($Force) {
        $Method = "PUT"
    }

    If($PSCmdlet.ParameterSetName -eq "SingleProfileAttribute") {
        $ProfileAttribute = @{
            $ProfileAttributeName = $ProfileAttributeValue
        }
    }

    $Body = @{
        "profile" = $ProfileAttribute
    }

    Invoke-OktaRequest -Method $Method -Endpoint "api/v1/users/$($User.id)" -Body $Body
}

Function Set-OktaUserPassword {
    [CmdletBinding(DefaultParameterSetName="PasswordChange")]
    param (
        [Parameter(ParameterSetName="PasswordChange", Position=0, ValueFromPipeline, Mandatory)]
        [Parameter(ParameterSetName="PasswordReset", Position=0, ValueFromPipeline, Mandatory)]
        [OktaUser]
        $Identity,

        [Parameter(ParameterSetName="PasswordChange", Mandatory)]
        [SecureString]
        $OldPassword,

        [Parameter(ParameterSetName="PasswordChange", Mandatory)]
        [SecureString]
        $NewPassword,

        [Parameter(ParameterSetName="PasswordReset", Mandatory)]
        [Switch]
        $Reset,

        [Parameter(ParameterSetName="PasswordReset")]
        [Switch]
        $AsPlainText
    )

    switch($PSCmdlet.ParameterSetName) {
        "PasswordChange" { 
            $null = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/users/$($Identity.id)/credentials/change_password" -Body @{
                "oldPassword" = @{ "value" = (ConvertFrom-SecureString $OldPassword -AsPlainText) }
                "newPassword" = @{ "value" = (ConvertFrom-SecureString $NewPassword -AsPlainText) }
            }
    
            Return 
        }

        "PasswordReset" {
            If($AsPlainText) {
                $tempPassword = Reset-OktaUserPassword -Identity $Identity -AsPlainText
            } else {
                $tempPassword = Reset-OktaUserPassword -Identity $Identity
            }
    
            Return $tempPassword
        }

        Default {
            throw [System.ArgumentOutOfRangeException] "Unknown parameter set: $($PSCmdlet.ParameterSetName)"
        }
    }
}