Remove inactive devices in Intune automatically using Microsoft Graph API and Powershell (and a scheduled task)

Introduction

*Updated July 23 2018: Minor changes to the script doing the deletion*

Just like we do in Configuration Manager, Active Directory, Exchange and anywhere else (where possible), It’s a good idea to keep things clean (at least I think so). Clean in terms of removing inactive computers, objects, mailboxes and so forth. This brings me to Microsoft Intune and how we can leverage Microsoft Graph API through Powershell to automatically remove inactive devices, and doing so on a schedule through a scheduled task. Curious? Read on 🙂

Example of devices that haven’t checked in for 30 days

Requirements

  • First off. Following script is put together from the the official Microsoft Intune script samples on GitHub.
  • Running anything automated (or manually for that matter) requires an account with proper permissions. A service account with delegated permissions. I’m using a dedicated Global Admin (shared for running all sorts of things automated against Intune/O365/Azure)
  • The script itself requires the Azure AD Powershell module. Run Install-Module AzureAD from an elevated Powershell prompt on the computer running the script.
  • If you really want to automate this to run unattended, you also need some sort of method for authentication. No fun in manually entering credentials each time the script runs. In below script, following Powershell snippet was used to create a credentials.txt file used in combination with my Service Account:
    • IMPORTANT: The credentials file created with below Powershell *HAS* to be run as the account intended to run the script automatically. In this case the ON-PREMISE account we will create later during this guide. (I will remind you again)
Read-Host -Prompt "Enter the password for your service account" -AsSecureString | ConvertFrom-SecureString | Out-File "<Enter File Path>\credentials.txt"

How to

Again, this is all done with inspiration from the official Microsoft Intune script samples on GitHub. However, I have made several changes to be able to run it fully automated. The original samples required confirmation prior to any action, as well as offered the opportunity to export data to a .csv. Also, I added a log function, which combined with audit logs in Intune will reveal exactly when the script was run locally and what actions was made in Intune.

So there’s that – run this with caution. It will potentially just delete any inactive device your have in your tenant.

The Script

Copy/paste below into your favorite ISE and save it as Remove-InactiveIntuneDevices.ps1. Search and find all comments starting with EDIT. There are 4 places where you should put your own details:

  1. Service account details
  2. Log file location
  3. How many days of inactivity
  4. Remove the out-commenting on the actual deletion. Keep it for testing
function Get-AuthToken {

<#
.SYNOPSIS
This function is used to authenticate with the Graph API REST interface
.DESCRIPTION
The function authenticate with the Graph API Interface with the tenant name
.EXAMPLE
Get-AuthToken
Authenticates you with the Graph API interface
.NOTES
NAME: Get-AuthToken
#>

[cmdletbinding()]

param
(
    [Parameter(Mandatory=$true)]
    $User,
    $Password
)

$userUpn = New-Object "System.Net.Mail.MailAddress" -ArgumentList $User

$tenant = $userUpn.Host

Write-Host "Checking for AzureAD module..."

    $AadModule = Get-Module -Name "AzureAD" -ListAvailable

    if ($AadModule -eq $null) {

        Write-Host "AzureAD PowerShell module not found, looking for AzureADPreview"
        $AadModule = Get-Module -Name "AzureADPreview" -ListAvailable

    }

    if ($AadModule -eq $null) {
        write-host
        write-host "AzureAD Powershell module not installed..." -f Red
        write-host "Install by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt" -f Yellow
        write-host "Script can't continue..." -f Red
        write-host
        exit
    }

# Getting path to ActiveDirectory Assemblies
# If the module count is greater than 1 find the latest version

    if($AadModule.count -gt 1){

        $Latest_Version = ($AadModule | select version | Sort-Object)[-1]

        $aadModule = $AadModule | ? { $_.version -eq $Latest_Version.version }

            # Checking if there are multiple versions of the same module found

            if($AadModule.count -gt 1){

            $aadModule = $AadModule | select -Unique

            }

        $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
        $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll"

    }

    else {

        $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
        $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll"

    }

[System.Reflection.Assembly]::LoadFrom($adal) | Out-Null

[System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null

$clientId = "d1ddf0e4-d672-4dae-b554-9d5bdfd93547"

$redirectUri = "urn:ietf:wg:oauth:2.0:oob"

$resourceAppIdURI = "https://graph.microsoft.com"

$authority = "https://login.microsoftonline.com/$Tenant"

    try {

    $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority

    # https://msdn.microsoft.com/en-us/library/azure/microsoft.identitymodel.clients.activedirectory.promptbehavior.aspx
    # Change the prompt behaviour to force credentials each time: Auto, Always, Never, RefreshSession

    $platformParameters = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.PlatformParameters" -ArgumentList "Auto"

    $userId = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.UserIdentifier" -ArgumentList ($User, "OptionalDisplayableId")

        if($Password -eq $null){

            $authResult = $authContext.AcquireTokenAsync($resourceAppIdURI,$clientId,$redirectUri,$platformParameters,$userId).Result

        }

        else {

            if(test-path "$Password"){

            $UserPassword = get-Content "$Password" | ConvertTo-SecureString

            $userCredentials = new-object Microsoft.IdentityModel.Clients.ActiveDirectory.UserPasswordCredential -ArgumentList $userUPN,$UserPassword

            $authResult = [Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContextIntegratedAuthExtensions]::AcquireTokenAsync($authContext, $resourceAppIdURI, $clientid, $userCredentials).Result;

            }

            else {

            Write-Host "Path to Password file" $Password "doesn't exist, please specify a valid path..." -ForegroundColor Red
            Write-Host "Script can't continue..." -ForegroundColor Red
            Write-Host
            break

            }

        }

        if($authResult.AccessToken){

        # Creating header for Authorization token

        $authHeader = @{
            'Content-Type'='application/json'
            'Authorization'="Bearer " + $authResult.AccessToken
            'ExpiresOn'=$authResult.ExpiresOn
            }

        return $authHeader

        }

        else {

        Write-Host
        Write-Host "Authorization Access Token is null, please re-run authentication..." -ForegroundColor Red
        Write-Host
        break

        }

    }

    catch {

    write-host $_.Exception.Message -f Red
    write-host $_.Exception.ItemName -f Red
    write-host
    break

    }

}

#region Authentication

# EDIT info with service account and credential.txt file location
$User = "SERVICEACCOUNT@YOURTENANT.onmicrosoft.com"
$Password = "C:\Scripts\credentials.txt"

write-host

# Checking if authToken exists before running authentication
if($global:authToken){

    # Setting DateTime to Universal time to work in all timezones
    $DateTime = (Get-Date).ToUniversalTime()

    # If the authToken exists checking when it expires
    $TokenExpires = ($authToken.ExpiresOn.datetime - $DateTime).Minutes

        if($TokenExpires -le 0){

        write-host "Authentication Token expired" $TokenExpires "minutes ago" -ForegroundColor Yellow
        write-host

            # Defining Azure AD tenant name, this is the name of your Azure Active Directory (do not use the verified domain name)

            if($User -eq $null -or $User -eq ""){

            $User = Read-Host -Prompt "Please specify your user principal name for Azure Authentication"
            Write-Host

            }

        $global:authToken = Get-AuthToken -User $User -Password "$Password"

        }
}

# Authentication doesn't exist, calling Get-AuthToken function

else {

    if($User -eq $null -or $User -eq ""){

    $User = Read-Host -Prompt "Please specify your user principal name for Azure Authentication"
    Write-Host

    }

# Getting the authorization token
$global:authToken = Get-AuthToken -User $User -Password "$Password"

}

#endregion

function Write-Log {
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [Alias("LogContent")]
        [string]$Message,
        
        # EDIT with your location for the local log file
        [Parameter(Mandatory=$false)]
        [Alias('LogPath')]
        [string]$Path='C:\Logs\Remove-InactiveIntuneDevices.log',
        
        [Parameter(Mandatory=$false)]
        [ValidateSet("Error","Warn","Info")]
        [string]$Level="Info",
        
        [Parameter(Mandatory=$false)]
        [switch]$NoClobber
    )

    Begin
    {
        # Set VerbosePreference to Continue so that verbose messages are displayed.
        $VerbosePreference = 'Continue'
    }
    Process
    {
        
        # If the file already exists and NoClobber was specified, do not write to the log.
        if ((Test-Path $Path) -AND $NoClobber) {
            Write-Error "Log file $Path already exists, and you specified NoClobber. Either delete the file or specify a different name."
            Return
            }

        # If attempting to write to a log file in a folder/path that doesn't exist create the file including the path.
        elseif (!(Test-Path $Path)) {
            Write-Verbose "Creating $Path."
            $NewLogFile = New-Item $Path -Force -ItemType File
            }

        else {
            # Nothing to see here yet.
            }

        # Format Date for our Log File
        $FormattedDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

        # Write message to error, warning, or verbose pipeline and specify $LevelText
        switch ($Level) {
            'Error' {
                Write-Error $Message
                $LevelText = 'ERROR:'
                }
            'Warn' {
                Write-Warning $Message
                $LevelText = 'WARNING:'
                }
            'Info' {
                Write-Verbose $Message
                $LevelText = 'INFO:'
                }
            }
        
        # Write log entry to $Path
        "$FormattedDate $LevelText $Message" | Out-File -FilePath $Path -Append
    }
    End
    {
    }
}

function Get-InactiveDevices(){

    [cmdletbinding()]
    
    param
    (
    )
    
    # Defining Variables
    $graphApiVersion = "beta"
    $Resource = "deviceManagement/managedDevices"
    # EDIT with how many days of inactivity is allowed
    $cutoffDate = (Get-Date).AddDays(-30).ToString("yyyy-MM-dd")
    
    $uri = ("https://graph.microsoft.com/{0}/{1}?filter=managementAgent eq 'mdm' or managementAgent eq 'easMDM' and lastSyncDateTime le {2}" -f $graphApiVersion, $Resource, $cutoffDate)
        
    try { 
               
        $devices = (Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get).Value
        return $devices
    }
    
        catch {
    
        $ex = $_.Exception
        $errorResponse = $ex.Response.GetResponseStream()
        $reader = New-Object System.IO.StreamReader($errorResponse)
        $reader.BaseStream.Position = 0
        $reader.DiscardBufferedData()
        $responseBody = $reader.ReadToEnd();
        Write-Host "Response content:`n$responseBody" -f Red
        Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
        write-host
        break
    
        }
    
    } 

function Remove-InactiveDevices(){

    [cmdletbinding()]

    param
    (
        [Parameter(Mandatory=$true)]$DeviceID
    )

    $graphApiVersion = "Beta"

    try {

        $Resource = "deviceManagement/managedDevices/$DeviceID/retire"
        $uri = "https://graph.microsoft.com/$graphApiVersion/$($resource)"
        Write-Host $uri
        Write-Verbose "Sending retire command to $DeviceID"
        Invoke-RestMethod -Uri $uri -Headers $authToken -Method Post

        Start-Sleep -Seconds 1

        $Resource = "deviceManagement/managedDevices('$DeviceID')"
        $uri = "https://graph.microsoft.com/$graphApiVersion/$($resource)"
        Write-Host $uri
        Write-Verbose "Sending delete command to $DeviceID"
        Invoke-RestMethod -Uri $uri -Headers $authToken -Method Delete

        }

    catch {

        $ex = $_.Exception
        $errorResponse = $ex.Response.GetResponseStream()
        $reader = New-Object System.IO.StreamReader($errorResponse)
        $reader.BaseStream.Position = 0
        $reader.DiscardBufferedData()
        $responseBody = $reader.ReadToEnd();
        Write-Host "Response content:`n$responseBody" -f Red
        Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
        write-host
        break

        }
}

$InactiveDevices = Get-InactiveDevices | Select-Object managedDeviceName,id,lastSyncDateTime

if ($InactiveDevices -ne $null){
    
    Write-Log "Beginning removal of inactive devices. Script is running on $($env:COMPUTERNAME) by $env:USERDOMAIN\$env:USERNAME"
    
    ForEach ($InactiveDevice in $InactiveDevices){

        #Remove-InactiveDevices -DeviceID $InactiveDevice.ID -ErrorAction SilentlyContinue
        Write-Log "Removing inactive device with ID: $($InactiveDevice.ID) and name: $($InactiveDevice.managedDevicename) with lastSyncDateTime: $($InactiveDevice.lastSyncDateTime)" -Level Info
                
    }
}

else {
    
    Write-Log "No inactive devices found" -Level Info
}

Create the Scheduled Task

  • Before creating the actual scheduled task, you need another service account (for the sake of doing this properly) dedicated to run the task. This will be an on-premise account without any special permissions. However, the account does need the Logon as a batch job user right assignment locally on the server running the task. Launch secpol.msc on the server and browse to the policy shown below and add the newly created account.
    • IMPORTANT: This is the account that the Powershell script creating the credentials.txt file has to be run as. If this is not the case, the authentication will fail as the credential.txt is unique for the user creating it.

  • Create the scheduled task. Below illustrates how I have set it up. Remember to select that the tasks runs whether a user is logged on or not and remember to select your newly created service account.

  • Mine runs on a weekly schedule. This is whatever you prefer and suits your environment.

  • Add an action of running powershell.exe with an argument of: C:\Scripts\Remove-InactiveIntuneDevices.ps1 -executionpolicy bypass (again, change to where you have the script stored)

  • With above in place, leave the rest as default. The task has been created and will be listed on the overview of all the tasks:

Finally

  • Test the script and task: Right click on the task and select Run. All actions will be logged locally to the log file in the path you have chosen. See below:

Please leave a comment if this was useful – thank you 🙂

References:

https://github.com/microsoftgraph/powershell-intune-samples
https://gallery.technet.microsoft.com/scriptcenter/Write-Log-PowerShell-999c32d0

3 thoughts on “Remove inactive devices in Intune automatically using Microsoft Graph API and Powershell (and a scheduled task)”

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.