Public/Get-IODormantApps.ps1

function Get-IODormantApps {
    <#
    .SYNOPSIS
        Finds app registrations with no recent sign-in activity.
    .EXAMPLE
        Get-IODormantApps -InactiveDays 90
    .EXAMPLE
        Get-IODormantApps -InactiveDays 180 -ToCsv "dormant-apps.csv"
    #>

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

        [string]$ToCsv
    )

    $cmdName = $MyInvocation.MyCommand.Name
    Write-IOLog "Scanning for apps with no sign-in activity in $InactiveDays+ days..." -Level Info -Component $cmdName

    $cutoff  = [datetime]::UtcNow.AddDays(-$InactiveDays)
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    # Service principals have signInActivity (requires Entra ID P1+)
    $sps = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals?`$filter=servicePrincipalType eq 'Application'&`$select=id,displayName,appId,createdDateTime,signInActivity"

    foreach ($sp in $sps) {
        $lastActivity     = $null
        $lastDelegated    = $null
        $lastAppOnly      = $null
        $neverUsed        = $true

        $sia = if ($sp.PSObject.Properties['signInActivity']) { $sp.signInActivity } else { $null }
        if ($sia) {
            if ($sia.lastSignInDateTime) {
                $lastDelegated = [datetime]::Parse($sia.lastSignInDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal)
                $neverUsed = $false
            }
            if ($sia.lastNonInteractiveSignInDateTime) {
                $lastAppActivity = [datetime]::Parse($sia.lastNonInteractiveSignInDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal)
                if (-not $lastDelegated -or $lastAppActivity -gt $lastDelegated) {
                    $lastActivity = $lastAppActivity
                }
                else {
                    $lastActivity = $lastDelegated
                }
                $neverUsed = $false
            }
            else {
                $lastActivity = $lastDelegated
            }
        }

        $isDormant = $neverUsed -or ($lastActivity -and $lastActivity -lt $cutoff)

        if ($isDormant) {
            $created = if ($sp.createdDateTime) { [datetime]::Parse($sp.createdDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToString('yyyy-MM-dd') } else { 'Unknown' }

            $results.Add([PSCustomObject]@{
                ApplicationName   = $sp.displayName
                ApplicationId     = $sp.appId
                ObjectId          = $sp.id
                CreatedDate       = $created
                LastActivity      = if ($lastActivity) { $lastActivity.ToString('yyyy-MM-dd') } else { 'Never' }
                InactiveDays      = if ($lastActivity) { [math]::Floor(([datetime]::UtcNow - $lastActivity).TotalDays) } else { 'N/A' }
                Status            = if ($neverUsed) { 'NEVER_USED' } else { 'DORMANT' }
            })
        }
    }

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