Providers/Entra/Private/Get-EntraErrorDetail.ps1

function Get-EntraErrorDetail {
    <#
    .SYNOPSIS
        Extracts the useful part of a failed Graph or token endpoint response

    .DESCRIPTION
        Graph returns its diagnosis in the response body - error.code, error.message and a
        request id - and the token endpoint uses a different shape again, with
        error_description carrying the AADSTS code that actually identifies the problem.
        PowerShell surfaces neither by default: Windows PowerShell throws away the body of a
        failed response entirely, and PowerShell 7 keeps it only in ErrorDetails.

        Both editions are handled, and the request id is included whenever Graph sends one.
        That id is the only thing Microsoft support can correlate against their side, so
        discarding it turns a supportable failure into an anecdote.

    .PARAMETER ErrorRecord
        The error record caught from Invoke-WebRequest

    .OUTPUTS
        System.String. A single-line description.

    .EXAMPLE
        PS> Get-EntraErrorDetail -ErrorRecord $_

        DESCRIPTION: Turns a failed response into something worth putting in an exception
        OUTPUT: Authorization_RequestDenied: Insufficient privileges to complete the operation. (request-id 3f2a...)
        USE CASE: Called from the catch block in Invoke-EntraRequest

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $raw = $null

    # PowerShell 7 path: the body is kept here and the stream has already been consumed.
    if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
        $raw = $ErrorRecord.ErrorDetails.Message
    }

    # Windows PowerShell path: read the response stream directly, because the body is not
    # attached to the error record at all.
    if (-not $raw -and $ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
        try {
            $stream = $ErrorRecord.Exception.Response.GetResponseStream()
            if ($stream) {
                $stream.Position = 0
                $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8)
                try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() }
            }
        }
        catch {
            Write-Verbose "Could not read the error response stream: $($_.Exception.Message)"
        }
    }

    if ([string]::IsNullOrWhiteSpace($raw)) { return $ErrorRecord.Exception.Message }

    try {
        $parsed = $raw | ConvertFrom-Json
    }
    catch {
        # Not JSON - a proxy or a gateway error page. Collapse it so one bad response cannot
        # paste an entire HTML document into an exception message.
        $flat = ($raw -replace '\s+', ' ').Trim()
        if ($flat.Length -gt 400) { $flat = $flat.Substring(0, 400) + '...' }
        return $flat
    }

    # Graph shape
    if ($parsed.PSObject.Properties['error'] -and $parsed.error -isnot [string]) {
        $text = '{0}: {1}' -f $parsed.error.code, $parsed.error.message
        if ($parsed.error.PSObject.Properties['innerError'] -and $parsed.error.innerError.PSObject.Properties['request-id']) {
            $text += " (request-id $($parsed.error.innerError.'request-id'))"
        }
        return $text
    }

    # Token endpoint shape
    if ($parsed.PSObject.Properties['error_description']) {
        return (($parsed.error_description -replace '\s+', ' ').Trim())
    }
    if ($parsed.PSObject.Properties['error']) {
        return [string]$parsed.error
    }

    return (($raw -replace '\s+', ' ').Trim())
}