Add script to report user MFA methods and highlight SMS/Voice reliance ahead of retirement

This commit is contained in:
Petr Štěpán
2026-07-27 14:36:15 +02:00
parent f2f0537bb9
commit 6b1595c168
+598
View File
@@ -0,0 +1,598 @@
<#
.SYNOPSIS
Reports every user's registered MFA/authentication methods and default method,
flagging accounts that rely solely on SMS/Voice (red) or whose default method
is SMS/Voice even though other methods exist (yellow).
.DESCRIPTION
Built ahead of the Microsoft Entra ID SMS/Voice retirement
(https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement).
Works on both Entra ID P1/P2 tenants and Free/Business Basic tenants:
- On P1/P2 tenants it uses the fast tenant-wide report
(Get-MgReportAuthenticationMethodUserRegistrationDetail), one call for
every user, including the reported preferred/default method.
- On tenants without P1/P2 (or if that report is unavailable/unlicensed)
it falls back to the per-user authentication methods API
(Get-MgUserAuthenticationMethod), which does not require premium
licensing, and best-effort per-user sign-in preference for the
default method.
The script never modifies anything in the tenant - it only reads
authentication method registrations - so it does not need -WhatIf/-Confirm.
Row classification in the HTML/CSV output:
- RED ("SmsVoiceOnly"): the only MFA-capable method(s) registered
are SMS and/or Voice call. These accounts
will hit the blocking passkey prompt on
Feb 2027 retirement with no fallback.
- YELLOW ("SmsVoiceRegistered"): other MFA methods are registered too,
but SMS or Voice is also registered and
still needs to be removed from the
account ahead of the retirement.
- (none): no SMS/Voice exposure, or no MFA registered
at all (flagged separately in the Notes
column - a different problem, not this
retirement).
Intended to be run once per tenant. For an MSP/CSP audit across many
customer tenants, call this script once per tenant (looping over your
GDAP-delegated customers) and collect the per-tenant CSV outputs.
.PARAMETER TenantId
Target Entra ID tenant ID or domain to connect to. Optional if you are
already connected via Connect-MgGraph, or if you want the interactive/
device-code sign-in to resolve the tenant itself.
.PARAMETER OutputFolder
Folder to write the CSV and HTML report to. Defaults to the current
working directory.
.PARAMETER IncludeGuests
Include guest (B2B) accounts in the report. By default only Member users
are reported, matching how Microsoft scopes the SMS/Voice retirement.
.PARAMETER ForcePerUserLookup
Skip the fast tenant-wide registration report even if the tenant has
P1/P2, and use the slower per-user method lookup for every user instead.
Useful for validating the two code paths against each other.
.EXAMPLE
PS> .\Get-UserMfaMethodReport.ps1
Connects interactively, reports on the current tenant, and writes
UserMfaMethodReport.csv / .html to the current folder.
.EXAMPLE
PS> .\Get-UserMfaMethodReport.ps1 -TenantId "contoso.onmicrosoft.com" -OutputFolder "C:\Audits\Contoso"
Reports on a specific customer tenant (e.g. via GDAP) and writes output
to a per-client folder.
.NOTES
Author: Petr Štěpán
Created: 2026-07-27
Version: 1.0.0
Changelog:
1.0.0 - Initial version
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
[Parameter(Mandatory = $false, HelpMessage = 'Target tenant ID or domain name.')]
[string]
$TenantId,
[Parameter(Mandatory = $false, HelpMessage = 'Folder to write the CSV/HTML report to.')]
[ValidateNotNullOrEmpty()]
[string]
$OutputFolder = (Get-Location).Path,
[Parameter(Mandatory = $false)]
[switch]
$IncludeGuests,
[Parameter(Mandatory = $false)]
[switch]
$ForcePerUserLookup
)
# --- Safety preamble -------------------------------------------------------
# Fail fast on uninitialized variables and typos in property/member names.
Set-StrictMode -Version Latest
# Treat all non-terminating errors as terminating so failures can't slip by silently,
# except inside the per-user/per-item loops below, where we deliberately catch and
# continue so one bad user doesn't abort the whole tenant run.
$ErrorActionPreference = 'Stop'
# Method @odata.type values that count as a genuine MFA-capable factor.
# Password and Email are excluded: Password isn't a second factor, and Email
# is an SSPR-only method, not an MFA method.
$script:MfaMethodTypes = @(
'#microsoft.graph.phoneAuthenticationMethod'
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod'
'#microsoft.graph.fido2AuthenticationMethod'
'#microsoft.graph.windowsHelloForBusinessAuthenticationMethod'
'#microsoft.graph.softwareOathAuthenticationMethod'
'#microsoft.graph.temporaryAccessPassAuthenticationMethod'
'#microsoft.graph.platformCredentialAuthenticationMethod'
)
# --- Functions ---------------------------------------------------------
function Test-PremiumAuthReportLicense {
<#
.SYNOPSIS
Returns $true if the connected tenant has an Entra ID P1 or P2 service
plan assigned to any subscribed SKU, which is required for the
tenant-wide authentication methods registration report.
#>
[CmdletBinding()]
param()
try {
[array]$skus = Get-MgSubscribedSku -All -ErrorAction Stop
}
catch {
Write-Warning "Could not read subscribed SKUs to detect P1/P2 licensing: $_"
return $false
}
# AAD_PREMIUM = Entra ID P1, AAD_PREMIUM_P2 = Entra ID P2. Both are also
# included as service plans inside bundles like Business Premium / E3 / E5.
$premiumPlanNames = @('AAD_PREMIUM', 'AAD_PREMIUM_P2')
foreach ($sku in $skus) {
foreach ($plan in $sku.ServicePlans) {
if ($plan.ServicePlanName -in $premiumPlanNames -and $plan.ProvisioningStatus -eq 'Success') {
return $true
}
}
}
return $false
}
function ConvertTo-FriendlyAuthMethod {
<#
.SYNOPSIS
Maps a Graph authentication method @odata.type (plus phoneType, where
relevant) to a friendly display name and a category used for
red/yellow classification.
.PARAMETER ODataType
The '@odata.type' value of the method, e.g.
'#microsoft.graph.phoneAuthenticationMethod'.
.PARAMETER PhoneType
For phone methods only: 'mobile', 'alternateMobile', or 'office'.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$ODataType,
[Parameter(Mandatory = $false)]
[string]
$PhoneType
)
switch ($ODataType) {
'#microsoft.graph.passwordAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Password'; Category = 'NotMfa' }
}
'#microsoft.graph.phoneAuthenticationMethod' {
$label = if ($PhoneType -eq 'office') { 'Voice call (office phone)' } else { 'SMS / Voice call (mobile)' }
return [PSCustomObject]@{ DisplayName = $label; Category = 'Phone' }
}
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Microsoft Authenticator'; Category = 'Strong' }
}
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Passwordless (Authenticator)'; Category = 'Strong' }
}
'#microsoft.graph.fido2AuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'FIDO2 / Passkey'; Category = 'Strong' }
}
'#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Windows Hello for Business'; Category = 'Strong' }
}
'#microsoft.graph.softwareOathAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'OATH software token'; Category = 'Strong' }
}
'#microsoft.graph.temporaryAccessPassAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Temporary Access Pass'; Category = 'Other' }
}
'#microsoft.graph.platformCredentialAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Platform credential (e.g. Mac)'; Category = 'Strong' }
}
'#microsoft.graph.emailAuthenticationMethod' {
return [PSCustomObject]@{ DisplayName = 'Email (SSPR only)'; Category = 'NotMfa' }
}
default {
return [PSCustomObject]@{ DisplayName = "Unknown method ($ODataType)"; Category = 'Other' }
}
}
}
function Get-UserAuthMethodDetail {
<#
.SYNOPSIS
Reads a single user's registered authentication methods and best-effort
default/preferred method via the per-user Graph API. Works regardless
of tenant licensing.
.PARAMETER UserId
The Entra ID object ID of the user to look up.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$UserId
)
[array]$friendlyMethods = @()
$hasStrongMethod = $false
$hasPhoneMethod = $false
try {
[array]$methods = Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop
}
catch {
Write-Warning "Could not read authentication methods for user '$UserId': $_"
return [PSCustomObject]@{
MethodsDisplay = 'Error retrieving methods'
HasStrongMethod = $false
HasPhoneMethod = $false
DefaultMethod = $null
}
}
foreach ($method in $methods) {
$odataType = $method.AdditionalProperties['@odata.type']
$phoneType = $method.AdditionalProperties['phoneType']
$friendly = ConvertTo-FriendlyAuthMethod -ODataType $odataType -PhoneType $phoneType
$friendlyMethods += $friendly.DisplayName
if ($friendly.Category -eq 'Strong') { $hasStrongMethod = $true }
if ($friendly.Category -eq 'Phone') { $hasPhoneMethod = $true }
}
# Best-effort default/preferred method. This endpoint reflects the user's
# own sign-in preference and, unlike the tenant-wide usage/insights report,
# is not gated behind a P1/P2 license - but treat failures as "unknown"
# rather than fatal, since behavior can vary by tenant/SDK version.
$defaultMethod = $null
try {
$preference = Get-MgBetaUserAuthenticationSignInPreference -UserId $UserId -ErrorAction Stop
$defaultMethod = $preference.UserPreferredMethodForSecondaryAuthentication
}
catch {
Write-Verbose "No sign-in preference available for user '$UserId' (falling back to heuristic): $_"
}
return [PSCustomObject]@{
MethodsDisplay = ($friendlyMethods -join ', ')
HasStrongMethod = $hasStrongMethod
HasPhoneMethod = $hasPhoneMethod
DefaultMethod = $defaultMethod
}
}
function Export-AuthenticationHtmlReport {
<#
.SYNOPSIS
Writes the collected report rows to a colour-coded HTML file.
.PARAMETER Report
Array of report row objects (must include a 'RowClass' property).
.PARAMETER Path
Destination HTML file path.
.PARAMETER TenantDisplayName
Tenant name shown in the report header.
.PARAMETER IsPremiumTenant
Whether the tenant-wide (P1/P2) report path was used.
.PARAMETER MethodPolicyStates
Array of objects with Id/State properties describing the tenant's
authentication methods policy (which methods are allowed/enabled),
as returned by Get-MgPolicyAuthenticationMethodPolicy. Optional -
omit or pass an empty array to skip that section of the report.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[array]
$Report,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$Path,
[Parameter(Mandatory = $true)]
[string]
$TenantDisplayName,
[Parameter(Mandatory = $true)]
[bool]
$IsPremiumTenant,
[Parameter(Mandatory = $false)]
[array]
$MethodPolicyStates = @()
)
$runDate = Get-Date -Format 'dd-MMM-yyyy HH:mm:ss'
$licenseNote = if ($IsPremiumTenant) { 'P1/P2 detected - tenant-wide report used' } else { 'No P1/P2 detected - per-user lookup used' }
$htmlHead = @"
<html>
<style>
BODY{font-family: Arial; font-size: 9pt;}
H1{font-size: 20px; font-family: 'Segoe UI Light','Segoe UI',Verdana,Arial,sans-serif;}
H3{font-size: 13px; font-family: 'Segoe UI Light','Segoe UI',Verdana,Arial,sans-serif; font-weight: normal;}
TABLE{border: 1px solid #969595; border-collapse: collapse; font-size: 9pt;}
TH{border: 1px solid #969595; background: #dddddd; padding: 5px; color: #000000;}
TD{border: 1px solid #969595; padding: 5px;}
tr.SmsVoiceOnly{background: #f8d7da;}
tr.SmsVoiceRegistered{background: #fff3cd;}
tr.MethodEnabledSmsVoice{background: #fff3cd;}
tr.MethodEnabled{background: #d4edda;}
tr.MethodDisabled{background: #e2e3e5; color: #6c6c6c;}
</style>
<body>
<div align="center">
<h1>User MFA Method Report - SMS/Voice Retirement Impact</h1>
<h3>Tenant: $TenantDisplayName &nbsp;|&nbsp; Generated: $runDate &nbsp;|&nbsp; $licenseNote</h3>
</div>
"@
$htmlTable = $Report |
Select-Object DisplayName, UserPrincipalName, RegisteredMethods, DefaultMethod, Notes |
ConvertTo-Html -Fragment
[xml]$xml = $htmlTable
$tableClassAttr = $xml.CreateAttribute('class')
$tableClassAttr.Value = 'ReportTable'
$xml.table.Attributes.Append($tableClassAttr) | Out-Null
# Walk the generated <tr> rows in lock-step with the report rows to apply
# the RowClass (SmsVoiceOnly / SmsVoiceRegistered / none) computed earlier.
$rowIndex = 0
foreach ($tableRow in $xml.table.SelectNodes('tr')) {
if ($tableRow.SelectNodes('th').Count -eq 0 -and $rowIndex -lt $Report.Count) {
$rowClass = $Report[$rowIndex].RowClass
if ($rowClass) {
$tableRow.SetAttribute('class', $rowClass)
}
$rowIndex++
}
}
$htmlBody = [string]::Format('<div>{0}</div>', $xml.OuterXml)
$countOnly = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count
$countDefault = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count
$methodPolicyHtml = ''
if ($MethodPolicyStates.Count -gt 0) {
$methodRows = foreach ($methodState in $MethodPolicyStates) {
$isSmsVoice = $methodState.Id -in @('Sms', 'Voice')
$rowClass = if ($methodState.State -ne 'enabled') { 'MethodDisabled' } elseif ($isSmsVoice) { 'MethodEnabledSmsVoice' } else { 'MethodEnabled' }
"<tr class='$rowClass'><td>$($methodState.Id)</td><td>$($methodState.State)</td></tr>"
}
$methodPolicyHtml = @"
<div align="center">
<h3>Allowed authentication methods (tenant policy)</h3>
<table>
<tr><th>Method</th><th>State</th></tr>
$($methodRows -join "`n")
</table>
</div>
"@
}
$htmlTail = @"
<p>-----------------------------------------------------------------------</p>
<p>Total users reported: $($Report.Count)</p>
<p style="background:#f8d7da; display:inline-block; padding:2px 6px;">Red - only MFA method is SMS/Voice: $countOnly</p><br/>
<p style="background:#fff3cd; display:inline-block; padding:2px 6px;">Yellow - SMS/Voice registered alongside other methods: $countDefault</p>
<p>Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement</p>
"@
($htmlHead + $htmlBody + $htmlTail + $methodPolicyHtml) | Out-File -FilePath $Path -Encoding utf8
}
# --- Main --------------------------------------------------------------
try {
Write-Verbose 'Connecting to Microsoft Graph...'
$requiredScopes = @(
'User.Read.All'
'UserAuthenticationMethod.Read.All'
'Reports.Read.All'
'AuditLog.Read.All'
'Organization.Read.All'
'Directory.Read.All'
'Policy.Read.All'
)
$connectParams = @{ Scopes = $requiredScopes; NoWelcome = $true }
if ($TenantId) { $connectParams['TenantId'] = $TenantId }
Connect-MgGraph @connectParams
[array]$organization = Get-MgOrganization -ErrorAction SilentlyContinue
$tenantDisplayName = if ($organization) { $organization[0].DisplayName } else { 'Unknown tenant' }
$isPremium = (-not $ForcePerUserLookup) -and (Test-PremiumAuthReportLicense)
Write-Host "Tenant: $tenantDisplayName - Premium (P1/P2) auth report available: $isPremium" -ForegroundColor Cyan
# Pull user population first; every user in scope gets a row regardless
# of which data-source path is used for their methods.
Write-Host 'Retrieving user accounts...' -ForegroundColor Cyan
$userFilter = "accountEnabled eq true"
if (-not $IncludeGuests) { $userFilter += " and userType eq 'Member'" }
[array]$users = Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound `
-Property Id, DisplayName, UserPrincipalName, UserType -All -PageSize 500 -Sort DisplayName
Write-Host "Found $($users.Count) enabled user account(s) to report on." -ForegroundColor Cyan
# On premium tenants, try the fast tenant-wide report once; fall back to
# per-user lookup for everyone if the report call fails (e.g. missing
# consent, throttled tenant, or a licensing edge case the SKU check missed).
$registrationLookup = $null
if ($isPremium) {
try {
Write-Host 'Retrieving tenant-wide authentication method registration report...' -ForegroundColor Cyan
[array]$registrationDetails = Get-MgReportAuthenticationMethodUserRegistrationDetail -All -PageSize 500
$registrationLookup = @{}
foreach ($detail in $registrationDetails) {
$registrationLookup[$detail.Id] = $detail
}
}
catch {
Write-Warning "Tenant-wide registration report failed even though P1/P2 was detected; falling back to per-user lookup. Error: $_"
$registrationLookup = $null
}
}
$report = [System.Collections.Generic.List[Object]]::new()
[int]$i = 0
foreach ($user in $users) {
$i++
Write-Progress -Activity 'Retrieving MFA methods' -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100)
Write-Verbose ("Processing {0} ({1}/{2})..." -f $user.DisplayName, $i, $users.Count)
$registeredMethodsDisplay = $null
$hasStrongMethod = $false
$hasPhoneMethod = $false
$defaultMethod = $null
$regDetail = if ($registrationLookup) { $registrationLookup[$user.Id] } else { $null }
if ($regDetail) {
# Fast path: everything needed comes from the single tenant-wide report.
$registeredMethodsDisplay = ($regDetail.MethodsRegistered -join ', ')
$hasPhoneMethod = ($regDetail.MethodsRegistered -contains 'mobilePhone') -or ($regDetail.MethodsRegistered -contains 'alternateMobilePhone') -or ($regDetail.MethodsRegistered -contains 'officePhone')
$strongRegistered = $regDetail.MethodsRegistered | Where-Object {
$_ -notin @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'email', 'password')
}
$hasStrongMethod = [bool]$strongRegistered
$defaultMethod = $regDetail.UserPreferredMethodForSecondaryAuthentication
}
else {
# Fallback path: per-user methods API, works without P1/P2.
try {
$detail = Get-UserAuthMethodDetail -UserId $user.Id
$registeredMethodsDisplay = $detail.MethodsDisplay
$hasStrongMethod = $detail.HasStrongMethod
$hasPhoneMethod = $detail.HasPhoneMethod
$defaultMethod = $detail.DefaultMethod
}
catch {
# Do not let one user's failure abort the whole tenant run.
Write-Warning "Skipping method detail for '$($user.UserPrincipalName)' after repeated failure: $_"
$registeredMethodsDisplay = 'Error retrieving methods'
}
}
# If no explicit default was reported/available, fall back to a
# heuristic: if phone is the only MFA-capable method registered,
# it is by definition the (only possible) default.
$notes = $null
if (-not $defaultMethod) {
if ($hasPhoneMethod -and -not $hasStrongMethod) {
$defaultMethod = 'sms/voice (inferred - only method registered)'
}
elseif (-not $hasPhoneMethod -and -not $hasStrongMethod) {
$notes = 'No MFA method registered'
}
else {
$defaultMethod = 'Not reported'
}
}
$rowClass = $null
if ($hasPhoneMethod -and -not $hasStrongMethod) {
$rowClass = 'SmsVoiceOnly'
}
elseif ($hasStrongMethod -and $hasPhoneMethod) {
# SMS/Voice is registered alongside other methods - not the user's
# only option, but it still needs to be removed from their
# account ahead of the retirement, regardless of default status.
$rowClass = 'SmsVoiceRegistered'
}
$report.Add([PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
RegisteredMethods = $registeredMethodsDisplay
DefaultMethod = $defaultMethod
RowClass = $rowClass
Notes = $notes
})
}
Write-Progress -Activity 'Retrieving MFA methods' -Completed
# Tenant-wide authentication methods policy: which methods are allowed
# (enabled) at all, regardless of who has actually registered them.
# Non-fatal - this is a supplementary overview, not required to have
# already produced the per-user report above.
[array]$methodPolicyStates = @()
try {
$authMethodPolicy = Get-MgPolicyAuthenticationMethodPolicy -ErrorAction Stop
[array]$methodPolicyStates = $authMethodPolicy.AuthenticationMethodConfigurations |
Select-Object Id, State |
Sort-Object Id
}
catch {
Write-Warning "Could not read tenant authentication methods policy: $_"
}
if (-not (Test-Path -Path $OutputFolder)) {
New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
# Prefer the tenant ID/domain the caller connected with (e.g.
# "contoso.onmicrosoft.com") over the organization's display name, since
# it's stable, unique, and matches how an MSP/CSP would identify the
# customer tenant across multiple runs. Fall back to the display name
# when no -TenantId was supplied (e.g. interactive sign-in).
$tenantIdentifier = if ($TenantId) { $TenantId } else { $tenantDisplayName }
# Strip characters that are reserved in file names on Windows (the
# strictest of the platforms this cross-platform script can run on)
# so the tenant identifier can be safely embedded in the file name.
$safeTenantName = ($tenantIdentifier -replace '[\\/:*?"<>|]', '_').Trim()
$csvPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.csv"
$htmlPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.html"
$report | Select-Object DisplayName, UserPrincipalName, RegisteredMethods, DefaultMethod, RowClass, Notes |
Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8
Export-AuthenticationHtmlReport -Report $report -Path $htmlPath -TenantDisplayName $tenantDisplayName -IsPremiumTenant $isPremium -MethodPolicyStates $methodPolicyStates
$countOnly = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count
$countDefault = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count
Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta
Write-Host "Tenant: $tenantDisplayName"
Write-Host "Total users reported: $($report.Count)"
Write-Host "Red (SMS/Voice only method): $countOnly" -ForegroundColor Red
Write-Host "Yellow (SMS/Voice registered alongside other methods): $countDefault" -ForegroundColor Yellow
Write-Host "CSV report: $csvPath"
Write-Host "HTML report: $htmlPath"
if ($methodPolicyStates.Count -gt 0) {
Write-Host "`nAllowed authentication methods (tenant policy):" -ForegroundColor Magenta
foreach ($methodState in $methodPolicyStates) {
$isSmsVoice = $methodState.Id -in @('Sms', 'Voice')
$color = if ($methodState.State -ne 'enabled') { 'DarkGray' } elseif ($isSmsVoice) { 'Yellow' } else { 'Green' }
Write-Host (" {0,-32} {1}" -f $methodState.Id, $methodState.State) -ForegroundColor $color
}
}
}
catch {
Write-Verbose "Full stack trace: $($_.ScriptStackTrace)"
Write-Error "Script failed: $_"
exit 1
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
exit 0