├── README.md └── Send-GraphRequest.psm1 /README.md: -------------------------------------------------------------------------------- 1 | # GraphRequest - PowerShell Module 2 | 3 | ## Introduction 4 | 5 | The `GraphRequest` PowerShell module allows to send single requests to the Microsoft Graph API. 6 | 7 | Key features: 8 | - Handles Microsoft Graph v1.0 and Beta APIs 9 | - Automatic Pagination Support 10 | - Retry Logic with exponential Backoff for transient errors (e.g. 429, 503) 11 | - Custom Headers and Query Parameters for flexible API queries 12 | - Optional Raw JSON Output 13 | - Simple HTTP Proxy Support (for debugging) 14 | - Verbose Logging Option 15 | - User-Agent Customization 16 | 17 | Note: 18 | - Cleartext access tokens can be obtained, for example, using [EntraTokenAid](https://github.com/zh54321/EntraTokenAid). 19 | - Use [GraphBatchRequest](https://github.com/zh54321/GraphBatchRequest) to send Batch Request to the Graph API. 20 | - Find first party apps with pre-consented scopes to bypass Graph API consent [GraphPreConsentExplorer](https://github.com/zh54321/GraphPreConsentExplorer) 21 | 22 | ## Parameters 23 | 24 | | Parameter | Description | 25 | | ---------------------------- | ------------------------------------------------------------------------------------------- | 26 | | `-AccessToken` *(Mandatory)* | The OAuth access token to authenticate against Microsoft Graph API. | 27 | | `-Method` *(Mandatory)* | HTTP Method to use (GET, POST, PATCH, PUT, DELETE) | 28 | | `-Uri` *(Mandatory)* | Relative Graph URI (e.g. /users) | 29 | | `-VerboseMode` | Enables verbose logging to provide additional information about request processing. | 30 | | `-UserAgent` | Custom UserAgent | 31 | | `-BetaAPI` | If specified, uses the Microsoft Graph `Beta` endpoint instead of `v1.0`. | 32 | | `-RawJson` | If specified, returns the response as a raw JSON string instead of a PowerShell object. | 33 | | `-MaxRetries` *(Default: 5)* | Specifies the maximum number of retry attempts for failed requests. | 34 | | `-Proxy` | Use a Proxy (e.g. http://127.0.0.1:8080) | 35 | | `-Body` | Request body as PowerShell hashtable/object (will be converted to JSON). | 36 | | `-QueryParameters` | Query parameters for more complex queries | 37 | | `-DisablePagination` | Prevents the function from automatically following @odata.nextLink for paginated results. | 38 | | `-AdditionalHeaders` | Add additional HTTP headers (e.g. for ConsistencyLevel) | 39 | | `-JsonDepthResponse` *(Default: 10)* | Specifies the depth for JSON conversion (request). Useful for deeply nested objects in combination with `-RawJson`. | 40 | | `-$Suppress404` | Supress 404 Messages (example if a queried User object is not found in the tenant) | 41 | 42 | ## Examples 43 | 44 | ### Example 1: **Retrieve All Users** 45 | 46 | ```powershell 47 | $AccessToken = "YOUR_ACCESS_TOKEN" 48 | $Response = Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri '/users' 49 | 50 | #Show Data 51 | $Response 52 | ``` 53 | 54 | ### Example 2: **Create a New Security Group** 55 | 56 | ```powershell 57 | $AccessToken = "YOUR_ACCESS_TOKEN" 58 | $Body = @{ 59 | displayName = "TestSecurityGroup" 60 | mailEnabled = $false 61 | mailNickname = "TestSecurityGroup$(Get-Random)" 62 | securityEnabled = $true 63 | groupTypes = @() 64 | } 65 | $Response = Send-GraphRequest -AccessToken $AccessToken -Method POST -Uri "/groups" -Body $Body" 66 | 67 | #Show Response 68 | $Response 69 | ``` 70 | 71 | ### Example 3: **Use the Beta API Endpoint and a simple query** 72 | 73 | ```powershell 74 | $AccessToken = "YOUR_ACCESS_TOKEN" 75 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri '/groups?$select=displayName' -BetaAPI 76 | ``` 77 | 78 | ### Example 4: **Using an advanced filter and a proxy** 79 | 80 | ```powershell 81 | $AccessToken = "YOUR_ACCESS_TOKEN" 82 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri '/users' -QueryParameters @{ '$filter' = "startswith(displayName,'Alex')" } -Proxy "http://127.0.0.1:8080" 83 | ``` 84 | 85 | ### Example 5: **Using an additional header** 86 | 87 | ```powershell 88 | $AccessToken = "YOUR_ACCESS_TOKEN" 89 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri '/users' -AdditionalHeaders @{ 'ConsistencyLevel' = 'eventual' } 90 | ``` 91 | 92 | ### Example 6: **Using an additional header to remove odata metadata** 93 | ```powershell 94 | $AccessToken = "YOUR_ACCESS_TOKEN" 95 | $QueryParameters = @{ 96 | '$select' = "Id,DisplayName,IsMemberManagementRestricted" 97 | } 98 | $headers = @{ 99 | 'Accept' = 'application/json;odata.metadata=none' 100 | } 101 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri "/directory/administrativeUnits" -QueryParameters $QueryParameters -AdditionalHeaders $headers 102 | ``` 103 | ### Example 7: **Catch errors** 104 | ```powershell 105 | $AccessToken = "YOUR_ACCESS_TOKEN" 106 | try { 107 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri '/doesnotexist' -BetaAPI -ErrorAction Stop 108 | } catch { 109 | $err = $_ 110 | Write-Host "[!] Auth error occurred:" 111 | Write-Host " Message : $($err.Exception.Message)" 112 | Write-Host " FullyQualifiedErrorId : $($err.FullyQualifiedErrorId)" 113 | Write-Host " TargetURL: $($err.TargetObject)" 114 | Write-Host " Category : $($err.CategoryInfo.Category)" 115 | Write-Host " Script Line : $($err.InvocationInfo.Line)" 116 | } 117 | ``` 118 | ### Example 8: **Get only one result by disabling pagination** 119 | ```powershell 120 | $AccessToken = "YOUR_ACCESS_TOKEN" 121 | $QueryParameters = @{ 122 | '$select' = "id,SignInActivity" 123 | '$top' = "1" 124 | } 125 | Send-GraphRequest -AccessToken $AccessToken -Method GET -Uri "/users" -QueryParameters $QueryParameters -DisablePagination 126 | ``` 127 | 128 | 129 | -------------------------------------------------------------------------------- /Send-GraphRequest.psm1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Sends requests to the Microsoft Graph API. 4 | 5 | .DESCRIPTION 6 | Sends single requests to Microsoft Graph API with automatic pagination, retries, and exponential backoff. 7 | 8 | .PARAMETER AccessToken 9 | OAuth token for Microsoft Graph authentication. 10 | 11 | .PARAMETER Method 12 | HTTP method (GET, POST, PUT, PATCH, DELETE). 13 | 14 | .PARAMETER Uri 15 | API resource URI (relative, e.g., /users). 16 | 17 | .PARAMETER Body 18 | Request body as PowerShell hashtable/object. 19 | 20 | .PARAMETER MaxRetries 21 | Maximum retry attempts. Default: 5. 22 | 23 | .PARAMETER BetaAPI 24 | Switch to use Beta API endpoint. 25 | 26 | .PARAMETER RawJson 27 | Returns the raw JSON string instead of a PowerShell object. 28 | 29 | .PARAMETER Proxy 30 | Proxy URL (e.g., http://proxyserver:port). 31 | 32 | .PARAMETER UserAgent 33 | Specify the User-Agent HTTP header. 34 | 35 | .PARAMETER VerboseMode 36 | Enable detailed logging. 37 | 38 | .PARAMETER Suppress404 39 | Supress 404 error messages 40 | 41 | .PARAMETER DisablePagination 42 | If specified, prevents the function from automatically following @odata.nextLink for paginated results. 43 | 44 | .PARAMETER AdditionalHeaders 45 | Add additional headers, example -AdditionalHeaders @{ 'ConsistencyLevel' = 'eventual' } 46 | 47 | .PARAMETER QueryParameters 48 | Query parameters for more complex queries (e.g. -QueryParameters @{ '$filter' = "startswith(displayName,'Alex')" }) 49 | 50 | .EXAMPLE 51 | Send-GraphRequest -AccessToken $token -Method GET -Uri '/users' 52 | 53 | .EXAMPLE 54 | Send-GraphRequest -AccessToken $token -Method GET -Uri '/groups?$select=displayName' -VerboseMode 55 | 56 | .EXAMPLE 57 | Send-GraphRequest -AccessToken $token -Method GET -Uri '/groups?$select=displayName' -proxy "http://127.0.0.1:8080" 58 | 59 | .EXAMPLE 60 | Send-GraphRequest -AccessToken $token -Method GET -Uri '/users' -AdditionalHeaders @{ 'ConsistencyLevel' = 'eventual' } 61 | 62 | .EXAMPLE 63 | Send-GraphRequest -AccessToken $token -Method GET -Uri '/users' -QueryParameters @{ '$filter' = "startswith(displayName,'Alex')" }" 64 | 65 | .EXAMPLE 66 | $Body = @{ 67 | displayName = "Test Security Group2" 68 | mailEnabled = $false 69 | mailNickname = "TestSecurityGroup$(Get-Random)" 70 | securityEnabled = $true 71 | groupTypes = @() 72 | } 73 | $result = Send-GraphRequest -AccessToken $token -Method POST -Uri "/groups" -Body $Body -VerboseMode 74 | 75 | 76 | .NOTES 77 | Author: ZH54321 78 | GitHub: https://github.com/zh54321/GraphRequest 79 | #> 80 | 81 | function Send-GraphRequest { 82 | [CmdletBinding()] 83 | param ( 84 | [Parameter(Mandatory)] 85 | [string]$AccessToken, 86 | 87 | [Parameter(Mandatory)] 88 | [ValidateSet("GET", "POST", "PATCH", "PUT", "DELETE")] 89 | [string]$Method, 90 | 91 | [Parameter(Mandatory)] 92 | [string]$Uri, 93 | [hashtable]$Body, 94 | [int]$MaxRetries = 5, 95 | [switch]$BetaAPI, 96 | [string]$UserAgent = 'PowerShell GraphRequest Module', 97 | [switch]$RawJson, 98 | [string]$Proxy, 99 | [switch]$DisablePagination, 100 | [switch]$VerboseMode, 101 | [switch]$Suppress404, 102 | [hashtable]$QueryParameters, 103 | [hashtable]$AdditionalHeaders, 104 | [int]$JsonDepthResponse = 10 105 | ) 106 | 107 | $ApiVersion = if ($BetaAPI) { "beta" } else { "v1.0" } 108 | $BaseUri = "https://graph.microsoft.com/$ApiVersion" 109 | $FullUri = "$BaseUri$Uri" 110 | 111 | #Add query parameters 112 | if ($QueryParameters) { 113 | $QueryString = ($QueryParameters.GetEnumerator() | 114 | ForEach-Object { 115 | "$($_.Key)=$([uri]::EscapeDataString($_.Value))" 116 | }) -join '&' 117 | $FullUri = "$FullUri`?$QueryString" 118 | } 119 | 120 | 121 | #Define basic headers 122 | $Headers = @{ 123 | Authorization = "Bearer $AccessToken" 124 | 'Content-Type' = 'application/json' 125 | 'User-Agent' = $UserAgent 126 | } 127 | 128 | #Add custom headers if required 129 | if ($AdditionalHeaders) { 130 | $Headers += $AdditionalHeaders 131 | } 132 | 133 | $RetryCount = 0 134 | $Results = @() 135 | 136 | # Prepare Invoke-RestMethod parameters 137 | $irmParams = @{ 138 | Uri = $FullUri 139 | Method = $Method 140 | Headers = $Headers 141 | UseBasicParsing = $true 142 | ErrorAction = 'Stop' 143 | } 144 | 145 | if ($Body) { 146 | $irmParams.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) 147 | } 148 | 149 | if ($Proxy) { 150 | $irmParams.Proxy = $Proxy 151 | } 152 | 153 | do { 154 | try { 155 | if ($VerboseMode) { Write-Host "[*] Request [$Method]: $FullUri" } 156 | 157 | $Response = Invoke-RestMethod @irmParams 158 | 159 | if ($Response.PSObject.Properties.Name -contains 'value') { 160 | if ($Response.value.Count -eq 0) { 161 | if ($VerboseMode) { Write-Host "[i] Empty 'value' array detected. Returning nothing." } 162 | return 163 | } else { 164 | $Results += $Response.value 165 | } 166 | } else { 167 | $Results += $Response 168 | } 169 | 170 | # Pagination handling 171 | while ($Response.'@odata.nextLink' -and -not $DisablePagination) { 172 | if ($VerboseMode) { Write-Host "[*] Following pagination link: $($Response.'@odata.nextLink')" } 173 | 174 | $irmParams.Uri = $Response.'@odata.nextLink' 175 | # Remove Body for paginated GET requests 176 | $irmParams.Remove('Body') 177 | 178 | $Response = Invoke-RestMethod @irmParams 179 | if ($Response.PSObject.Properties.Name -contains 'value') { 180 | if ($Response.value.Count -eq 0) { 181 | if ($VerboseMode) { Write-Host "[i] Empty 'value' array detected. Returning nothing." } 182 | return 183 | } else { 184 | $Results += $Response.value 185 | } 186 | } else { 187 | $Results += $Response 188 | } 189 | } 190 | 191 | break 192 | } 193 | catch { 194 | $StatusCode = $_.Exception.Response.StatusCode.value__ 195 | $StatusDesc = $_.Exception.Message 196 | # Map HTTP status code to a PowerShell ErrorCategory 197 | switch ($StatusCode) { 198 | 400 { $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidArgument } 199 | 401 { $errorCategory = [System.Management.Automation.ErrorCategory]::AuthenticationError } 200 | 403 { $errorCategory = [System.Management.Automation.ErrorCategory]::PermissionDenied } 201 | 404 { $errorCategory = [System.Management.Automation.ErrorCategory]::ObjectNotFound } 202 | 409 { $errorCategory = [System.Management.Automation.ErrorCategory]::ResourceExists } 203 | 429 { $errorCategory = [System.Management.Automation.ErrorCategory]::LimitsExceeded } 204 | 500 { $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidResult } 205 | 502 { $errorCategory = [System.Management.Automation.ErrorCategory]::ProtocolError } 206 | 503 { $errorCategory = [System.Management.Automation.ErrorCategory]::ResourceUnavailable } 207 | 504 { $errorCategory = [System.Management.Automation.ErrorCategory]::OperationTimeout } 208 | default { $errorCategory = [System.Management.Automation.ErrorCategory]::NotSpecified } 209 | } 210 | 211 | if ($StatusCode -in @(429,500,502,503,504) -and $RetryCount -lt $MaxRetries) { 212 | $RetryAfter = $_.Exception.Response.Headers['Retry-After'] 213 | if ($RetryAfter) { 214 | Write-Host "[i] [$StatusCode] - Throttled. Retrying after $RetryAfter seconds..." 215 | Start-Sleep -Seconds ([int]$RetryAfter) 216 | } elseif ($RetryCount -eq 0) { 217 | Write-Host "[*] [$StatusCode] - Retrying immediately..." 218 | Start-Sleep -Seconds 0 219 | } else { 220 | $Backoff = [math]::Pow(2, $RetryCount) 221 | Write-Host "[*] [$StatusCode] - Retrying in $Backoff seconds..." 222 | Start-Sleep -Seconds $Backoff 223 | } 224 | $RetryCount++ 225 | } else { 226 | if (-not ($StatusCode -eq 404 -and $Suppress404)) { 227 | $msg = "[!] Graph API request failed after $RetryCount retries. Status: $StatusCode. Message: $StatusDesc" 228 | $exception = New-Object System.Exception($msg) 229 | 230 | $errorRecord = New-Object System.Management.Automation.ErrorRecord ( 231 | $exception, 232 | "GraphApiRequestFailed", 233 | $errorCategory, 234 | $FullUri 235 | ) 236 | 237 | Write-Error $errorRecord 238 | } 239 | 240 | return 241 | } 242 | } 243 | } while ($RetryCount -le $MaxRetries) 244 | 245 | if ($RawJson) { 246 | return $Results | ConvertTo-Json -Depth $JsonDepthResponse 247 | } 248 | else { 249 | return $Results 250 | } 251 | } 252 | --------------------------------------------------------------------------------