Synopsis

Identifying inactive Azure AD B2B (Guest) users.

Inactive Azure AD B2B Users

B2B (Guest) accounts are created whenever an invitation is made.

These accounts remain in the tenant even if the user does not respond to the invitation or does not sign-in for an extended period of time.

PowerShell Script

The following script gathers all Guest accounts and returns those:

  • Considered active (according to the thresholds)
  • Exceeding the nominated sign-in activity threshold
  • Exceeding the nominated invitation duration threshold
  • Exceeding both thresholds; and
  • The total number of guests.

Note the thresholds for script’s sign-in inactivity and invitation duration are customisable.

Sample Output

Active                                  : 100
Exceeding sign-in activity threshold    : 200
Exceeding invitation duration threshold : 300
Exceeding both thresholds               : 400
--------------------------------------------------
Total Guests                            : 1000

Code

This snippet depends upon the Azure AD or AzureADPreview PowerShell modules.

using namespace System.Collections.Generic
using namespace Microsoft.Open.Azure.AD.CommonLibrary
$ErrorActionPreference = "Stop"

# Days without sign-in activity
$SignInActivityThresholdInDays = 90
# Days awaiting invitation acceptance
$InvitationThresholdInDays = 30

$SignInActivityThresholdDateTime = (Get-Date).AddDays(-$SignInActivityThresholdInDays)
$InvitationThresholdDateime = (Get-Date).AddDays(-$InvitationThresholdInDays)

try {
    Get-AzureADTenantDetail | Out-Null
}
catch [Microsoft.Open.Azure.AD.CommonLibrary.AadNeedAuthenticationException] {
    Connect-AzureAD
}

# This is for calling Graph Directly using Azure AD PS module token - it is here for completeness.
function Send-MSGraphGetRequest {
    [CmdletBinding()]
    [OutputType([List[PSCustomObject]])]
    param
    (
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0)]
        [String] $GraphUri
    )
    process {
        Get-AzureADMSAdministrativeUnit -Top 1 | Out-Null # Just to ensure we have a graph.microsoft.com token (not just a graph.windows.net one)
        $graphToken = [AzureSession]::TokenCache.ReadItems() | `
            Where-Object { $_.Resource -eq 'https://graph.microsoft.com' } | Select-Object AccessToken
        if ($null -eq $graphToken) {
            throw "The Graph Access token is not available!"
        }
        $graphRequest = @{
            Uri     = $GraphUri
            Headers = @{
                'Authorization' = "Bearer $($graphToken.AccessToken)" 
            }
            Method  = 'GET'
        }
        $resultList = [List[PSCustomObject]]::new()
        try {
            $response = Invoke-RestMethod @graphRequest
            if ($response.Value.Count -eq 0) {
                $resultList.Add($response)
            }
            else {
                $response.Value | ForEach-Object {
                    $resultList.Add($_)
                }
                while ($null -ne $response.'@odata.nextLink') {
                    $graphRequest.Uri = $response.'@odata.nextLink' 
                    $response = Invoke-RestMethod @graphRequest
                    if ($response.Value.Count -eq 0) {
                        $resultList.Add($response)
                    }
                    else {
                        $response.Value | ForEach-Object {
                            $resultList.Add($_)
                        }   
                    }
                }
            }
        }
        catch {
            $respStream = $_.Exception.Response.GetResponseStream()
            $reader = New-Object System.IO.StreamReader($respStream)
            $errorBody = $reader.ReadToEnd() | ConvertFrom-Json | Select-Object -ExpandProperty error
            $errorCode = $errorBody | Select-Object -ExpandProperty code
            $errorMessage = $errorBody | Select-Object -ExpandProperty message
            throw "$errorCode`n$errorMessage"
        }
        
        $resultList
    }
}

Write-Host -ForegroundColor Yellow "Getting Guest Users..."

# Filter for Guest accounts and their relevant attributes
$graphUri = "
    https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'
    &`$select=displayName,userPrincipalName,signInActivity,createdDateTime,creationType,
    externalUserState,externalUserStateChangeDateTime
    "
$guestUsers = Send-MSGraphGetRequest $graphUri

class GuestUser {
    [string]$Id
    [string]$DisplayName
    [string]$UserPrincipalName
    [Nullable[DateTime]]$CreatedDateTime
    [string]$CreationType
    [string]$ExternalUserState
    [Nullable[DateTime]]$ExternalUserStateChangeDateTime
    [Nullable[DateTime]]$LastSignInDateTime
    [string]$LastSignInRequestId
    [Nullable[DateTime]]$LastNonInteractiveSignInDateTime
    [string]$LastNonInteractiveSignInRequestId
    [Nullable[DateTime]]$LastSignInActivityDateTime
    [bool]$IsExceedingSignInActivityThreshold
    [bool]$IsExceedingInvitationThreshold
}

$guestUserList = [List[Object]]::new()

$guestUsers | ForEach-Object {
    $guestUser = [GuestUser]::new()
    $guestUser.Id = $_.id
    $guestUser.DisplayName = $_.displayName
    $guestUser.UserPrincipalName = $_.userPrincipalName
    $guestUser.CreatedDateTime = [DateTime]::ParseExact($_.createdDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    $guestUser.CreationType = $_.creationType
    $guestUser.ExternalUserState = $_.externalUserState
    if ($_.externalUserStateChangeDateTime -ne $null -and $_.externalUserStateChangeDateTime -ne '0001-01-01T00:00:00Z') { 
        $guestUser.ExternalUserStateChangeDateTime = [DateTime]::ParseExact($_.externalUserStateChangeDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }
    if ($_.signInActivity.lastSignInDateTime -ne $null -and $_.signInActivity.lastSignInDateTime -ne '0001-01-01T00:00:00Z') {
        $guestUser.LastSignInDateTime = [DateTime]::ParseExact($_.signInActivity.lastSignInDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }  
    $guestUser.LastSignInRequestId = $_.signInActivity.lastSignInRequestId
    if ($_.signInActivity.lastNonInteractiveSignInDateTime -ne $null -and $_.signInActivity.lastNonInteractiveSignInDateTime -ne '0001-01-01T00:00:00Z') {
        $guestUser.LastNonInteractiveSignInDateTime = [DateTime]::ParseExact($_.signInActivity.lastNonInteractiveSignInDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }  
    $guestUser.LastNonInteractiveSignInRequestId = $_.signInActivity.lastNonInteractiveSignInRequestId

    # Get the most recent sign-in activity (either interactive or non-interactive)
    if ($guestUser.LastNonInteractiveSignInDateTime -gt $guestUser.LastSignInDateTime) {
        $guestUser.LastSignInActivityDateTime = $guestUser.LastNonInteractiveSignInDateTime
    }
    else {
        $guestUser.LastSignInActivityDateTime = $guestUser.LastSignInDateTime
    }

    # If at least the Sign-In Activity threshold has elapsed
    # AND the user has not logged in within that threshold
    if ($guestUser.CreatedDateTime -lt $SignInActivityThresholdDateTime -and $SignInActivityThresholdDateTime -gt $guestUser.LastSignInActivityDateTime ) {
        $guestUser.IsExceedingSignInActivityThreshold = $true
    }

    # If the account is pending acceptance 
    # AND the invitation threshold has elapsed
    if ($guestUser.ExternalUserState -eq 'PendingAcceptance' -and $InvitationThresholdDateime -gt $guestUser.CreatedDateTime) {
        $guestUser.IsExceedingInvitationThreshold = $true
    }

    $guestUserList.Add($guestUser)
}

# A simple Where-Object clause would also suffice in the below.
# However, there is a significant performance benefit in using Linq when
# enumerating LOTS of objects.

$guestsExceedingSignInActivityThreshold = [Linq.Enumerable]::ToList([Linq.Enumerable]::Where($guestUserList, `
            [Func[Object, bool]] { param($x); return ($x.IsExceedingSignInActivityThreshold -eq $true) `
                -and ($x.IsExceedingInvitationThreshold -eq $false) }
    ))

$guestsExceedingInvitationThreshold = [Linq.Enumerable]::ToList([Linq.Enumerable]::Where($guestUserList, `
            [Func[Object, bool]] { param($x); return ($x.IsExceedingSignInActivityThreshold -eq $false) `
                -and ($x.IsExceedingInvitationThreshold -eq $true) }
    ))

$guestsExceedingBothThresholds = [Linq.Enumerable]::ToList([Linq.Enumerable]::Where($guestUserList, `
            [Func[Object, bool]] { param($x); return ($x.IsExceedingSignInActivityThreshold -eq $true) `
                -and ($x.IsExceedingInvitationThreshold -eq $true) }
    ))

$guestsActive = [Linq.Enumerable]::ToList([Linq.Enumerable]::Where($guestUserList, `
            [Func[Object, bool]] { param($x); return ($x.IsExceedingSignInActivityThreshold -eq $false) `
                -and ($x.IsExceedingInvitationThreshold -eq $false) }
    ))

Write-Host -ForegroundColor Yellow "`nActive                                  : $($guestsActive.Count)"
Write-Host -ForegroundColor Yellow "Exceeding sign-in activity threshold    : $($guestsExceedingSignInActivityThreshold.Count)"
Write-Host -ForegroundColor Yellow "Exceeding invitation duration threshold : $($guestsExceedingInvitationThreshold.Count)"
Write-Host -ForegroundColor Yellow "Exceeding both thresholds               : $($guestsExceedingBothThresholds.Count)"
Write-Host -ForegroundColor Yellow "--------------------------------------------------"
Write-Host -ForegroundColor Yellow "Total Guests                            : $($guestUserList.Count)`n "

# $guestUserList | Export-Csv -NoTypeInformation -Path 'YourFileName.csv'

# $guestsExceedingSignInActivityThreshold
# $guestsExceedingInvitationThreshold
# $guestsExceedingBothThresholds
# $guestsActive