Finds Windows devices joined to Entra ID (Azure AD) that are NOT enrolled in Intune Powershell via Graph

#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.DeviceManagement

<#
.SYNOPSIS
    Finds Windows devices joined to Entra ID (Azure AD) that are NOT enrolled in Intune.

.DESCRIPTION
    Queries Microsoft Graph for all Windows devices in Entra ID (Azure AD Joined or Hybrid
    Azure AD Joined), then compares against Intune-enrolled devices. Outputs a report of
    devices present in Entra but missing from Intune.

    Supports:
      - Single tenant or multi-tenant (GDAP/CSP) via -TenantId
      - Export to CSV
      - Filtering by join type (AADJ, HAADJ, or both)
      - Optional stale device filtering by last sign-in age

.PARAMETER TenantId
    The target tenant ID or domain. Defaults to your home tenant if omitted.

.PARAMETER ExportCsv
    Path to export results as a CSV file. If omitted, results are written to the console.

.PARAMETER JoinType
    Filter by join type. Options: All, AADJ (Azure AD Joined only), HAADJ (Hybrid only).
    Default: All

.PARAMETER ExcludeStaleDevices
    If specified, devices with no sign-in activity in the last N days are excluded.

.PARAMETER StaleDaysThreshold
    Number of days of inactivity to consider a device stale. Default: 90.
    Only used when -ExcludeStaleDevices is set.

.EXAMPLE
    # Run against your home tenant, output to console
    .\Get-EntraNotInIntune.ps1

.EXAMPLE
    # Run against a specific tenant (GDAP), export to CSV
    .\Get-EntraNotInIntune.ps1 -TenantId "contoso.onmicrosoft.com" -ExportCsv "C:\Reports\EntraNotInIntune.csv"

.EXAMPLE
    # AADJ only, exclude devices inactive for 60+ days
    .\Get-EntraNotInIntune.ps1 -JoinType AADJ -ExcludeStaleDevices -StaleDaysThreshold 60

.NOTES
    Required Graph API Permissions (Application or Delegated):
      - Device.Read.All
      - DeviceManagementManagedDevices.Read.All

    Install modules if needed:
      Install-Module Microsoft.Graph -Scope CurrentUser
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string]$TenantId,

    [Parameter()]
    [string]$ExportCsv,

    [Parameter()]
    [ValidateSet("All", "AADJ", "HAADJ")]
    [string]$JoinType = "All",

    [Parameter()]
    [switch]$ExcludeStaleDevices,

    [Parameter()]
    [int]$StaleDaysThreshold = 90
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

#region ?? Helper ??????????????????????????????????????????????????????????????

function Get-AllGraphPages {
    <#
    .SYNOPSIS Handles paged Graph responses, returning all items as a flat array. #>
    param(
        [Parameter(Mandatory)][string]$Uri,
        [hashtable]$QueryParams = @{}
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $nextLink = $Uri

    # Append query params to initial URI
    if ($QueryParams.Count -gt 0) {
        $qs = ($QueryParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&"
        $nextLink = "$Uri`?$qs"
    }

    do {
        $response = Invoke-MgGraphRequest -Method GET -Uri $nextLink
        if ($response.value) { $results.AddRange($response.value) }
        # Safely retrieve nextLink — property won't exist on the final page, and
        # StrictMode -Version Latest throws on missing properties, so we check first.
        $nextLink = if ($response.PSObject.Properties['@odata.nextLink']) {
            $response.'@odata.nextLink'
        } else { $null }
    } while ($nextLink)

    return $results
}

#endregion

#region ?? Connect ?????????????????????????????????????????????????????????????

Write-Host "`n[+] Connecting to Microsoft Graph..." -ForegroundColor Cyan

$connectParams = @{
    Scopes = @(
        "Device.Read.All",
        "DeviceManagementManagedDevices.Read.All"
    )
    NoWelcome = $true
}

if ($TenantId) {
    $connectParams["TenantId"] = $TenantId
    Write-Host "    Tenant : $TenantId" -ForegroundColor Gray
}

Connect-MgGraph @connectParams

$context = Get-MgContext
Write-Host "    Signed in as : $($context.Account)" -ForegroundColor Gray
Write-Host "    Tenant ID    : $($context.TenantId)" -ForegroundColor Gray

#endregion

#region ?? Fetch Entra Devices ?????????????????????????????????????????????????

Write-Host "`n[+] Fetching Windows devices from Entra ID..." -ForegroundColor Cyan

# Build OData filter based on join type selection.
# Workplace Joined (trustType 'Workplace') are personal/registered devices - explicitly
# excluded from All to prevent duplicates where a device has both an AADJ and a Registered object.
$osFilter    = "operatingSystem eq 'Windows'"
$joinFilters = @{
    All   = "$osFilter and (trustType eq 'AzureAd' or trustType eq 'ServerAd')"
    AADJ  = "$osFilter and trustType eq 'AzureAd'"
    HAADJ = "$osFilter and trustType eq 'ServerAd'"
}

$entraUri = "https://graph.microsoft.com/v1.0/devices"
$entraQueryParams = @{
    '$filter' = $joinFilters[$JoinType]
    '$select' = "id,deviceId,displayName,operatingSystem,operatingSystemVersion,trustType,approximateLastSignInDateTime,accountEnabled,isCompliant,isManaged"
    '$top'    = "999"
}

$entraDevices = Get-AllGraphPages -Uri $entraUri -QueryParams $entraQueryParams
Write-Host "    Found $($entraDevices.Count) Windows devices in Entra ID (filter: $JoinType)" -ForegroundColor Gray

# Optional: exclude stale devices
if ($ExcludeStaleDevices) {
    $cutoff = (Get-Date).AddDays(-$StaleDaysThreshold)
    $before = $entraDevices.Count
    $entraDevices = $entraDevices | Where-Object {
        $_.approximateLastSignInDateTime -and
        ([datetime]$_.approximateLastSignInDateTime) -ge $cutoff
    }
    Write-Host "    Excluded $($before - $entraDevices.Count) stale devices (inactive > $StaleDaysThreshold days)" -ForegroundColor Gray
}

# Deduplicate by deviceId — prefer AADJ over HAADJ if both exist for same deviceId.
# This handles edge cases where a device has multiple Entra objects (e.g. re-registered).
$trustPriority = @{ 'AzureAd' = 0; 'ServerAd' = 1 }
$entraDevices = $entraDevices |
    Group-Object -Property { $_.deviceId } |
    ForEach-Object {
        $_.Group |
            Sort-Object { $trustPriority[($_.trustType)] ?? 99 } |
            Select-Object -First 1
    }
Write-Host "    After deduplication: $($entraDevices.Count) unique devices" -ForegroundColor Gray

#endregion

#region ?? Fetch Intune Devices ????????????????????????????????????????????????

Write-Host "`n[+] Fetching enrolled devices from Intune..." -ForegroundColor Cyan

$intuneUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"
$intuneQueryParams = @{
    '$filter' = "operatingSystem eq 'Windows'"
    '$select' = "id,azureADDeviceId,deviceName,lastSyncDateTime,complianceState,managementState"
    '$top'    = "999"
}

$intuneDevices = Get-AllGraphPages -Uri $intuneUri -QueryParams $intuneQueryParams
Write-Host "    Found $($intuneDevices.Count) Windows devices in Intune" -ForegroundColor Gray

# Build a HashSet of Intune device IDs (azureADDeviceId maps to Entra deviceId)
$intuneDeviceIds = [System.Collections.Generic.HashSet[string]]::new(
    [System.StringComparer]::OrdinalIgnoreCase
)
foreach ($d in $intuneDevices) {
    if ($d.azureADDeviceId) { [void]$intuneDeviceIds.Add($d.azureADDeviceId) }
}

#endregion

#region ?? Compare & Build Report ?????????????????????????????????????????????

Write-Host "`n[+] Comparing..." -ForegroundColor Cyan

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

foreach ($device in $entraDevices) {
    if (-not $intuneDeviceIds.Contains($device.deviceId)) {

        # Map trustType to a friendly label
        $joinTypeFriendly = switch ($device.trustType) {
            "AzureAd"  { "Azure AD Joined (AADJ)" }
            "ServerAd" { "Hybrid Azure AD Joined (HAADJ)" }
            default    { if ($device.trustType) { $device.trustType } else { "Unknown" } }
        }

        $lastSignIn = if ($device.approximateLastSignInDateTime) {
            [datetime]$device.approximateLastSignInDateTime
        } else { $null }

        $daysSinceSignIn = if ($lastSignIn) {
            [math]::Round(((Get-Date) - $lastSignIn).TotalDays, 0)
        } else { "Unknown" }

        $report.Add([PSCustomObject]@{
            DisplayName          = $device.displayName
            DeviceId             = $device.deviceId
            EntraObjectId        = $device.id
            OSVersion            = $device.operatingSystemVersion
            JoinType             = $joinTypeFriendly
            AccountEnabled       = $device.accountEnabled
            IsCompliant          = $device.isCompliant
            IsManaged            = $device.isManaged
            LastSignIn           = if ($lastSignIn) { $lastSignIn.ToString("yyyy-MM-dd") } else { "Never/Unknown" }
            DaysSinceLastSignIn  = $daysSinceSignIn
        })
    }
}

Write-Host "    Devices in Entra NOT in Intune: $($report.Count)" -ForegroundColor Yellow

#endregion

#region ?? Output ??????????????????????????????????????????????????????????????

if ($report.Count -eq 0) {
    Write-Host "`n[?] All Entra Windows devices are enrolled in Intune. Nothing to report." -ForegroundColor Green
}
else {
    # Summary breakdown
    Write-Host "`n?? Summary ???????????????????????????????????????????????" -ForegroundColor Cyan
    $report | Group-Object JoinType | Sort-Object Count -Descending | ForEach-Object {
        Write-Host "   $($_.Name): $($_.Count) device(s)" -ForegroundColor White
    }

    $staleCount = ($report | Where-Object { $_.DaysSinceLastSignIn -ne "Unknown" -and [int]$_.DaysSinceLastSignIn -gt 90 }).Count
    if ($staleCount -gt 0) {
        Write-Host "   Potentially stale (>90 days inactive): $staleCount" -ForegroundColor DarkYellow
    }
    Write-Host "??????????????????????????????????????????????????????????`n" -ForegroundColor Cyan

    if ($ExportCsv) {
        $report | Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding UTF8
        Write-Host "[?] Report exported to: $ExportCsv" -ForegroundColor Green
    }
    else {
        $report | Sort-Object DaysSinceLastSignIn -Descending |
            Format-Table -AutoSize DisplayName, JoinType, OSVersion, AccountEnabled, IsCompliant, LastSignIn, DaysSinceLastSignIn
    }
}

#endregion

# Disconnect cleanly (optional — comment out if running in a pipeline)
Disconnect-MgGraph | Out-Null
Write-Host "`n[+] Done. Graph session disconnected." -ForegroundColor Cyan
1 Star2 Stars3 Stars4 Stars5 Stars (No Ratings Yet)
Loading...