Public/Get-StaleEntraUsers.ps1

function Get-StaleEntraUsers {
    <#
    .SYNOPSIS
        Finds Entra ID users who have not signed in for a given number of days.

    .DESCRIPTION
        Uses the signInActivity property from Microsoft Graph to identify users whose
        last interactive and non-interactive sign-ins are both older than -DaysInactive.
        Results are exported to CSV. Requires Microsoft Entra ID P1 or P2.

    .PARAMETER DaysInactive
        Number of days without sign-in to treat as stale. Default 90.

    .PARAMETER IncludeNeverSignedIn
        Also include accounts with no recorded sign-in (created before the threshold).

    .PARAMETER IncludeGuests
        Include guest (B2B) accounts. By default only Member accounts are evaluated.

    .PARAMETER OutputPath
        Full path of the CSV file. Defaults to .\StaleEntraUsers_<date>.csv

    .EXAMPLE
        Get-StaleEntraUsers -DaysInactive 60 -IncludeNeverSignedIn

    .NOTES
        Author : Anthony Buhnerkemper
        Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Users
        Scopes : User.Read.All, AuditLog.Read.All
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateRange(1, 3650)]
        [int]$DaysInactive = 90,

        [Parameter()]
        [switch]$IncludeNeverSignedIn,

        [Parameter()]
        [switch]$IncludeGuests,

        [Parameter()]
        [string]$OutputPath = (Join-Path (Get-Location).Path ("StaleEntraUsers_{0}.csv" -f (Get-Date -Format 'yyyyMMdd')))
    )

    # Runtime dependency (not enforced at import): Microsoft.Graph.Authentication, Microsoft.Graph.Users

    $ErrorActionPreference = 'Stop'

    # signInActivity requires AuditLog.Read.All in addition to User.Read.All
    Connect-MgGraph -Scopes 'User.Read.All', 'AuditLog.Read.All' -NoWelcome

    $cutoff = (Get-Date).ToUniversalTime().AddDays(-$DaysInactive)
    $props  = 'id,displayName,userPrincipalName,userType,accountEnabled,createdDateTime,signInActivity'

    Write-Verbose "Retrieving users (cutoff: $cutoff UTC)..."
    $users = Get-MgUser -All -Property $props

    if (-not $IncludeGuests) {
        $users = $users | Where-Object { $_.UserType -eq 'Member' }
    }

    $results = foreach ($user in $users) {
        $interactive    = $user.SignInActivity.LastSignInDateTime
        $nonInteractive = $user.SignInActivity.LastNonInteractiveSignInDateTime

        # Most recent of the two sign-in timestamps (either may be null)
        $last = @($interactive, $nonInteractive) | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1

        $isStale = $false
        if ($last) {
            $isStale = $last -lt $cutoff
        }
        elseif ($IncludeNeverSignedIn -and $user.CreatedDateTime -lt $cutoff) {
            $isStale = $true
        }

        if ($isStale) {
            [pscustomobject]@{
                DisplayName          = $user.DisplayName
                UserPrincipalName    = $user.UserPrincipalName
                UserType             = $user.UserType
                AccountEnabled       = $user.AccountEnabled
                CreatedDateTime      = $user.CreatedDateTime
                LastInteractive      = $interactive
                LastNonInteractive   = $nonInteractive
                DaysSinceLastSignIn  = if ($last) { [int]((Get-Date).ToUniversalTime() - $last).TotalDays } else { 'Never' }
            }
        }
    }

    $results | Sort-Object UserPrincipalName | Export-Csv -Path $OutputPath -NoTypeInformation
    Write-Host "Found $(@($results).Count) stale user(s). Report: $OutputPath"

    Disconnect-MgGraph | Out-Null
}