diff --git a/Find Unused Microsoft 365 Licenses/FindUnusedM365Licenses.ps1 b/Find Unused Microsoft 365 Licenses/FindUnusedM365Licenses.ps1 new file mode 100644 index 0000000..d1edfe8 --- /dev/null +++ b/Find Unused Microsoft 365 Licenses/FindUnusedM365Licenses.ps1 @@ -0,0 +1,311 @@ +<# +============================================================================================= +Name: Find Unused Licenses in Microsoft 365 Using PowerShell +Description: This script exports a report of unused Microsoft 365 licenses by identifying inactive users through their last successful sign-in activity. +Version: 1.0 +website: o365reports.com + +Script Highlights: +~~~~~~~~~~~~~~~~~ + +1. Retrieves unused licenses based on users' last successful sign-in time. +2. Lists licenses assigned to sign-in disabled users. +3. Identifies licenses assigned to never logged-in user accounts. +4. Filters unused licenses by type, such as paid, free, or trial. +5. Fetches inactive licenses assigned to external accounts. +6. Identifies unused specific licenses, such as Power BI Pro. +7. Automatically verifies and installs the Microsoft Graph PowerShell Module (if not already installed) upon your confirmation. +8. Supports Certificate-based Authentication (CBA) too. +9. The script is scheduler-friendly. + + +For detailed script execution: https://o365reports.com/2025/09/02/find-unused-licenses-in-microsoft-365-using-powershell/ + +============================================================================================ +#> +Param( + [int]$InactiveDays, + [Nullable[int]]$LicenseCount = $null, + [string]$ImportCSVPath, + [switch]$ReturnNeverLoggedInUser, + [ValidateSet("InternalUser", "ExternalUser")] + [string]$UserType, + [ValidateSet("EnabledUser", "DisabledUser")] + [string]$UserState, + [ValidateSet( "Paid", "Trial", "Free")] + [string]$LicenseType, + [string[]]$LicensePlanList, + [switch]$CreateSession, + [string]$TenantId, + [string]$ClientId, + [string]$CertificateThumbprint +) + +if (-not $InactiveDays -and -not $ReturnNeverLoggedInUser) { + do { + $InactiveDays = Read-Host "`nEnter the number of inactive days" + if ($InactiveDays -notmatch '^\d+$') { + Write-Host "Please enter a valid number." -ForegroundColor Red + } + } while ($InactiveDays -notmatch '^\d+$') + $InactiveDays = [int]$InactiveDays +} + +Function Connect_MgGraph { + #Check for module installatiion + $Module = Get-Module -Name microsoft.graph -ListAvailable + if($Module.Count -eq 0){ + Write-Host "Microsoft Graph PowerShell SDK is not available" -ForegroundColor yellow + $Confirm = Read-Host Are you sure want to install the module? [Y]Yes [N]No + if($Confirm -match [Yy]){ + Write-Host "Installing Microsoft Graph PowerShell module..." + Install-Module Microsoft.Graph -Repository PSGallery -Scope CurrentUser -AllowClobber -Force + } + else{ + Write-Host "Microsoft Graph PowerShell module is required to run this script. Please install module using Install-Module Microsoft.Graph cmdlet." + Exit + } + } + #Disconnect Existing MgGraph session + if ($CreateSession.IsPresent) { + Disconnect-MgGraph | Out-Null + } + Write-Host "`nConnecting to Microsoft Graph..." + if(($TenantId -ne "") -and ($ClientId -ne "") -and ($CertificateThumbprint -ne "")) + { + Connect-MgGraph -TenantId $TenantId -AppId $ClientId -CertificateThumbprint $CertificateThumbprint -NoWelcome + } + else{ + Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All", "Organization.Read.All", "AuditLog.Read.All" -NoWelcome + } +} + +Connect_MgGraph + +$Location = Get-Location +$ExportCSV = "$Location\UnusedM365LicensesByLastSignIn_$((Get-Date -format yyyy-MMM-dd-ddd` hh-mm-ss` tt).ToString()).csv" +$ExportResult = "" +$Count = 0 +$PrintedUsers = 0 + +if( $ImportCSVPath -ne "" -and !(Test-Path $ImportCSVPath)){ + Write-Host "File not found: $($ImportCSVPath)" -ForegroundColor Red + Exit +} + +#Get Licenses +$FriendlyNameHash = @{} +Import-Csv -Path ".\LicenseFriendlyName.csv" -ErrorAction Stop | ForEach-Object{ + $FriendlyNameHash[$_.String_Id] = $_.Product_Display_Name +} +$LicenseMap = @{} +if($ImportCSVPath -ne ""){ + $LicenseNames = Import-Csv -Header "SkuPartNumber" -path $ImportCSVPath | ForEach-Object { $_ } + foreach ($License in $LicenseNames) { + $SkuPartNumber = $License.SkuPartNumber + if ($FriendlyNameHash.ContainsKey($SkuPartNumber)) { + $LicenseMap[$SkuPartNumber] = $FriendlyNameHash[$SkuPartNumber] + } + } +} +else{ + $LicenseMap = $FriendlyNameHash +} + +#Get License type details +$LicenseSkuIdMap = @{} +$LifeCycleDateInfo = Get-MgDirectorySubscription -All +$LifeCycleDateInfo | ForEach-Object{ + $LicenseSkuIdMap[$_.SkuId] = $_.SkuPartNumber +} + +#Retrieve Users +Write-Host "`nRetrieving inactive users with assigned licenses..." +$RequiredProperties = @('UserPrincipalName','DisplayName','SignInActivity','UserType','CreatedDateTime','AccountEnabled', 'LicenseAssignmentStates', 'Department','JobTitle') +Get-MgUser -All -Property $RequiredProperties | select $RequiredProperties | ForEach-Object{ + $Count++ + $UPN = $_.UserPrincipalName + Write-Progress -Activity " Processing user: $($count) $($UPN)" + $DisplayName = $_.DisplayName + $UserCategory = $_.UserType + $LastSuccessfulSigninDate = $_.SignInActivity.LastSuccessfulSignInDateTime + $LastInteractiveSignIn = $_.SignInActivity.LastSignInDateTime + $LastNon_InterativeSignIn = $_.SignInActivity.LastNonInteractiveSignInDateTime + $CreatedDate = $_.CreatedDateTime + $AccountEnabled = $_.AccountEnabled + $Department = if($_.Department -eq $null) {" -"} else{$_.Department} + $JobTitle = if($_.JobTitle -eq $null) {" -"} else{$_.JobTitle} + $TotalLicenses = 0 + $LicenseStates = $_.LicenseAssignmentStates + $Print = 1 + + #Calculate Inactive users days + if($LastSuccessfulSigninDate -eq $null){ + $LastSuccessfulSigninDate = "Never Logged In" + $InactiveUserDays = "-" + }else{ + $InactiveUserDays = (New-TimeSpan -Start $LastSuccessfulSigninDate).Days + } + + if($LastInteractiveSignIn -eq $null){ + $LastInteractiveSignIn = "Never Logged In" + } + + if($LastNon_InterativeSignIn -eq $null){ + $LastNon_InterativeSignIn = "Never Logged In" + } + + #Get account status + if($AccountEnabled -eq $true){ + $AccountStats = "Enabled" + } + else{ + $AccountStats = "Disabled" + } + + #Inactive days based on last successful signins filter + if ($ReturnNeverLoggedInUser.IsPresent -and ($LastInteractiveSignIn -ne "Never Logged In" -or $LastNon_InterativeSignIn -ne "Never Logged In")) { + $Print = 0 + } + elseif (-not $ReturnNeverLoggedInUser.IsPresent) { + if ($LastSuccessfulSigninDate -eq "Never Logged In") { + $Print = 0 + } + # Filter by inactive days + elseif (($InactiveDays -ne 0) -and ($InactiveDays -ge $InactiveUserDays)) { + $Print = 0 + } + } + + #Filter for internal users only + if(($UserType -eq "InternalUser") -and ($UserCategory -eq "Guest")){ + $Print = 0 + } + + #Filter for external users only + if(($UserType -eq "ExternalUser") -and ($UserCategory -ne "Guest")){ + $Print = 0 + } + + #Signin allowed Users + if(($UserState -eq "EnabledUser") -and ($AccountStats -eq 'Disabled')){ + $Print = 0 + } + + #Signin disabled Users + if(($UserState -eq "DisabledUser") -and ($AccountStats -eq 'Enabled')){ + $Print = 0 + } + + #Licensed users only + $LicensePartNumbers = @() + $Groups = @() + $GroupLicense = @() + $DirectLicense = @() + if($LicenseStates.Count -ne 0){ + foreach($State in $LicenseStates){ + if($State){ + $Flag = 1 + $LicensePartNumber = "" + $LicenseName = "" + if($LicenseSkuIdMap.ContainsKey($State.SkuId)){ + $LicensePartNumber = $LicenseSkuIdMap[$State.SkuId] + $LicenseName = $LicenseMap[$LicensePartNumber] + $MoreSkuDetails = $LifeCycleDateInfo | Where-Object {$_.skuId -eq $State.SkuId} + $ExpiryDate = $MoreSkuDetails.nextLifeCycleDateTime + #Filter SkuPartNumber + if($LicensePlanList){ + if($LicensePlanList -notcontains $LicensePartNumber){ + $Flag = 0 + } + } + + #Filter Free Licensed User + if($LicenseType -eq "Free"){ + if($ExpiryDate -ne $null){ + $Flag = 0 + } + } + + #Filter Trial Licensed User + if($LicenseType -eq "Trial"){ + if(-not $MoreSkuDetails.isTrial){ + $Flag = 0 + } + } + + #Filter Paid Licensed User + if($LicenseType -eq "Paid"){ + if(($ExpiryDate -eq $null) -or ($MoreSkuDetails.isTrial)){ + $Flag = 0 + } + } + + if($Flag -eq 1){ + if($LicenseName){ + $LicensePartNumbers += $LicensePartNumber + if($State.AssignedByGroup -ne $null){ + $Groups += (Get-MgGroup -GroupId $State.AssignedByGroup -ErrorAction SilentlyContinue).DisplayName + $GroupLicense += $LicenseName + } + else{ + $DirectLicense += $LicenseName + } + } + } + } + } + } + + if(($DirectLicense.Count -ne 0) -or ($GroupLicense.Count -ne 0)) { + $LicensePlans = $LicensePartNumbers -join ", " + $TotalLicenses = $DirectLicense.Count + $GroupLicense.Count + $GroupNames = if($Groups.Count -ne 0) {$Groups -join ","} else {"- "} + $GroupLicenseNames = if($GroupLicense.Count -ne 0) { $GroupLicense -join ","} else {"- "} + $DirectLicenseNames = if($DirectLicense.Count -ne 0) { $DirectLicense -join ","} else{"- "} + } + else{ + $Print = 0 + } + } + else{ + $Print = 0; + } + + #LicenseCount above users only + if($LicenseCount -ne $null){ + if($LicenseCount -gt $TotalLicenses){ + $Print = 0 + } + } + + #Export users to output file + if($Print -eq 1 ){ + $PrintedUsers++ + $ExportResult = [PSCustomObject]@{ 'Display Name' = $DisplayName; 'UPN' = $UPN; 'User Type' = $UserCategory; 'Account Status' = $AccountStats; 'License Plans' = $LicensePlans; 'Directly Assigned Licenses' = $DirectLicenseNames; 'Licenses Assigned via Groups' =$GroupLicenseNames; 'Assigned via (Group Names)' = $GroupNames; 'License Count' = $TotalLicenses;'Last Successful SignIn Date '= $LastSuccessfulSigninDate; 'Inactive Days' = $InactiveUserDays; 'Last Interactive SignIn Date' = $LastInteractiveSignIn; 'Last Non-Interactive SignIn Date' = $LastNon_InterativeSignIn;'Creation Date' = $CreatedDate; 'Department' = $Department;'Job Title' = $JobTitle;} + $ExportResult | Export-Csv -Path $ExportCSV -NoTypeInformation -Append + } +} + +Disconnect-MgGraph | Out-Null + +Write-Host `nScript executed successfully. +Write-Host `n~~ Script prepared by AdminDroid Community ~~`n -ForegroundColor Green +Write-Host "~~ Check out " -NoNewline -ForegroundColor Green; Write-Host "admindroid.com" -ForegroundColor Yellow -NoNewline; Write-Host " to access 3,000+ reports and 450+ management actions across your Microsoft 365 environment. ~~" -ForegroundColor Green `n`n + +#Open output file after execution +if(((Test-Path -Path $ExportCSV) -eq "True")) +{ + Write-Host "Exported report has $($PrintedUsers) user(s)." + $Prompt = New-Object -ComObject wscript.shell + $UserInput = $Prompt.popup("Do you want to open output file?",` 0,"Open Output File",4) + if ($UserInput -eq 6) + { + Invoke-Item "$ExportCSV" + } + Write-Host "The generated report is available in:" -NoNewline -ForegroundColor Yellow; Write-Host "$($ExportCSV)" +} +else +{ + Write-Host "No user found" -ForegroundColor Red +} \ No newline at end of file