Providers/Okta/Private/Invoke-OktaRequest.ps1

function Invoke-OktaRequest {
    <#
    .SYNOPSIS
        Single entry point for every Okta management API call this module makes

    .DESCRIPTION
        Wraps Invoke-WebRequest with the things the Okta API needs and PowerShell does not do
        by default:

        - UTF-8 on both sides. The seed data carries accented names on purpose, and Windows
          PowerShell will otherwise send them as question marks and decode responses as
          Latin-1. Request bodies are encoded to UTF-8 bytes and responses are decoded from
          the raw stream rather than trusting the charset negotiation.
        - Cursor pagination. Okta pages with a Link header, not a page number, so -Paginate
          follows rel="next" until a page comes back empty.
        - 429 handling. The Integrator Free Plan has a low per-minute ceiling and seeding an
          environment will hit it. Retries honour X-Rate-Limit-Reset when Okta sends it and
          fall back to exponential backoff when it does not.
        - Readable errors. Okta puts the useful part in errorCauses inside the response body,
          which PowerShell discards by default on both editions.

        Invoke-WebRequest is used rather than Invoke-RestMethod because the Link and
        X-Rate-Limit headers are only reachable through the response object, and Windows
        PowerShell's Invoke-RestMethod does not expose them at all.

    .PARAMETER Method
        HTTP method

    .PARAMETER Path
        Path below the org URL, beginning with a slash, for example /api/v1/users

    .PARAMETER Body
        Request body. A string is sent as-is, anything else is serialised to JSON.

    .PARAMETER Query
        Query string parameters. Values are URL-encoded.

    .PARAMETER Paginate
        Follow the Link rel="next" header and return every item across all pages

    .PARAMETER ContentType
        Overrides the request content type. Only the token endpoint needs this, because it
        takes form encoding rather than JSON.

    .PARAMETER Connection
        Connection hashtable to use instead of the module's active connection. Used by
        Connect-OktaEnvironment to validate credentials before storing them.

    .PARAMETER MaxRetry
        Attempts made against a rate limit or a server error before giving up

    .OUTPUTS
        The deserialised response body, or $null for a 204

    .EXAMPLE
        Invoke-OktaRequest -Method GET -Path '/api/v1/users' -Paginate

    .EXAMPLE
        Invoke-OktaRequest -Method POST -Path '/api/v1/groups' -Body @{
            profile = @{ name = 'OKTALAB-Dept-Engineering'; description = 'Engineering' }
        }

    .NOTES
        Author: Jeffrey Stuhr
        Version: 1.0.0
        Last Updated: 2026-08-07
    #>


    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory = $true)]
        [ValidatePattern('^/')]
        [string]$Path,

        [Parameter()]
        [object]$Body,

        [Parameter()]
        [hashtable]$Query,

        [Parameter()]
        [switch]$Paginate,

        [Parameter()]
        [string]$ContentType = 'application/json; charset=UTF-8',

        [Parameter()]
        [hashtable]$Connection,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$MaxRetry = 5
    )

    if (-not $Connection) { $Connection = Get-OktaConnection }

    # Windows PowerShell defaults to TLS 1.0, which Okta refuses outright. Only ever add to
    # the enabled set: clearing it would change behaviour for everything else in the session.
    if ($PSVersionTable.PSEdition -eq 'Desktop') {
        $tls12 = [System.Net.SecurityProtocolType]::Tls12
        if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) {
            [System.Net.ServicePointManager]::SecurityProtocol =
                [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12
        }
    }

    $uri = '{0}{1}' -f $Connection.OrgUrl.TrimEnd('/'), $Path
    if ($Query -and $Query.Count -gt 0) {
        $pairs = foreach ($key in $Query.Keys) {
            if ($null -eq $Query[$key] -or '' -eq $Query[$key]) { continue }
            '{0}={1}' -f [uri]::EscapeDataString($key), [uri]::EscapeDataString([string]$Query[$key])
        }
        if ($pairs) { $uri = '{0}?{1}' -f $uri, ($pairs -join '&') }
    }

    # The token endpoint is the one Okta endpoint that must not carry an Authorization
    # header: the client assertion in the body is the credential, and sending both makes Okta
    # reject the request. Callers signal that by passing a connection with no header.
    $headers = @{ 'Accept' = 'application/json' }
    if (-not [string]::IsNullOrWhiteSpace($Connection.AuthorizationHeader)) {
        $headers['Authorization'] = $Connection.AuthorizationHeader
    }

    $bodyBytes = $null
    if ($null -ne $Body) {
        $bodyText = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 -Compress }
        $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyText)
    }

    # Invoke-WebRequest paints a progress bar per call on Windows PowerShell, which for a few
    # hundred calls costs more wall clock than the calls do.
    $previousProgress = $ProgressPreference
    $ProgressPreference = 'SilentlyContinue'

    $collected = [System.Collections.Generic.List[object]]::new()
    $result = $null

    try {
        $nextUri = $uri

        while ($nextUri) {
            $attempt = 0
            $response = $null

            while ($true) {
                $attempt++
                try {
                    $requestArgs = @{
                        Uri             = $nextUri
                        Method          = $Method
                        Headers         = $headers
                        UseBasicParsing = $true
                        ErrorAction     = 'Stop'
                    }
                    if ($null -ne $bodyBytes) {
                        $requestArgs.Body        = $bodyBytes
                        $requestArgs.ContentType = $ContentType
                    }

                    Write-Verbose "Okta $Method $nextUri (attempt $attempt)"
                    $response = Invoke-WebRequest @requestArgs
                    break
                }
                catch {
                    $statusCode = 0
                    if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
                        try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = 0 }
                    }

                    $retryable = ($statusCode -eq 429 -or $statusCode -ge 500)
                    if (-not $retryable -or $attempt -ge $MaxRetry) {
                        # The message is built into its own variable rather than inline. A
                        # concatenation written directly inside New-Object's argument list
                        # binds as ONE array argument, so "message" + detail, $exception is
                        # joined into a single string and matched against Exception(string).
                        # The result is a message with the inner exception's whole stack trace
                        # stringified into it, and no InnerException set at all.
                        $statusText = if ($statusCode) { " with HTTP $statusCode" } else { '' }
                        $message = "Okta $Method $Path failed${statusText}: " +
                            (Get-OktaErrorDetail -ErrorRecord $_)

                        throw (New-Object System.Exception($message, $_.Exception))
                    }

                    # Okta answers a 429 with the epoch second the window resets. Prefer it
                    # over guessing, but cap it: a clock skew between here and Okta would
                    # otherwise park the run for an arbitrarily long time.
                    $waitSeconds = [Math]::Min(60, [Math]::Pow(2, $attempt))
                    try {
                        $resetHeader = $_.Exception.Response.Headers['X-Rate-Limit-Reset']
                        if ($resetHeader) {
                            $resetEpoch = [long](@($resetHeader)[0])
                            $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
                            $fromHeader = $resetEpoch - $now + 1
                            if ($fromHeader -gt 0) { $waitSeconds = [Math]::Min(60, $fromHeader) }
                        }
                    }
                    catch {
                        Write-Verbose "No usable X-Rate-Limit-Reset header; backing off instead."
                    }

                    Write-Warning ("Okta returned HTTP $statusCode for $Method $Path. " +
                        "Retrying in $waitSeconds second(s) (attempt $attempt of $MaxRetry).")
                    Start-Sleep -Seconds $waitSeconds
                }
            }

            # Decode from the raw bytes rather than $response.Content. Okta does not send a
            # charset on application/json, and Windows PowerShell then decodes as Latin-1 and
            # turns every accented character in the directory into mojibake.
            $content = $null
            if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) {
                $content = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())
            }
            elseif ($response.Content -is [byte[]]) {
                $content = [System.Text.Encoding]::UTF8.GetString($response.Content)
            }
            else {
                $content = [string]$response.Content
            }

            $page = $null
            if (-not [string]::IsNullOrWhiteSpace($content)) {
                $page = $content | ConvertFrom-Json
            }

            if (-not $Paginate) {
                $result = $page
                break
            }

            $pageItems = @($page)
            if ($pageItems.Count -eq 0) { break }
            $collected.AddRange($pageItems)

            # Okta pages with an opaque cursor in a Link header. Windows PowerShell gives the
            # header back as one comma-joined string and PowerShell 7 as a collection, so
            # normalise before parsing. The self-comparison is the loop guard: Okta will
            # happily hand back a next link identical to the one just fetched.
            $currentUri = $nextUri
            $nextUri = $null
            $linkHeader = $response.Headers['Link']
            if ($linkHeader) {
                $linkText = (@($linkHeader) -join ', ')
                $match = [regex]::Match($linkText, '<(?<url>[^>]+)>\s*;\s*rel="next"')
                if ($match.Success -and $match.Groups['url'].Value -ne $currentUri) {
                    $nextUri = $match.Groups['url'].Value
                }
            }
        }
    }
    finally {
        $ProgressPreference = $previousProgress
    }

    if ($Paginate) { return $collected.ToArray() }
    return $result
}