Private/Graph/Invoke-IntuneDiffRequest.ps1

# Copyright (c) 2026 Sandy Zeng. All rights reserved.
# Source-available. All rights reserved. See LICENSE file.

<#
    Invoke-IntuneDiffRequest.ps1 — Thin wrapper around Invoke-MgGraphRequest with pagination and 429 retry.
 
    Author: Sandy Zeng
    Project: IntuneDiff
 
    Version History:
    1.0.0 Initial release.
#>


function Invoke-IntuneDiffRequest {
    <#
    .SYNOPSIS
        Thin wrapper around Invoke-MgGraphRequest with pagination + 429 retry.
 
    .DESCRIPTION
        - Calls Invoke-MgGraphRequest with the configured Graph context.
        - For GET requests, follows @odata.nextLink and concatenates `value` arrays.
        - Retries automatically on HTTP 429 honoring the Retry-After header.
        - Returns the raw response object for POST or the merged result for GET.
 
    .PARAMETER Method
        HTTP method. Defaults to GET.
 
    .PARAMETER Uri
        Absolute or relative Graph URI (e.g. /beta/deviceManagement/configurationPolicies).
 
    .PARAMETER Body
        Optional request body (hashtable) for POST/PATCH.
 
    .PARAMETER All
        For GET requests, follow @odata.nextLink until exhausted and return a hashtable
        whose `value` property contains every page concatenated.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter(Mandatory)]
        [string]$Uri,

        [object]$Body,

        [switch]$All,

        [int]$TimeoutSec = 30
    )

    Write-IDLog "Graph $Method $Uri"
    $maxRetries = 5
    $attempt = 0
    $currentUri = $Uri
    $tokenRefreshed = $false

    # Helper: detect 401 from various exception formats
    $isUnauthorized = {
        param($err)
        if ($err.Exception.Response -and [int]$err.Exception.Response.StatusCode -eq 401) { return $true }
        if ($err.Exception.Message -match 'Unauthorized') { return $true }
        return $false
    }

    # Helper: silently refresh token using MSAL cache
    $refreshToken = {
        Write-IDLog 'Token expired, attempting silent refresh...'
        $app = $script:MSALApp
        if (-not $app) { throw 'MSAL app not initialized. Please sign in again.' }
        $accounts = $app.GetAccountsAsync().GetAwaiter().GetResult()
        $account = $accounts | Select-Object -First 1
        if (-not $account) { throw 'No cached account found. Please sign in again.' }
        $scopes = Get-IntuneDiffRequiredScopes
        $silentBuilder = $app.AcquireTokenSilent($scopes, $account)
        $authResult = $silentBuilder.ExecuteAsync().GetAwaiter().GetResult()
        if (-not $authResult -or [string]::IsNullOrEmpty($authResult.AccessToken)) {
            throw 'Token refresh failed. Please sign in again.'
        }
        Write-IDLog "Token refreshed for $($authResult.Account.Username)"
        Write-MSALCache
        $secureToken = ConvertTo-SecureString $authResult.AccessToken -AsPlainText -Force
        try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
        try { Connect-MgGraph -AccessToken $secureToken -NoWelcome | Out-Null } catch {
            Connect-MgGraph -AccessToken $secureToken | Out-Null
        }
    }

    # Helper: invoke Graph request with timeout using thread job (shares session state)
    $invokeWithTimeout = {
        param([hashtable]$Params, [int]$Timeout)
        $job = Start-ThreadJob -ScriptBlock {
            param($p)
            Invoke-MgGraphRequest @p
        } -ArgumentList $Params
        $completed = $job | Wait-Job -Timeout $Timeout
        if (-not $completed) {
            $job | Stop-Job
            $job | Remove-Job -Force
            throw "Graph request timed out after ${Timeout}s: $($Params.Uri)"
        }
        $result = $job | Receive-Job
        $job | Remove-Job -Force
        return $result
    }

    if ($Method -eq 'GET' -and $All) {
        $aggregated = @()
        $firstResponse = $null

        while ($currentUri) {
            $attempt = 0
            $response = $null
            while ($true) {
                try {
                    $params = @{
                        Method = 'GET'
                        Uri    = $currentUri
                    }
                    $response = & $invokeWithTimeout $params $TimeoutSec
                    break
                } catch {
                    $statusCode = $null
                    if ($_.Exception.Response) {
                        $statusCode = [int]$_.Exception.Response.StatusCode
                    }

                    if ($statusCode -eq 429 -and $attempt -lt $maxRetries) {
                        $retryAfter = 2
                        try {
                            $headerValue = $_.Exception.Response.Headers['Retry-After']
                            if ($headerValue) { $retryAfter = [int]$headerValue }
                        } catch { }
                        Start-Sleep -Seconds $retryAfter
                        $attempt++
                        continue
                    }

                    if ((& $isUnauthorized $_) -and -not $tokenRefreshed) {
                        & $refreshToken
                        $tokenRefreshed = $true
                        continue
                    }

                    throw
                }
            }

            if (-not $firstResponse) { $firstResponse = $response }

            if ($response.value) {
                $aggregated += $response.value
            }

            $nextLink = $response.'@odata.nextLink'
            if ($nextLink) {
                if ($nextLink -notmatch '^https://graph\.microsoft\.com/') {
                    Write-Warning "IntuneDiff: Ignoring untrusted @odata.nextLink: $nextLink"
                    $nextLink = $null
                }
            }
            $currentUri = $nextLink
        }

        # Return shape mirroring a single Graph response with all rows.
        if ($firstResponse -is [hashtable]) {
            $firstResponse['value'] = $aggregated
            return $firstResponse
        }

        return @{ value = $aggregated }
    }

    # Single-shot (non-paginated) request
    while ($true) {
        try {
            $params = @{
                Method = $Method
                Uri    = $currentUri
            }
            if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
                $params['Body'] = ($Body | ConvertTo-Json -Depth 20 -Compress)
                $params['ContentType'] = 'application/json'
            }

            return (& $invokeWithTimeout $params $TimeoutSec)
        } catch {
            $statusCode = $null
            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            if ($statusCode -eq 429 -and $attempt -lt $maxRetries) {
                $retryAfter = 2
                try {
                    $headerValue = $_.Exception.Response.Headers['Retry-After']
                    if ($headerValue) { $retryAfter = [int]$headerValue }
                } catch { }
                Start-Sleep -Seconds $retryAfter
                $attempt++
                continue
            }

            if ((& $isUnauthorized $_) -and -not $tokenRefreshed) {
                & $refreshToken
                $tokenRefreshed = $true
                continue
            }

            throw
        }
    }
}