mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
314 lines
11 KiB
PowerShell
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
|
|
}
|
|
}
|