Public/Get-IOUsersWithoutMFA.ps1

function Get-IOUsersWithoutMFA {
    <#
    .SYNOPSIS
        Lists users who have no MFA authentication methods registered.
    .EXAMPLE
        Get-IOUsersWithoutMFA
    .EXAMPLE
        Get-IOUsersWithoutMFA -IncludeGuests -ToCsv "no-mfa.csv"
    .EXAMPLE
        Get-IOUsersWithoutMFA -AdminsOnly -ToCsv "admins-no-mfa.csv"
    #>

    [CmdletBinding()]
    param(
        [switch]$IncludeGuests,

        [switch]$AdminsOnly,

        [ValidateRange(1, 20)]
        [int]$BatchSize = 5,

        [string]$ToCsv
    )

    $cmdName = $MyInvocation.MyCommand.Name
    Write-IOLog 'Scanning users for MFA registration status...' -Level Info -Component $cmdName

    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    # Build user query — proper URL separator handling
    $selectFields = 'id,displayName,userPrincipalName,userType,accountEnabled'
    if ($IncludeGuests) {
        $uri = "v1.0/users?`$select=$selectFields"
    }
    else {
        $uri = "v1.0/users?`$filter=userType eq 'Member'&`$select=$selectFields"
    }

    $users = Invoke-IOGraphRequest -Uri $uri

    # If AdminsOnly, get directory role members first
    $adminUserIds = $null
    if ($AdminsOnly) {
        Write-IOLog 'Filtering to admin role holders only...' -Level Verbose -Component $cmdName
        $adminUserIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $roles = Invoke-IOGraphRequest -Uri 'v1.0/directoryRoles?$select=id,displayName' -NoPagination -SkipConnectionCheck
        foreach ($role in $roles) {
            try {
                $members = Invoke-IOGraphRequest -Uri "v1.0/directoryRoles/$($role.id)/members?`$select=id" -NoPagination -SkipConnectionCheck
                foreach ($m in $members) { [void]$adminUserIds.Add($m.id) }
            }
            catch { }
        }
        Write-IOLog "Found $($adminUserIds.Count) admin role holder(s)." -Level Verbose -Component $cmdName
    }

    # Filter to eligible users first (before making per-user API calls)
    $eligibleUsers = [System.Collections.Generic.List[object]]::new()
    foreach ($user in $users) {
        if (-not $user.accountEnabled) { continue }
        if ($AdminsOnly -and -not $adminUserIds.Contains($user.id)) { continue }
        $eligibleUsers.Add($user)
    }

    $total = $eligibleUsers.Count
    if ($total -eq 0) {
        Write-IOLog 'No eligible users found matching criteria.' -Level Info -Component $cmdName
        return
    }

    Write-IOLog "Checking MFA methods for $total eligible user(s)..." -Level Info -Component $cmdName
    $counter  = 0
    $errors   = 0
    $maxErrors = [math]::Max(50, [math]::Floor($total * 0.1))  # Circuit breaker: stop if >10% fail

    try {
        # Process in batches to reduce throttling impact
        for ($i = 0; $i -lt $total; $i += $BatchSize) {
            $batchEnd = [math]::Min($BatchSize, $total - $i)
            $batch = $eligibleUsers.GetRange($i, $batchEnd)

            foreach ($user in $batch) {
                $counter++
                if ($counter % 25 -eq 0 -or $counter -eq $total) {
                    Write-Progress -Activity 'Checking MFA methods' -Status "$counter / $total users (errors: $errors)" -PercentComplete (($counter / $total) * 100)
                }

                try {
                    $methods = Invoke-IOGraphRequest -Uri "v1.0/users/$($user.id)/authentication/methods" -NoPagination -SkipConnectionCheck
                }
                catch {
                    $errors++
                    Write-IOLog "Could not read auth methods for $($user.userPrincipalName): $($_.Exception.Message)" -Level Verbose -Component $cmdName

                    # Circuit breaker — abort if too many errors (likely permissions issue)
                    if ($errors -ge $maxErrors) {
                        Write-IOLog "Circuit breaker triggered: $errors errors out of $counter attempts. Aborting. Check UserAuthenticationMethod.Read.All permission." -Level Warning -Component $cmdName
                        break
                    }
                    continue
                }

                # Classify methods — password-only means no MFA
                $mfaMethods = @($methods | Where-Object {
                    $_.'@odata.type' -and
                    $_.'@odata.type' -notin @(
                        '#microsoft.graph.passwordAuthenticationMethod',
                        '#microsoft.graph.emailAuthenticationMethod'
                    )
                })

                if ($mfaMethods.Count -eq 0) {
                    $results.Add([PSCustomObject]@{
                        DisplayName       = $user.displayName
                        UserPrincipalName = $user.userPrincipalName
                        ObjectId          = $user.id
                        UserType          = $user.userType
                        RegisteredMethods = 'Password Only'
                        MFAMethodCount    = 0
                        Risk              = if ($AdminsOnly -or ($adminUserIds -and $adminUserIds.Contains($user.id))) { 'Critical' } else { 'High' }
                    })
                }
            }

            # Throttle-friendly delay between batches for large tenants
            if ($total -gt 100 -and ($i + $BatchSize) -lt $total) {
                Start-Sleep -Milliseconds 200
            }
        }
    }
    finally {
        Write-Progress -Activity 'Checking MFA methods' -Completed
    }

    if ($errors -gt 0) {
        Write-IOLog "Completed with $errors error(s) out of $counter user(s) checked." -Level Warning -Component $cmdName
    }

    $sorted = $results | Sort-Object Risk, DisplayName
    Export-IOResult -Data $sorted -ToCsv $ToCsv -CommandName $cmdName
}