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 🙂
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:
- Service account details
- Log file location
- How many days of inactivity
- 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
Amazing work pal. Wish I had Powershell skills like yours 🙂
we are using Intune MAM-WE ..is it applicable for MAM-WE
Hey,
Well client id d1ddf0e4-d672-4dae-b554-9d5bdfd93547 is getting outdated.
Did you make an updated version of the script?