Public/Entra/Object/Get-MgExternalUser.ps1

<#
    .SYNOPSIS
    Lists external users in Microsoft Entra ID based on their identities and invitation trace, not on userType.
 
    .DESCRIPTION
    Most scripts identify external users with userType -eq 'Guest'. This is unreliable:
    users onboarded through cross-tenant synchronization, or guests converted to members,
    keep userType 'Member' while still being backed by an account from another directory.
 
    Get-MgExternalUser returns every account that is external by any of these criteria:
    - it holds at least one identity with signInType 'federated' (the account authenticates elsewhere)
    - it was created through an invitation (creationType 'Invitation')
    - it carries an invitation state (externalUserState)
    - its userPrincipalName contains the #EXT# pattern
    - its userType is 'Guest'
 
    The invitation state is deliberately NOT part of the detection: a guest who never redeemed
    its invitation is reported like any other external account.
 
    Each result is classified through the AuthSource property, which tells whether the account
    relies on its own identity provider or exists only thanks to this tenant:
 
    - ExternalTenant : issuer ExternalAzureAD - account from another Microsoft Entra ID tenant (B2B / cross-tenant sync)
    - MicrosoftAccount : issuer MSA - personal Microsoft account
    - SocialIdP : google.com, facebook.com, ... - social identity provider
    - ExternalIdP : issuer is a third-party domain - direct federation (SAML / WS-Fed)
    - LocalOTP : issuer 'mail' or one of this tenant's own domains - email one-time passcode,
                          the account has NO external identity provider and depends entirely on this tenant
    - PendingRedemption : no federated identity yet - the invitation was never redeemed, so Entra ID
                          has not determined which identity provider the account will use
    - Other : anything else
 
    Accounts with their own identity provider are ExternalTenant, MicrosoftAccount, SocialIdP and
    ExternalIdP. Accounts depending on this tenant are LocalOTP. PendingRedemption is undetermined
    until the invitation is redeemed.
 
    Filtering is done client-side because Microsoft Graph does not support server-side
    filtering on identities/signInType.
 
    .PARAMETER Issuer
    (Optional) Restricts the results to one or more identity issuers (for example 'ExternalAzureAD').
    Because the filter applies to the federated identities, accounts classified PendingRedemption
    are excluded when this parameter is used: they do not carry an issuer yet.
    When omitted, every external account is returned whatever its issuer or invitation state.
 
    .PARAMETER UserType
    (Optional) Restricts the results to a given userType. Valid values: All, Member, Guest. Default is All.
    Use 'Member' to surface external accounts that are NOT flagged as guests - typically
    cross-tenant synchronized users, which a userType-based script would miss.
 
    .PARAMETER IncludeSignInActivity
    (Optional) Adds the last sign-in information (interactive, non-interactive and last successful sign-in)
    plus the number of days since the last interactive sign-in.
    Requires the AuditLog.Read.All permission and a Microsoft Entra ID P1 or P2 license.
    When the tenant does not meet those requirements, the report falls back to the standard
    properties and a warning is displayed.
 
    .PARAMETER ForceNewToken
    Switch parameter to force getting a new token from Microsoft Graph.
 
    .PARAMETER ExportToExcel
    (Optional) If specified, exports the results to an Excel file in the user's profile directory.
 
    .PARAMETER ExportPath
    (Optional) Output directory for the Excel export. Defaults to the user profile.
 
    .EXAMPLE
    Get-MgExternalUser
 
    Returns every external account of the tenant, whatever its issuer, userType and invitation state.
 
    .EXAMPLE
    Get-MgExternalUser | Where-Object { $_.AuthSource -eq 'LocalOTP' }
 
    Returns the external accounts that have no external identity provider and exist only through
    this tenant (email one-time passcode).
 
    .EXAMPLE
    Get-MgExternalUser | Where-Object { $_.AuthSource -eq 'PendingRedemption' }
 
    Returns the invited accounts that never redeemed their invitation.
 
    .EXAMPLE
    Get-MgExternalUser -Issuer 'ExternalAzureAD' -UserType Member
 
    Returns external users from another tenant that are NOT flagged as guests, which is the
    typical footprint of cross-tenant synchronization.
 
    .EXAMPLE
    Get-MgExternalUser -IncludeSignInActivity | Where-Object { $_.DaysSinceLastSignIn -gt 90 }
 
    Returns external users that have not signed in interactively for more than 90 days.
 
    .EXAMPLE
    Get-MgExternalUser -ExportToExcel
 
    Exports the external users report to an Excel file in the user's profile directory.
 
    .OUTPUTS
    System.Collections.Generic.List[PSCustomObject]
 
    .NOTES
    OUTPUT PROPERTIES
    Identity : DisplayName, UserPrincipalName, Mail, ExternalDomain
                      ExternalDomain is the domain the account really belongs to, derived from the UPN
                      (#EXT# pattern) or from the mail attribute.
    Origin : UserType, CreationType, AuthSource, Issuer, SignInType, IssuerAssignedId
                      Issuer, SignInType and IssuerAssignedId come from the federated identities and
                      are empty for PendingRedemption accounts.
                      IssuerAssignedId is the identifier assigned by the issuer (typically the email
                      address for social or OTP accounts). It is usually empty for ExternalAzureAD.
    Account state : AccountEnabled, InvitationState, InvitationStateChangeDateTime
                      InvitationState is the Graph 'externalUserState' property: PendingAcceptance until
                      the invitee redeems the invitation, then Accepted. It stays empty for accounts
                      that were never invited (cross-tenant sync, direct creation).
    Sign-in activity: LastSignInDateTime, LastNonInteractiveSignInDateTime, LastSuccessfulSignInDateTime,
                      DaysSinceLastSignIn (only with -IncludeSignInActivity)
    Metadata : CompanyName, CreatedDateTime, OnPremisesSyncEnabled, Id
 
    Required Microsoft Graph permissions:
        - User.Read.All
        - Domain.Read.All (to recognize an issuer that is one of this tenant's own domains)
        - AuditLog.Read.All (only with -IncludeSignInActivity)
 
    An external account that redeemed its invitation carries two identities: one with
    signInType 'userPrincipalName' issued by this tenant, and one with signInType 'federated'
    issued by its identity provider. Only the federated ones drive the AuthSource classification.
 
    With -IncludeSignInActivity the page size is capped at 120 users per request: Microsoft Graph
    enforces this limit when signInActivity is part of the $select clause. The report is therefore
    slower on large tenants.
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MgExternalUser
#>


function Get-MgExternalUser {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Issuer,

        [Parameter(Mandatory = $false)]
        [ValidateSet('All', 'Member', 'Guest')]
        [string]$UserType = 'All',

        [Parameter(Mandatory = $false)]
        [switch]$IncludeSignInActivity,

        [Parameter(Mandatory = $false)]
        [switch]$ForceNewToken,

        [Parameter(Mandatory = $false)]
        [switch]$ExportToExcel,

        [Parameter(Mandatory = $false, HelpMessage = 'Optional output directory for the Excel export (defaults to the user profile).')]
        [string]$ExportPath
    )

    try {
        $null = Import-Module 'Microsoft.Graph.Authentication' -ErrorAction Stop
    }
    catch {
        Write-Warning 'Please install Microsoft.Graph.Authentication first'
        return
    }

    $permissionsNeeded = @('User.Read.All', 'Domain.Read.All')
    if ($IncludeSignInActivity.IsPresent) {
        # signInActivity is only returned when the token carries AuditLog.Read.All
        $permissionsNeeded += 'AuditLog.Read.All'
    }

    $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue)
    if ($ForceNewToken.IsPresent) {
        $null = Disconnect-MgGraph -ErrorAction SilentlyContinue
        $isConnected = $false
    }
    if (-not $isConnected) {
        Write-Host -ForegroundColor Cyan 'Connecting to Microsoft Graph'
        $null = Connect-MgGraph -Scopes $permissionsNeeded -NoWelcome
    }

    if (-not (Test-MgGraphPermission -RequiredScopes $permissionsNeeded -CallerName $MyInvocation.MyCommand.Name)) {
        return
    }

    # Tenant domains: an issuer matching one of them means the account has no external identity
    # provider (email one-time passcode), so it depends entirely on this tenant.
    Write-Host -ForegroundColor Cyan 'Retrieving tenant domains'
    [System.Collections.Generic.List[string]]$tenantDomains = @()
    $domainsUri = 'https://graph.microsoft.com/v1.0/domains?$select=id&$top=999'
    try {
        do {
            $domainsResponse = Invoke-MgGraphRequestWithRetry -Method GET -Uri $domainsUri
            foreach ($domain in $domainsResponse.value) {
                $tenantDomains.Add($domain.id)
            }
            $domainsUri = $domainsResponse.'@odata.nextLink'
        } while ($domainsUri)
    }
    catch {
        Write-Warning "Unable to retrieve the tenant domains: $_"
        Write-Warning 'Identities issued by one of this tenant domains will not be classified as LocalOTP.'
    }

    $socialIssuers = @('google.com', 'facebook.com', 'twitter.com', 'linkedin.com', 'github.com', 'apple.com', 'amazon.com')

    # identities is not returned by default and cannot be filtered server-side on signInType,
    # so the whole user set is retrieved and filtered client-side.
    $selectBaseProperties = 'id,displayName,userPrincipalName,mail,userType,creationType,accountEnabled,companyName,createdDateTime,externalUserState,externalUserStateChangeDateTime,onPremisesSyncEnabled,identities'

    # Not an alias of the switch: this one is turned off at runtime when the tenant cannot
    # serve signInActivity (missing Entra ID P1/P2 license), so the report degrades instead of failing.
    $signInActivityAvailable = $IncludeSignInActivity.IsPresent

    if ($IncludeSignInActivity.IsPresent) {
        # Graph caps the page size at 120 users per request when signInActivity is selected
        $uri = "https://graph.microsoft.com/v1.0/users?`$select=$selectBaseProperties,signInActivity&`$top=120"
    }
    else {
        $uri = "https://graph.microsoft.com/v1.0/users?`$select=$selectBaseProperties&`$top=999"
    }

    Write-Host -ForegroundColor Cyan 'Retrieving users from Microsoft Entra ID'

    [System.Collections.Generic.List[PSCustomObject]]$externalUsersArray = @()
    $processedCount = 0
    $referenceDate = Get-Date

    do {
        $response = $null
        try {
            $response = Invoke-MgGraphRequestWithRetry -Method GET -Uri $uri -ErrorAction Stop
        }
        catch {
            # signInActivity also requires an Entra ID P1/P2 license: degrade gracefully instead of failing
            if ($signInActivityAvailable -and ($processedCount -eq 0)) {
                Write-Warning "Unable to retrieve signInActivity (an Entra ID P1/P2 license and AuditLog.Read.All are required): $_"
                Write-Warning 'Falling back to the report without sign-in activity.'
                $signInActivityAvailable = $false
                $uri = "https://graph.microsoft.com/v1.0/users?`$select=$selectBaseProperties&`$top=999"
                continue
            }

            Write-Warning "Unable to retrieve users: $_"
            return
        }

        foreach ($user in $response.value) {
            $processedCount++

            if (-not (Test-MgUserIsExternal -User $user)) {
                continue
            }

            $userPrincipalName = $user.userPrincipalName
            $federatedIdentities = @($user.identities | Where-Object { $_.signInType -eq 'federated' })

            if ($Issuer) {
                # Filtering on the issuer necessarily drops the accounts that do not have one yet
                $federatedIdentities = @($federatedIdentities | Where-Object { $_.issuer -in $Issuer })
                if ($federatedIdentities.Count -eq 0) {
                    continue
                }
            }

            if (($UserType -ne 'All') -and ($user.userType -ne $UserType)) {
                continue
            }

            # Where the account really authenticates
            if ($federatedIdentities.Count -eq 0) {
                $authSource = 'PendingRedemption'
            }
            else {
                $authSourceList = foreach ($identity in $federatedIdentities) {
                    $issuerValue = $identity.issuer
                    if (-not $issuerValue) { 'Other' }
                    elseif ($issuerValue -eq 'ExternalAzureAD') { 'ExternalTenant' }
                    elseif ($issuerValue -eq 'MSA') { 'MicrosoftAccount' }
                    elseif ($issuerValue -eq 'mail') { 'LocalOTP' }
                    elseif ($issuerValue -in $tenantDomains) { 'LocalOTP' }
                    elseif ($issuerValue -in $socialIssuers) { 'SocialIdP' }
                    elseif ($issuerValue -match '\.') { 'ExternalIdP' }
                    else { 'Other' }
                }
                $authSource = (@($authSourceList) | Sort-Object -Unique) -join '; '
            }

            # Resolve the domain the account really belongs to
            $externalDomain = $null
            if ($userPrincipalName -match '#EXT#') {
                $externalPart = ($userPrincipalName -split '#EXT#')[0]
                if ($externalPart -match '^.+_(?<domain>[^_]+\.[^_]+)$') {
                    $externalDomain = $matches['domain']
                }
            }
            if ((-not $externalDomain) -and $user.mail) {
                $externalDomain = ($user.mail -split '@')[-1]
            }
            if ((-not $externalDomain) -and ($userPrincipalName -match '@')) {
                $externalDomain = ($userPrincipalName -split '@')[-1]
            }

            $issuerList = ($federatedIdentities.issuer | Sort-Object -Unique) -join '; '
            $signInTypeList = ($federatedIdentities.signInType | Sort-Object -Unique) -join '; '
            $issuerAssignedIdList = ($federatedIdentities | Where-Object { $_.issuerAssignedId } | ForEach-Object { $_.issuerAssignedId }) -join '; '

            # Who / where the account comes from / account state
            $properties = [ordered]@{
                DisplayName                   = $user.displayName
                UserPrincipalName             = $userPrincipalName
                Mail                          = $user.mail
                ExternalDomain                = $externalDomain
                UserType                      = $user.userType
                CreationType                  = $user.creationType
                AuthSource                    = $authSource
                Issuer                        = $issuerList
                SignInType                    = $signInTypeList
                IssuerAssignedId              = $issuerAssignedIdList
                AccountEnabled                = $user.accountEnabled
                InvitationState               = $user.externalUserState
                InvitationStateChangeDateTime = $user.externalUserStateChangeDateTime
            }

            if ($signInActivityAvailable) {
                $signInActivity = $user.signInActivity
                $lastSignInDateTime = $signInActivity.lastSignInDateTime

                $daysSinceLastSignIn = $null
                if ($lastSignInDateTime) {
                    try {
                        $lastSignInDate = if ($lastSignInDateTime -is [datetime]) {
                            $lastSignInDateTime
                        }
                        else {
                            [datetime]::Parse($lastSignInDateTime, [cultureinfo]::InvariantCulture)
                        }
                        $daysSinceLastSignIn = [int]($referenceDate - $lastSignInDate).TotalDays
                    }
                    catch {
                        Write-Verbose "Unable to parse lastSignInDateTime '$lastSignInDateTime' for $userPrincipalName"
                    }
                }

                $properties['LastSignInDateTime'] = $lastSignInDateTime
                $properties['LastNonInteractiveSignInDateTime'] = $signInActivity.lastNonInteractiveSignInDateTime
                $properties['LastSuccessfulSignInDateTime'] = $signInActivity.lastSuccessfulSignInDateTime
                $properties['DaysSinceLastSignIn'] = $daysSinceLastSignIn
            }

            # Metadata
            $properties['CompanyName'] = $user.companyName
            $properties['CreatedDateTime'] = $user.createdDateTime
            $properties['OnPremisesSyncEnabled'] = if ($null -eq $user.onPremisesSyncEnabled) { $false } else { $user.onPremisesSyncEnabled }
            $properties['Id'] = $user.id

            $externalUsersArray.Add([PSCustomObject]$properties)
        }

        $uri = $response.'@odata.nextLink'
        Write-Progress -Activity 'Retrieving users' -Status "$processedCount user(s) scanned - $($externalUsersArray.Count) external user(s) found"
    } while ($uri)

    Write-Progress -Activity 'Retrieving users' -Completed

    if ($externalUsersArray.Count -eq 0) {
        Write-Host -ForegroundColor Yellow "No external user found among the $processedCount user(s) scanned."
        return
    }

    Write-Host -ForegroundColor Green "Found $($externalUsersArray.Count) external user(s) among $processedCount user(s) scanned."

    Write-Host -ForegroundColor Cyan 'Breakdown by AuthSource:'
    foreach ($group in ($externalUsersArray | Group-Object AuthSource | Sort-Object Count -Descending)) {
        Write-Host " $($group.Name): $($group.Count)"
    }

    $externalMembersCount = @($externalUsersArray | Where-Object { $_.UserType -eq 'Member' }).Count
    if ($externalMembersCount -gt 0) {
        Write-Host -ForegroundColor Yellow "$externalMembersCount external user(s) have userType 'Member' and would be missed by a userType-based script."
    }

    if ($ExportToExcel.IsPresent) {
        $now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
        $exportDirectory = if ($ExportPath) { $ExportPath } else { $env:userprofile }
        $excelFilePath = Join-Path -Path $exportDirectory -ChildPath "$now-MgExternalUser.xlsx"
        Write-Host -ForegroundColor Cyan "Exporting external users to Excel file: $excelFilePath"
        $externalUsersArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -WorksheetName 'Entra-ExternalUsers'
        Write-Host -ForegroundColor Green 'Export completed successfully!'
    }
    else {
        return $externalUsersArray
    }
}