Files
IntuneManagement/Internal/MSGraphErrors.ps1
T

314 lines
11 KiB
PowerShell

# MS Graph error-response + request helpers extracted from Public/Invoke-MSGraphAPI.ps1
# (architecture R11 - keep the public cmdlet file to just the public function).
# Module-internal, not exported.
# Wraps Invoke-MgGraphRequest so its response looks like Invoke-WebRequest's response —
# letting Invoke-MSGraphAPI use either backend without different downstream handling.
# Used when MgGraph is the active provider but the raw bearer token couldn't be
# extracted (SDK v2 in-memory cache is opaque to us). The SDK handles auth itself.
function Invoke-MgGraphRequestAsWebResponse {
[CmdletBinding()]
param(
[string]$Url,
[string]$Method = 'GET',
$Body,
[hashtable]$Headers
)
$mgParams = @{
Method = $Method
Uri = $Url
OutputType = 'Json'
ErrorAction = 'Stop'
}
if ($Body) {
$mgParams['Body'] = $Body
if ($Method -ne 'GET') { $mgParams['ContentType'] = 'application/json' }
}
# Drop headers the SDK manages itself (Authorization is added by the auth
# handler; Content-Type is set above). Pass through the rest so things like
# x-ms-client-request-id survive for trace correlation.
if ($Headers) {
$cleanHeaders = @{}
foreach ($k in $Headers.Keys) {
if ($k -notin 'Authorization', 'Content-Type', 'Content-Length') {
$cleanHeaders[$k] = $Headers[$k]
}
}
if ($cleanHeaders.Count -gt 0) { $mgParams['Headers'] = $cleanHeaders }
}
$mgStatusCode = $null
$mgParams['StatusCodeVariable'] = 'mgStatusCode'
$contentStr = Invoke-MgGraphRequest @mgParams
$sc = if ($mgStatusCode) { [int]$mgStatusCode } else { 200 }
[PSCustomObject]@{
StatusCode = $sc
StatusDescription = if ($sc -ge 200 -and $sc -lt 300) { 'OK' } else { 'Error' }
Content = [string]$contentStr
RawContentLength = if ($contentStr) { [long]([string]$contentStr).Length } else { [long]0 }
Headers = @{}
}
}
function Read-MSGraphErrorResponseContent {
param($ErrorRecord)
$response = $ErrorRecord.Exception.Response
if($response) {
try {
if($response.PSObject.Methods['GetResponseStream']) {
$reader = New-Object System.IO.StreamReader($response.GetResponseStream())
try {
$reader.BaseStream.Position = 0
$reader.DiscardBufferedData()
return $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
}
catch { }
try {
if($response.Content -and $response.Content.PSObject.Methods['ReadAsStringAsync']) {
return [string]$response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
}
catch { }
}
if($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
return [string]$ErrorRecord.ErrorDetails.Message
}
return $null
}
function Get-MSGraphErrorHeaderValue {
param(
$Response,
[string]$Name
)
if(-not $Response -or -not $Name) { return $null }
try {
if($Response.Headers) {
if($Response.Headers -is [System.Collections.IDictionary] -and $Response.Headers.Contains($Name)) {
return [string]$Response.Headers[$Name]
}
# PS5.1's WebException exposes a WebHeaderCollection, which derives from
# NameValueCollection - neither IDictionary nor TryGetValues. Without this
# branch every header lookup silently returned $null on 5.1.
if($Response.Headers -is [System.Collections.Specialized.NameValueCollection]) {
$nvValue = $Response.Headers[$Name]
if($null -ne $nvValue) { return [string]$nvValue }
}
$values = $null
if($Response.Headers.PSObject.Methods['TryGetValues'] -and $Response.Headers.TryGetValues($Name, [ref]$values)) {
return [string](@($values) -join ',')
}
}
}
catch { }
return $null
}
# Multi Admin Approval (MAA) support.
#
# Since June 2026 MAA also intercepts app-auth (client-credentials) calls, not just
# interactive admin actions. When the target resource is covered by an access policy,
# any POST/PATCH/PUT/DELETE is held for a second admin's approval. GET is never
# affected. The round trip Microsoft documents is:
#
# 1. Send the write with an 'x-msft-approval-justification' header (base64).
# 2. Graph answers HTTP 412 Precondition Failed, outer error code "BadRequest",
# and an 'x-msft-approval-code' header. That is NOT a failure - it means the
# approval request was created and is waiting for a human.
# 3. Poll deviceManagement/operationApprovalRequests until status is 'approved'.
# 4. Re-send the identical request with 'x-msft-approval-code' instead of the
# justification header.
#
# Omitting the justification header entirely gives HTTP 400 instead. A plain 403 is
# an ordinary permission problem and is classified separately so we don't blame MAA
# for a missing Graph scope.
#
# The approval code is returned in three places and PS5.1's WebException header
# access is unreliable, so probe all of them: the response header, the "HttpHeaders"
# field inside the nested error body, then the message text itself.
$script:MSGraphApprovalCodeHeader = 'x-msft-approval-code'
$script:MSGraphApprovalJustifyHeader = 'x-msft-approval-justification'
function ConvertTo-MSGraphApprovalJustification
{
param([string]$Justification)
if([String]::IsNullOrWhiteSpace($Justification)) { return $null }
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Justification))
}
function Get-MSGraphApprovalCodeFromError
{
param($Response, $GraphError)
$code = Get-MSGraphErrorHeaderValue $Response $script:MSGraphApprovalCodeHeader
if($code) { return $code.Trim() }
# The nested body carries the same header as an escaped JSON string, so the
# quotes arrive as \" rather than " - the pattern has to accept both forms.
if($GraphError -and $GraphError.Content)
{
$raw = $null
try { $raw = $GraphError.Content.error.message } catch { }
if(-not $raw) { $raw = $GraphError.RawContent }
if($raw)
{
$m = [regex]::Match([string]$raw, '\\?"x-msft-approval-code\\?"\s*:\s*\\?"([0-9a-fA-F-]{36})')
if($m.Success) { return $m.Groups[1].Value }
}
}
foreach($text in @($GraphError.Message, $GraphError.RawContent))
{
if(-not $text) { continue }
$m = [regex]::Match([string]$text, 'x-msft-approval-code:\s*([0-9a-fA-F-]{36})')
if($m.Success) { return $m.Groups[1].Value }
}
return $null
}
function Get-MSGraphApprovalInfo
{
param($ErrorRecord, $GraphError, [string]$HttpMethod, [string]$Url)
$info = [PSCustomObject]@{
StatusCode = $null
IsApprovalPending = $false
IsJustificationNeeded = $false
IsForbidden = $false
ApprovalCode = $null
Advice = $null
}
$response = $null
try { $response = $ErrorRecord.Exception.Response } catch { }
$status = $null
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
if($null -eq $status) { return $info }
$info.StatusCode = $status
if($status -notin @(400, 403, 412)) { return $info }
# GET is never gated by MAA, so anything here is an ordinary error.
$isWrite = $HttpMethod -and $HttpMethod.ToUpperInvariant() -in @('POST','PATCH','PUT','DELETE')
if($status -eq 412)
{
$code = Get-MSGraphApprovalCodeFromError $response $GraphError
if($code)
{
$info.IsApprovalPending = $true
$info.ApprovalCode = $code
$info.Advice = "Multi Admin Approval: the request was accepted and is waiting for another administrator to approve it. Approval code $code. Approve it in the Intune portal under Tenant administration > Multi Admin Approval, then run the operation again."
}
return $info
}
if($status -eq 403)
{
$info.IsForbidden = $true
$info.Advice = "Access denied. The signed-in identity lacks the Graph permission or Intune role needed for this operation. If the tenant uses Multi Admin Approval, check whether this application is excluded from the access policy."
return $info
}
# 400 - only treat as MAA when the body actually names the justification header.
$body = "$($GraphError.Message) $($GraphError.RawContent)"
if($isWrite -and $body -match [regex]::Escape($script:MSGraphApprovalJustifyHeader))
{
$info.IsJustificationNeeded = $true
$info.Advice = "Multi Admin Approval requires a justification for this change. Set the 'Multi Admin Approval justification' setting and retry - the request is then held for a second administrator to approve."
}
return $info
}
function ConvertFrom-MSGraphErrorResponse {
param($ErrorRecord)
$response = $ErrorRecord.Exception.Response
$rawContent = Read-MSGraphErrorResponseContent $ErrorRecord
$parsedContent = $null
$graphError = $null
$innerError = $null
$message = $null
$code = $null
$requestId = $null
$clientRequestId = $null
$date = $null
if($rawContent) {
try {
$parsedContent = $rawContent | ConvertFrom-Json -ErrorAction Stop
if($parsedContent.error) {
$graphError = $parsedContent.error
$code = $graphError.code
$message = $graphError.message
$innerError = $graphError.innerError
}
elseif($parsedContent.code -or $parsedContent.message) {
$graphError = $parsedContent
$code = $parsedContent.code
$message = $parsedContent.message
$innerError = $parsedContent.innerError
}
}
catch {
$message = $rawContent
}
}
if($message -and $message.StartsWith("{") -and $message.EndsWith("}")) {
try {
$nested = $message | ConvertFrom-Json -ErrorAction Stop
if($nested.Message) { $message = $nested.Message }
elseif($nested.message) { $message = $nested.message }
if(-not $code -and $nested.Code) { $code = $nested.Code }
if(-not $code -and $nested.code) { $code = $nested.code }
}
catch { }
}
if($innerError) {
$requestId = $innerError.'request-id'
if(-not $requestId) { $requestId = $innerError.requestId }
$clientRequestId = $innerError.'client-request-id'
if(-not $clientRequestId) { $clientRequestId = $innerError.clientRequestId }
$date = $innerError.date
}
if(-not $requestId) { $requestId = Get-MSGraphErrorHeaderValue $response 'request-id' }
if(-not $clientRequestId) { $clientRequestId = Get-MSGraphErrorHeaderValue $response 'client-request-id' }
if(-not $date) { $date = Get-MSGraphErrorHeaderValue $response 'Date' }
[PSCustomObject]@{
Code = $code
Message = $message
RequestId = $requestId
ClientRequestId = $clientRequestId
Date = $date
RawContent = $rawContent
Content = $parsedContent
}
}