Export-EntraGroupMemberShip.ps1


<#PSScriptInfo

.VERSION 1.1.0

.GUID 3980b2a1-4d15-4cc6-98b6-c3e619407421

.AUTHOR Chendrayan Venkatesan

.COMPANYNAME

.COPYRIGHT

.TAGS EntraID AzureAD GroupMembership MicrosoftGraph Export

.LICENSEURI

.PROJECTURI

.ICONURI

.EXTERNALMODULEDEPENDENCIES

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES No module dependency. Signs in with the authorization code + PKCE flow via the
system browser and a local loopback listener, and uses Invoke-RestMethod against Microsoft
Graph directly. Device code sign-in has been removed entirely per IT Security requirement: it
has no binding to the requesting device (a known phishing vector) and is commonly blocked by
Conditional Access "Authentication flows" policies. This script only ever uses the loopback
authorization code + PKCE flow.


.PRIVATEDATA

#>


<#
.SYNOPSIS
    Prompts for interactive Microsoft Entra ID (Azure AD) authentication and exports a detailed
    group membership report to CSV, including group name, group source, group type, and members.

.DESCRIPTION
    Authenticates interactively against Microsoft Graph using the OAuth 2.0 authorization code
    flow with PKCE (Invoke-RestMethod only - no Microsoft.Graph, MSAL.PS, or AzureAD module
    dependency): a browser window opens for sign-in and a local loopback listener on 127.0.0.1
    catches the redirect, so the token exchange is bound to the machine that requested it.
    Device code sign-in is intentionally not supported by this script - per IT Security
    requirement, it is not offered as an option or a fallback under any parameter.
    The script then enumerates Microsoft Entra ID groups. The report contains one row per group
    with:
      - GroupName : the group's display name.
      - GroupSource : Cloud (native Entra ID) or Windows Server AD (synced via Entra Connect).
      - GroupType : Microsoft 365, Security, Mail-Enabled Security, Distribution, Dynamic, etc.
      - Members : comma-separated list of direct members (users and groups). Members that
                              are themselves groups are suffixed with " (Group)".
      - NestedGroupMembers : for every direct member that is a group, that nested group's own members,
                              formatted as "NestedGroupName: member1, member2; NextGroup: member3".

.PARAMETER GroupNames
    Optional list of exact group display names to limit the report to. If omitted, all groups in
    the tenant are reported on. Cannot be combined with -GroupNameStartsWith.

.PARAMETER GroupNameStartsWith
    Optional prefix used to limit the report to groups whose display name starts with this value.
    Cannot be combined with -GroupNames.

.PARAMETER OutputPath
    Path to the CSV file to create. Defaults to a timestamped file in the current user's temp folder.

.PARAMETER TenantId
    Azure AD tenant to authenticate against. Defaults to 'common' (the signed-in user's home tenant).

.PARAMETER ClientId
    Azure AD application (client) ID used for sign-in. Defaults to the Microsoft first-party
    "Microsoft Graph Command Line Tools" public client (14d82eec-204b-4c2f-b7e8-296a70dab67e),
    which is pre-registered in every tenant and supports the loopback authorization code flow.
    Override with your own app registration's client ID if your tenant restricts sign-in to
    specific applications.

.PARAMETER RedirectTimeoutSeconds
    How long to wait on the local loopback listener for the browser sign-in to complete before
    giving up. Defaults to 300 (5 minutes).

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1

    Opens a browser for sign-in (authorization code + PKCE) and exports every group's detailed
    membership to a timestamped CSV in the temp folder.

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1 -GroupNames "Finance Team","IT Admins" -OutputPath C:\Reports\GroupReport.csv

    Exports membership only for the named groups.

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1 -GroupNameStartsWith "Sales-"

    Exports membership only for groups whose display name starts with "Sales-".

.NOTES
    No external module dependency - only built-in Invoke-RestMethod is used to call Microsoft Graph.
    Required Graph delegated permissions (must be consented in the tenant): Group.Read.All, User.Read.All.
    Device code sign-in is not supported by this script and cannot be enabled by any parameter -
    per IT Security requirement, sign-in always uses the loopback authorization code + PKCE flow.
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string[]]$GroupNames,

    [Parameter()]
    [string]$GroupNameStartsWith,

    [Parameter()]
    [string]$OutputPath = (Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "GroupMembershipReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"),

    [Parameter()]
    [string]$TenantId = 'common',

    [Parameter()]
    [string]$ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e',

    [Parameter()]
    [int]$RedirectTimeoutSeconds = 300
)

if ($GroupNames -and $GroupNameStartsWith) {
    throw '-GroupNames and -GroupNameStartsWith cannot be used together.'
}

$ErrorActionPreference = 'Stop'
$graphBaseUri = 'https://graph.microsoft.com/v1.0'

function ConvertTo-Base64Url {
    param([byte[]]$Bytes)

    [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function Get-RandomBytes {
    param([int]$Length)

    $bytes = [byte[]]::new($Length)
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try {
        $rng.GetBytes($bytes)
    }
    finally {
        $rng.Dispose()
    }
    return $bytes
}

function New-PkceCodePair {
    $verifier = ConvertTo-Base64Url -Bytes (Get-RandomBytes -Length 32)

    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    try {
        $challengeBytes = $sha256.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($verifier))
    }
    finally {
        $sha256.Dispose()
    }

    [pscustomobject]@{
        Verifier  = $verifier
        Challenge = ConvertTo-Base64Url -Bytes $challengeBytes
    }
}

function New-OAuthState {
    return ConvertTo-Base64Url -Bytes (Get-RandomBytes -Length 16)
}

function Get-AvailableLoopbackPort {
    $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
    try {
        $listener.Start()
        return $listener.LocalEndpoint.Port
    }
    finally {
        $listener.Stop()
    }
}

function ConvertFrom-QueryString {
    param([string]$Query)

    $result = @{}
    $Query = $Query.TrimStart('?')
    if (-not $Query) { return $result }

    foreach ($pair in $Query -split '&') {
        if (-not $pair) { continue }
        $parts = $pair.Split('=', 2)
        $key = [System.Uri]::UnescapeDataString($parts[0])
        $value = if ($parts.Count -gt 1) { [System.Uri]::UnescapeDataString($parts[1]) } else { '' }
        $result[$key] = $value
    }

    return $result
}

function Send-LoopbackResponse {
    param(
        [System.Net.HttpListenerResponse]$Response,
        [int]$StatusCode,
        [string]$Html
    )

    $Response.StatusCode = $StatusCode
    if ($Html) {
        $buffer = [System.Text.Encoding]::UTF8.GetBytes($Html)
        $Response.ContentType = 'text/html; charset=utf-8'
        $Response.ContentLength64 = $buffer.Length
        $Response.OutputStream.Write($buffer, 0, $buffer.Length)
    }
    $Response.OutputStream.Close()
}

function Get-AuthorizationCodeAccessToken {
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string[]]$Scopes,
        [int]$TimeoutSeconds
    )

    $authorizeUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize"
    $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $scopeString = ($Scopes -join ' ')

    $port = Get-AvailableLoopbackPort
    $redirectUri = "http://localhost:$port/"
    $pkce = New-PkceCodePair
    $state = New-OAuthState

    $authParams = [ordered]@{
        client_id             = $ClientId
        response_type         = 'code'
        redirect_uri          = $redirectUri
        response_mode         = 'query'
        scope                 = $scopeString
        state                 = $state
        code_challenge        = $pkce.Challenge
        code_challenge_method = 'S256'
    }
    $queryString = ($authParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$([System.Uri]::EscapeDataString($_.Value))" }) -join '&'
    $authUri = "${authorizeUri}?${queryString}"

    $listener = [System.Net.HttpListener]::new()
    $listener.Prefixes.Add($redirectUri)

    try {
        $listener.Start()
    }
    catch {
        throw "Failed to start local loopback listener on $redirectUri : $($_.Exception.Message)"
    }

    Write-Host "Opening your browser to sign in. If it doesn't open automatically, browse to:`n$authUri" -ForegroundColor Yellow
    try {
        Start-Process -FilePath $authUri | Out-Null
    }
    catch {
        Write-Warning "Could not launch a browser automatically: $($_.Exception.Message)"
    }

    $code = $null
    $authError = $null

    try {
        while (-not $code -and -not $authError) {
            $asyncResult = $listener.BeginGetContext($null, $null)
            if (-not $asyncResult.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))) {
                throw 'Timed out waiting for browser sign-in to complete.'
            }
            $context = $listener.EndGetContext($asyncResult)

            if ($context.Request.Url.AbsolutePath -eq '/favicon.ico') {
                Send-LoopbackResponse -Response $context.Response -StatusCode 204 -Html $null
                continue
            }

            $query = ConvertFrom-QueryString -Query $context.Request.Url.Query

            if ($query['error']) {
                $authError = "$($query['error']): $($query['error_description'])"
                Send-LoopbackResponse -Response $context.Response -StatusCode 200 -Html '<html><body><h2>Sign-in failed.</h2>You can close this window and return to PowerShell.</body></html>'
                continue
            }

            if (-not $query['code']) {
                Send-LoopbackResponse -Response $context.Response -StatusCode 400 -Html '<html><body>Unexpected request.</body></html>'
                continue
            }

            if ($query['state'] -ne $state) {
                $authError = 'State mismatch on the sign-in redirect - possible CSRF, aborting.'
                Send-LoopbackResponse -Response $context.Response -StatusCode 400 -Html '<html><body><h2>Sign-in aborted.</h2>State mismatch. You can close this window.</body></html>'
                continue
            }

            $code = $query['code']
            Send-LoopbackResponse -Response $context.Response -StatusCode 200 -Html '<html><body><h2>Sign-in complete.</h2>You can close this window and return to PowerShell.</body></html>'
        }
    }
    finally {
        $listener.Stop()
        $listener.Close()
    }

    if ($authError) {
        throw "Sign-in failed: $authError"
    }

    try {
        return Invoke-RestMethod -Method Post -Uri $tokenUri -ContentType 'application/x-www-form-urlencoded' -Body @{
            grant_type    = 'authorization_code'
            client_id     = $ClientId
            code          = $code
            redirect_uri  = $redirectUri
            code_verifier = $pkce.Verifier
            scope         = $scopeString
        }
    }
    catch {
        $errorBody = $null
        if ($_.ErrorDetails.Message) {
            $errorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
        }
        $description = if ($errorBody.error_description) { $errorBody.error_description } else { $_.Exception.Message }
        throw "Failed to exchange authorization code for a token: $description"
    }
}

function Invoke-GraphGet {
    param(
        [string]$Uri,
        [string]$AccessToken,
        [switch]$Eventual
    )

    $headers = @{ Authorization = "Bearer $AccessToken" }
    if ($Eventual) { $headers['ConsistencyLevel'] = 'eventual' }

    $results = [System.Collections.Generic.List[object]]::new()
    $nextUri = $Uri

    while ($nextUri) {
        $response = Invoke-RestMethod -Method Get -Uri $nextUri -Headers $headers
        if ($null -ne $response.value) {
            $results.AddRange(@($response.value))
        }
        $nextUri = $response.'@odata.nextLink'
    }

    return $results
}

function Get-EntraGroupTypeLabel {
    param($Group)

    $groupTypes = $Group.groupTypes
    $labels = [System.Collections.Generic.List[string]]::new()

    if ($groupTypes -contains 'Unified') {
        $labels.Add('Microsoft 365')
    }
    elseif ($Group.securityEnabled -and $Group.mailEnabled) {
        $labels.Add('Mail-Enabled Security')
    }
    elseif ($Group.securityEnabled) {
        $labels.Add('Security')
    }
    elseif ($Group.mailEnabled) {
        $labels.Add('Distribution')
    }
    else {
        $labels.Add('Unknown')
    }

    if ($groupTypes -contains 'DynamicMembership') {
        $labels.Add('Dynamic')
    }

    if ($Group.isAssignableToRole) {
        $labels.Add('Role-Assignable')
    }

    return ($labels -join ' / ')
}

function Get-EntraGroupSourceLabel {
    param($Group)

    if ($Group.onPremisesSyncEnabled) {
        return 'Windows Server AD'
    }
    return 'Cloud'
}

function Get-EntraMemberTypeLabel {
    param($Member)

    switch ($Member.'@odata.type') {
        '#microsoft.graph.user' { 'User'; break }
        '#microsoft.graph.group' { 'Group'; break }
        '#microsoft.graph.servicePrincipal' { 'Service Principal'; break }
        '#microsoft.graph.device' { 'Device'; break }
        '#microsoft.graph.orgContact' { 'Contact'; break }
        default { ($_ -replace '^#microsoft\.graph\.', '') }
    }
}

function Get-EntraMemberDisplayName {
    param($Member)

    if ($Member.displayName) {
        return $Member.displayName
    }
    if ($Member.userPrincipalName) {
        return $Member.userPrincipalName
    }
    return $Member.id
}

# Cache of GroupId -> comma-separated member names, so a group nested under
# multiple parents is only resolved once.
$script:NestedGroupMemberCache = @{}

function Get-EntraNestedGroupMemberSummary {
    param(
        [string]$GroupId,
        [string]$GroupDisplayName,
        [string]$AccessToken
    )

    if ($script:NestedGroupMemberCache.ContainsKey($GroupId)) {
        return $script:NestedGroupMemberCache[$GroupId]
    }

    try {
        $nestedMembers = Invoke-GraphGet -Uri "$graphBaseUri/groups/$GroupId/members?`$top=999" -AccessToken $AccessToken
    }
    catch {
        Write-Warning "Failed to retrieve members for nested group '$GroupDisplayName': $($_.Exception.Message)"
        $nestedMembers = $null
    }

    if (-not $nestedMembers) {
        $summary = "$($GroupDisplayName): (No members)"
    }
    else {
        $names = foreach ($nestedMember in $nestedMembers) {
            $name = Get-EntraMemberDisplayName -Member $nestedMember
            if ((Get-EntraMemberTypeLabel -Member $nestedMember) -eq 'Group') {
                "$name (Group)"
            }
            else {
                $name
            }
        }
        $summary = "$($GroupDisplayName): $($names -join ', ')"
    }

    $script:NestedGroupMemberCache[$GroupId] = $summary
    return $summary
}

$requiredScopes = 'Group.Read.All', 'User.Read.All'

Write-Host 'Starting Microsoft Graph sign-in (authorization code + PKCE)...' -ForegroundColor Cyan
$tokenResponse = Get-AuthorizationCodeAccessToken -TenantId $TenantId -ClientId $ClientId -Scopes $requiredScopes -TimeoutSeconds $RedirectTimeoutSeconds

$accessToken = $tokenResponse.access_token
Write-Host 'Authenticated successfully.' -ForegroundColor Green

Write-Host 'Retrieving groups...' -ForegroundColor Cyan
$groupSelect = 'id,displayName,groupTypes,securityEnabled,mailEnabled,isAssignableToRole,onPremisesSyncEnabled'

if ($GroupNames) {
    $groups = foreach ($name in $GroupNames) {
        $escaped = $name.Replace("'", "''")
        Invoke-GraphGet -Uri "$graphBaseUri/groups?`$filter=displayName eq '$escaped'&`$select=$groupSelect" -AccessToken $accessToken
    }
}
elseif ($GroupNameStartsWith) {
    $escaped = $GroupNameStartsWith.Replace("'", "''")
    $groups = Invoke-GraphGet -Uri "$graphBaseUri/groups?`$filter=startswith(displayName, '$escaped')&`$select=$groupSelect&`$count=true" -AccessToken $accessToken -Eventual
}
else {
    $groups = Invoke-GraphGet -Uri "$graphBaseUri/groups?`$select=$groupSelect&`$top=999" -AccessToken $accessToken
}

if (-not $groups) {
    Write-Warning 'No matching groups were found.'
    return
}

$report = [System.Collections.Generic.List[object]]::new()
$total = @($groups).Count
$current = 0

foreach ($group in $groups) {
    $current++
    Write-Progress -Activity 'Exporting group membership' -Status $group.displayName -PercentComplete (($current / $total) * 100)

    $groupTypeLabel = Get-EntraGroupTypeLabel -Group $group
    $groupSourceLabel = Get-EntraGroupSourceLabel -Group $group

    try {
        $members = Invoke-GraphGet -Uri "$graphBaseUri/groups/$($group.id)/members?`$top=999" -AccessToken $accessToken
    }
    catch {
        Write-Warning "Failed to retrieve members for group '$($group.displayName)': $($_.Exception.Message)"
        continue
    }

    if (-not $members) {
        $report.Add([pscustomobject]@{
                GroupName          = $group.displayName
                GroupSource        = $groupSourceLabel
                GroupType          = $groupTypeLabel
                Members            = '(No members)'
                NestedGroupMembers = ''
            })
        continue
    }

    $memberNames = [System.Collections.Generic.List[string]]::new()
    $nestedGroupSummaries = [System.Collections.Generic.List[string]]::new()

    foreach ($member in $members) {
        $memberTypeLabel = Get-EntraMemberTypeLabel -Member $member
        $memberName = Get-EntraMemberDisplayName -Member $member

        if ($memberTypeLabel -eq 'Group') {
            $memberNames.Add("$memberName (Group)")
            $nestedGroupSummaries.Add((Get-EntraNestedGroupMemberSummary -GroupId $member.id -GroupDisplayName $memberName -AccessToken $accessToken))
        }
        else {
            $memberNames.Add($memberName)
        }
    }

    $report.Add([pscustomobject]@{
            GroupName          = $group.displayName
            GroupSource        = $groupSourceLabel
            GroupType          = $groupTypeLabel
            Members            = ($memberNames -join ', ')
            NestedGroupMembers = ($nestedGroupSummaries -join '; ')
        })
}

Write-Progress -Activity 'Exporting group membership' -Completed

$report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

Write-Host "Report exported to: $OutputPath" -ForegroundColor Green
Write-Host "Total groups: $($report.Count)" -ForegroundColor Green