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 |