MiniGraph.psm1
function Assert-GraphConnection { <# .SYNOPSIS Asserts a valid graph connection has been established. .DESCRIPTION Asserts a valid graph connection has been established. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .EXAMPLE PS C:\> Assert-GraphConnection -Cmdlet $PSCmdlet Asserts a valid graph connection has been established. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Cmdlet ) process { if ($script:token) { return } $exception = [System.InvalidOperationException]::new('Not yet connected to MSGraph. Use Connect-Graph* to establish a connection!') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "NotConnected", 'InvalidOperation', $null) $Cmdlet.ThrowTerminatingError($errorRecord) } } function Connect-GraphRefreshToken { <# .SYNOPSIS Connect with the refresh token provided previously. .DESCRIPTION Connect with the refresh token provided previously. Used mostly for delegate authentication flows to avoid interactivity. .EXAMPLE PS C:\> Connect-GraphRefreshToken Connect with the refresh token provided previously. #> [CmdletBinding()] param ( ) process { if (-not $script:lastConnect.Refresh) { throw "No refresh token found!" } $scopes = 'https://graph.microsoft.com/.default' if ($script:lastConnect.Parameters.Scopes) { $scopes = $script:lastConnect.Parameters.Scopes } $body = @{ client_id = $script:lastConnect.Parameters.ClientID scope = $scopes -join " " refresh_token = [PSCredential]::new("whatever", $script:lastConnect.Refresh).GetNetworkCredential().Password grant_type = 'refresh_token' } $uri = "https://login.microsoftonline.com/$($script:lastConnect.Parameters.TenantID)/oauth2/v2.0/token" $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body $script:token = $authResponse.access_token $script:lastConnect.Refresh = $authResponse.refresh_token } } function ConvertTo-Base64 { <# .SYNOPSIS Converts input string to base 64. .DESCRIPTION Converts input string to base 64. .PARAMETER Text The text to encode. .PARAMETER Encoding The encoding of the input text. .EXAMPLE PS C:\> "Hello World" | ConvertTo-Base64 Converts the string "Hello World" to base 64. #> [OutputType([string])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string[]] $Text, [System.Text.Encoding] $Encoding = [System.Text.Encoding]::UTF8 ) process { foreach ($entry in $Text) { $bytes = $Encoding.GetBytes($entry) [Convert]::ToBase64String($bytes) } } } function ConvertTo-SignedString { <# .SYNOPSIS Signs a string. .DESCRIPTION Signs a string. Used for certificate authentication. .PARAMETER Text The text to sign. .PARAMETER Certificate The certificate to sign with. Must have private key. .PARAMETER Padding The padding mechanism to use while signing. Defaults to "Pkcs1" .PARAMETER Algorithm The signing algorithm to use. Defaults to "SHA256" .PARAMETER Encoding Encoding of the source text. Defaults to UTF8 .EXAMPLE PS C:\> $token | ConvertTo-SignedString -Certificate $cert Signs the text stored in $token with the certificate stored in $cert #> [OutputType([string])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string[]] $Text, [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Security.Cryptography.RSASignaturePadding] $Padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1, [Security.Cryptography.HashAlgorithmName] $Algorithm = [Security.Cryptography.HashAlgorithmName]::SHA256, [System.Text.Encoding] $Encoding = [System.Text.Encoding]::UTF8 ) begin { $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) } process { foreach ($entry in $Text) { $inBytes = $Encoding.GetBytes($entry) $outBytes = $privateKey.SignData($inBytes, $Algorithm, $Padding) [convert]::ToBase64String($outBytes) } } } function Invoke-TerminatingException { <# .SYNOPSIS Throw a terminating exception in the context of the caller. .DESCRIPTION Throw a terminating exception in the context of the caller. Masks the actual code location from the end user in how the message will be displayed. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .PARAMETER Message The message to show the user. .PARAMETER Exception A nested exception to include in the exception object. .PARAMETER Category The category of the error. .PARAMETER ErrorRecord A full error record that was caught by the caller. Use this when you want to rethrow an existing error. .PARAMETER Target The target of the exception. .EXAMPLE PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module' Terminates the calling command, citing an unknown caller. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] $Cmdlet, [string] $Message, [System.Exception] $Exception, [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, [System.Management.Automation.ErrorRecord] $ErrorRecord, $Target ) process{ if ($ErrorRecord -and -not $Message) { $Cmdlet.ThrowTerminatingError($ErrorRecord) } $exceptionType = switch ($Category) { default { [System.Exception] } 'InvalidArgument' { [System.ArgumentException] } 'InvalidData' { [System.IO.InvalidDataException] } 'AuthenticationError' { [System.Security.Authentication.AuthenticationException] } 'InvalidOperation' { [System.InvalidOperationException] } } if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) } elseif ($ErrorRecord) { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) } else { $newException = $exceptionType::new($Message) } $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target) $Cmdlet.ThrowTerminatingError($record) } } function Set-ReconnectInfo { <# .SYNOPSIS Helper Utility to set the automatic reconnection information. .DESCRIPTION Helper Utility to set the automatic reconnection information. Registers the connection time, parameters used and command for ease of reuse. .PARAMETER BoundParameters The parameters the Connect-Graph* command was called with .PARAMETER NoReconnect Whether to not reconnect after all. .PARAMETER RefreshToken The refresh token returned after the calling command's connection. When provided, will be used to do the refreshing when possible. .EXAMPLE PS C:\> Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect Called from within a Connect-Graph* command, this will set itself to auto-reconnect. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $BoundParameters, [switch] $NoReconnect, [string] $RefreshToken ) process { if ($NoReconnect) { $script:lastConnect = @{ When = $null Command = $null Parameters = $null Refresh = $null } return } $script:lastConnect = @{ When = Get-Date Command = Get-Command (Get-PSCallStack)[1].InvocationInfo.MyCommand Parameters = $BoundParameters Refresh = $null } if ($RefreshToken) { $script:lastConnect.Refresh = $RefreshToken | ConvertTo-SecureString -AsPlainText -Force } } } function Update-Token { <# .SYNOPSIS Automatically reconnects if necessary, using the previous method of connecting. .DESCRIPTION Automatically reconnects if necessary, using the previous method of connecting. Called from within Invoke-GraphRequest, it ensures that tokens don't expire, especially during long-running queries. Will not cause errors directly, but the reconnection attempt might fail. .EXAMPLE PS C:\> Update-Token Automatically reconnects if necessary, using the previous method of connecting. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( ) process { # If no reconnection data is set, terminate if (-not $script:lastConnect) { return } if (-not $script:lastConnect.When) { return } # If the last connection is less than 50 minutes ago, terminate if ($script:lastConnect.When -gt (Get-Date).AddMinutes(-50)) { return } if ($script:lastConnect.Refresh) { Connect-GraphRefreshToken return } $command = $script:lastConnect.Command $param = $script:lastConnect.Parameters & $command @param } } function Connect-GraphAzure { <# .SYNOPSIS Connect to graph using your current Az session. .DESCRIPTION Connect to graph using your current Az session. Requires the Az.Accounts module and for the current session to already be connected via Connect-AzAccount. .PARAMETER Authority Authority to connect to. Defaults to: "https://graph.microsoft.com" .PARAMETER ShowDialog Whether to risk showing a dialog during authentication. If set to never, it will fail if not possible to do silent authentication. Defaults to: Auto .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphAzure Connect to graph via the current Az session #> [CmdletBinding()] param ( [string] $Authority = "https://graph.microsoft.com", [ValidateSet('Auto', 'Always', 'Never')] [string] $ShowDialog = 'Auto', [switch] $NoReconnect ) try { $azContext = Get-AzContext -ErrorAction Stop } catch { $PSCmdlet.ThrowTerminatingError($_) } try { $result = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate( $azContext.Account, $azContext.Environment, "$($azContext.Tenant.id)", $null, $ShowDialog, $null, $Authority ) } catch { $PSCmdlet.ThrowTerminatingError($_) } $script:token = $result.AccessToken Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect } function Connect-GraphBrowser { <# .SYNOPSIS Interactive logon using the Authorization flow and browser. Supports SSO. .DESCRIPTION Interactive logon using the Authorization flow and browser. Supports SSO. This flow requires an App Registration configured for the platform "Mobile and desktop applications". Its redirect Uri must be "http://localhost" On successful authentication .PARAMETER ClientID The ID of the registered app used with this authentication request. .PARAMETER TenantID The ID of the tenant connected to with this authentication request. .PARAMETER SelectAccount Forces account selection on logon. As this flow supports single-sign-on, it will otherwise not prompt for anything if already signed in. This could be a problem if you want to connect using another (e.g. an admin) account. .PARAMETER Scopes Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' .PARAMETER LocalPort The local port that should be redirected to. In order to process the authentication response, we need to listen to a local web request on some port. Usually needs not be redirected. Defaults to: 8080 .PARAMETER Resource The resource the token grants access to. Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' Only needed when connecting to another service. .PARAMETER Browser The path to the browser to use for the authentication flow. Provide the full path to the executable. The browser must accept the url to open as its only parameter. Defaults to Edge. .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphBrowser -ClientID '<ClientID>' -TenantID '<TenantID>' Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $TenantID, [Parameter(Mandatory = $true)] [string] $ClientID, [switch] $SelectAccount, [string[]] $Scopes = 'https://graph.microsoft.com/.default', [int] $LocalPort = 8080, [Uri] $Resource = 'https://graph.microsoft.com/', [string] $Browser = $script:browserPath, [switch] $NoReconnect ) process { Add-Type -AssemblyName System.Web $redirectUri = "http://localhost:$LocalPort" $actualScopes = foreach ($scope in $Scopes) { if ($scope -like 'https://*/*') { $scope } else { "{0}://{1}/{2}" -f $Resource.Scheme, $Resource.Host, $scope } } if (-not $NoReconnect) { $actualScopes = @($actualScopes) + 'offline_access' } $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/authorize?" $state = Get-Random $parameters = @{ client_id = $ClientID response_type = 'code' redirect_uri = $redirectUri response_mode = 'query' scope = $Scopes -join ' ' state = $state } if ($SelectAccount) { $parameters.prompt = 'select_account' } $paramStrings = foreach ($pair in $parameters.GetEnumerator()) { $pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '=' } $uriFinal = $uri + ($paramStrings -join '&') Write-Verbose "Authorize Uri: $uriFinal" $redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/MiniGraph/master/nothing-to-see-here.txt' if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) { $redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' } # Start local server to catch the redirect $http = [System.Net.HttpListener]::new() $http.Prefixes.Add("$redirectUri/") try { $http.Start() } catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError } # Execute in default browser & $Browser $uriFinal # Get Result $task = $http.GetContextAsync() $authorizationCode, $stateReturn, $sessionState = $null try { while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 200 } $context = $task.Result $context.Response.Redirect($redirectTo) $context.Response.Close() $authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&" } finally { $http.Stop() $http.Dispose() } if (-not $stateReturn) { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError } if ($state -ne $stateReturn.Split("=")[1]) { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation } $actualAuthorizationCode = $authorizationCode.Split("=")[1] $body = @{ client_id = $ClientID scope = $actualScopes -join " " code = $actualAuthorizationCode redirect_uri = $redirectUri grant_type = 'authorization_code' } $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop } catch { if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ } Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category } $script:token = $authResponse.access_token Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect -RefreshToken $authResponse.refresh_token } } function Connect-GraphCertificate { <# .SYNOPSIS Connect to graph as an application using a certificate .DESCRIPTION Connect to graph as an application using a certificate .PARAMETER Certificate The certificate to use for authentication. .PARAMETER TenantID The Guid of the tenant to connect to. .PARAMETER ClientID The ClientID / ApplicationID of the application to connect as. .PARAMETER Scopes The scopes to request when connecting. IN Application flows, this only determines the service for which to retrieve the scopes already configured on the App Registration. Defaults to graph API. .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> $cert = Get-Item -Path 'Cert:\CurrentUser\My\082D5CB4BA31EED7E2E522B39992E34871C92BF5' PS C:\> Connect-GraphCertificate -TenantID '0639f07d-76e1-49cb-82ac-abcdefabcdefa' -ClientID '0639f07d-76e1-49cb-82ac-1234567890123' -Certificate $cert Connect to graph with the specified cert stored in the current user's certificate store. .LINK https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateScript({ if (-not $_.HasPrivateKey) { throw "Certificate has no private key!" } $true })] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(Mandatory = $true)] [string] $TenantID, [Parameter(Mandatory = $true)] [string] $ClientID, [string[]] $Scopes = 'https://graph.microsoft.com/.default', [switch] $NoReconnect ) $jwtHeader = @{ alg = "RS256" typ = "JWT" x5t = [Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '=' } $encodedHeader = $jwtHeader | ConvertTo-Json | ConvertTo-Base64 $claims = @{ aud = "https://login.microsoftonline.com/$TenantID/v2.0" exp = ((Get-Date).AddMinutes(5) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int] iss = $ClientID jti = "$(New-Guid)" nbf = ((Get-Date) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int] sub = $ClientID } $encodedClaims = $claims | ConvertTo-Json | ConvertTo-Base64 $jwtPreliminary = $encodedHeader, $encodedClaims -join "." $jwtSigned = ($jwtPreliminary | ConvertTo-SignedString -Certificate $Certificate) -replace '\+', '-' -replace '/', '_' -replace '=' $jwt = $jwtPreliminary, $jwtSigned -join '.' $body = @{ client_id = $ClientID client_assertion = $jwt client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' scope = $Scopes -join ' ' grant_type = 'client_credentials' } $header = @{ Authorization = "Bearer $jwt" } $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" try { $script:token = (Invoke-RestMethod -Uri $uri -Method Post -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop).access_token } catch { $PSCmdlet.ThrowTerminatingError($_) } Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect } function Connect-GraphClientSecret { <# .SYNOPSIS Connects using a client secret. .DESCRIPTION Connects using a client secret. .PARAMETER ClientID The ID of the registered app used with this authentication request. .PARAMETER TenantID The ID of the tenant connected to with this authentication request. .PARAMETER ClientSecret The actual secret used for authenticating the request. .PARAMETER Scopes Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' .PARAMETER Resource The resource the token grants access to. Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' Only needed when connecting to another service. .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphClientSecret -ClientID '<ClientID>' -TenantID '<TenantID>' -ClientSecret $secret Connects to the specified tenant using the specified client and secret. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [string] $TenantID, [Parameter(Mandatory = $true)] [securestring] $ClientSecret, [string[]] $Scopes = 'https://graph.microsoft.com/.default', [string] $Resource = 'https://graph.microsoft.com/', [switch] $NoReconnect ) process { $body = @{ client_id = $ClientID client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password scope = $Scopes -join " " grant_type = 'client_credentials' resource = $Resource } try { $authResponse = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$TenantId/oauth2/token" -Body $body -ErrorAction Stop } catch { $PSCmdlet.ThrowTerminatingError($_) } $script:token = $authResponse.access_token Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect } } function Connect-GraphCredential { <# .SYNOPSIS Connect to graph using username and password. .DESCRIPTION Connect to graph using username and password. This logs into graph as a user, not as an application. Only cloud-only accounts can be used for this workflow. Consent to scopes must be granted before using them, as this command cannot show the consent prompt. .PARAMETER Credential Credentials of the user to connect as. .PARAMETER TenantID The Guid of the tenant to connect to. .PARAMETER ClientID The ClientID / ApplicationID of the application to use. .PARAMETER Scopes The permission scopes to request. .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphCredential -Credential max@contoso.com -ClientID $client -TenantID $tenant -Scopes 'user.read','user.readbasic.all' Connect as max@contoso.com with the rights to read user information. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSCredential] $Credential, [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [string] $TenantID, [string[]] $Scopes = 'user.read', [switch] $NoReconnect ) $request = @{ client_id = $ClientID scope = $Scopes -join " " username = $Credential.UserName password = $Credential.GetNetworkCredential().Password grant_type = 'password' } try { $answer = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop } catch { $PSCmdlet.ThrowTerminatingError($_) } $script:token = $answer.access_token Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect } function Connect-GraphDeviceCode { <# .SYNOPSIS Connects to Azure AD using the Device Code authentication workflow. .DESCRIPTION Connects to Azure AD using the Device Code authentication workflow. .PARAMETER ClientID The ID of the registered app used with this authentication request. .PARAMETER TenantID The ID of the tenant connected to with this authentication request. .PARAMETER Scopes Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' .PARAMETER Resource The resource the token grants access to. Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' Only needed when connecting to another service. .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphDeviceCode -ClientID '<ClientID>' -TenantID '<TenantID>' Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [string] $TenantID, [string[]] $Scopes = 'https://graph.microsoft.com/.default', [Uri] $Resource = 'https://graph.microsoft.com/', [switch] $NoReconnect ) $actualScopes = foreach ($scope in $Scopes) { if ($scope -like 'https://*/*') { $scope } else { "{0}://{1}/{2}" -f $Resource.Scheme, $Resource.Host, $scope } } try { $initialResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/devicecode" -Body @{ client_id = $ClientID scope = @($actualScopes) + 'offline_access' -join " " } -ErrorAction Stop } catch { throw } Write-Host $initialResponse.message $paramRetrieve = @{ Uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" Method = "POST" Body = @{ grant_type = "urn:ietf:params:oauth:grant-type:device_code" client_id = $ClientID device_code = $initialResponse.device_code } ErrorAction = 'Stop' } $limit = (Get-Date).AddSeconds($initialResponse.expires_in) while ($true) { if ((Get-Date) -gt $limit) { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Timelimit exceeded, device code authentication failed" -Category AuthenticationError } Start-Sleep -Seconds $initialResponse.interval try { $authResponse = Invoke-RestMethod @paramRetrieve } catch { if ($_ -match '"error":\s*"authorization_pending"') { continue } $PSCmdlet.ThrowTerminatingError($_) } if ($authResponse) { break } } $script:token = $authResponse.access_token Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect -RefreshToken $authResponse.refresh_token } function Connect-GraphToken { <# .SYNOPSIS Connect to graph using a token and the on behalf of flow. .DESCRIPTION Connect to graph using a token and the on behalf of flow. .PARAMETER Token The existing token to use for the request. .PARAMETER TenantID The Guid of the tenant to connect to. .PARAMETER ClientID The ClientID / ApplicationID of the application to connect as. .PARAMETER ClientSecret The secret used to authorize the OBO flow. .PARAMETER Scopes The scopes to request Defaults to: 'https://graph.microsoft.com/.default' .PARAMETER NoReconnect Disables automatic reconnection. By default, MiniGraph will automatically try to reaquire a new token before the old one expires. .EXAMPLE PS C:\> Connect-GraphToken -Token $token -TenantID $tenantID -ClientID $clientID -CLientSecret $secret Connect to graph using a token and the on behalf of flow. .LINK https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Token, [Parameter(Mandatory = $true)] [string] $TenantID, [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [SecureString] $ClientSecret, [string[]] $Scopes = 'https://graph.microsoft.com/.default', [switch] $NoReconnect ) $body = @{ grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer' client_id = $ClientID client_secret = ([PSCredential]::new("Whatever", $ClientSecret)).GetNetworkCredential().Password assertion = $Token scope = @($Scopes) requested_token_use = 'on_behalf_of' } $param = @{ Method = "POST" Uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" Body = $body ContentType = 'application/x-www-form-urlencoded' } try { $script:token = Invoke-RestMethod @param -ErrorAction Stop } catch { $PSCmdlet.ThrowTerminatingError($_) } Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect } function Invoke-GraphRequest { <# .SYNOPSIS Execute a request against the graph API .DESCRIPTION Execute a request against the graph API .PARAMETER Query The relative graph query with all conditions appended. .PARAMETER Method Which rest method to use. Defaults to GET. .PARAMETER ContentType Which content type to specify. Defaults to "Application/Json" .PARAMETER Body Any body to specify. Anything not a string, will be converted to json. .PARAMETER Raw Return the raw response, rather than processing the output. .PARAMETER NoPaging Only return the first set of data, rather than paging through the entire set. .PARAMETER Header Additional header entries to include beside authentication .EXAMPLE PS C:\> Invoke-GraphRequest -Query me Returns information about the current user. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Query, [Microsoft.PowerShell.Commands.WebRequestMethod] $Method = 'GET', [string] $ContentType = 'application/json', $Body, [switch] $Raw, [switch] $NoPaging, [hashtable] $Header = @{ } ) begin { Assert-GraphConnection -Cmdlet $PSCmdlet } process { $parameters = @{ Uri = "$($script:baseEndpoint)/$($Query.TrimStart("/"))" Method = $Method ContentType = $ContentType } if ($Body) { if ($Body -is [string]) { $parameters.Body = $Body } else { $parameters.Body = $Body | ConvertTo-Json -Compress -Depth 99 } } do { try { Update-Token } catch { throw } $parameters.Headers = @{ Authorization = "Bearer $($script:Token)" } + $Header try { $data = Invoke-RestMethod @parameters -ErrorAction Stop } catch { throw } if ($Raw) { $data } elseif ($data.Value) { $data.Value } elseif ($data -and $null -eq $data.Value) { $data } $parameters.Uri = $data.'@odata.nextLink' } until (-not $data.'@odata.nextLink' -or $NoPaging) } } function Invoke-GraphRequestBatch { <# .SYNOPSIS Invoke a batch request against the graph API .DESCRIPTION Invoke a batch request against the graph API in batches of twenty. .PARAMETER Request A list of requests to batch. Each entry should either be ... - A relative uri to query (what you would send to Invoke-GraphRequest) - A hashtable consisting of url (mandatory), method (optional), id (optional), body (optional), headers (optional) and dependsOn (optional). .PARAMETER Method The method to use with requests, that do not specify their method. Defaults to "GET" .PARAMETER Body The body to add to requests that do not specify their own body. .PARAMETER Header The header to add to requests that do not specify their own header. .EXAMPLE $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq true" $requests = @($servicePrincipals).ForEach{ "/servicePrincipals/$($_.id)/appRoleAssignments" } Invoke-GraphRequestBatch -Request $requests Retrieve the role assignments for all enabled service principals .EXAMPLE $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq false" $requests = @($servicePrincipals).ForEach{ "/servicePrincipals/$($_.id)" } Invoke-GraphRequestBatch -Request $requests -Body { accountEnabled = $true } -Method PATCH Enables all disabled service principals .EXAMPLE $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq true" $araCounter = 1 $idToSp = @{} $appRoleAssignmentsRequest = foreach ($sp in $servicePrincipals) { @{ url = "/servicePrincipals/$($sp.id)/appRoleAssignments" method = "GET" id = $araCounter } $idToSp[$araCounter] = $sp $araCounter++ } Invoke-GraphRequestBatch -Request $appRoleAssignmentsRequest Retrieve the role assignments for all enabled service principals #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object[]] $Request, [Microsoft.PowerShell.Commands.WebRequestMethod] $Method = 'Get', [hashtable] $Body, [hashtable] $Header ) begin { function ConvertTo-BatchRequest { [CmdletBinding()] param ( [object[]] $Request, [Microsoft.PowerShell.Commands.WebRequestMethod] $Method, $Cmdlet, [AllowNull()] [hashtable] $Body, [AllowNull()] [hashtable] $Header ) $defaultMethod = "$Method".ToUpper() $results = @{} $requests = foreach ($entry in $Request) { $newRequest = @{ url = '' method = $defaultMethod id = 0 } if ($Body) { $newRequest.body = $Body } if ($Header) { $newRequest.headers = $Header } if ($entry -is [string]) { $newRequest.url = $entry $newRequest continue } if (-not $entry.url) { Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Invalid batch request: No Url found! $entry" -Category InvalidArgument } $newRequest.url = $entry.url if ($entry.Method) { $newRequest.method = "$($entry.Method)".ToUpper() } if ($entry.id -as [int]) { $newRequest.id = $entry.id -as [int] $results[($entry.id -as [int])] = $newRequest } if ($entry.body) { $newRequest.body = $entry.body } if ($entry.headers) { $newRequest.headers = $entry.headers } if ($entry.dependsOn) { $newRequest.dependsOn } $newRequest } $index = 1 $finalList = foreach ($requestItem in $requests) { $requestItem.id = $requestItem.id -as [string] if ($requestItem.id) { $requestItem continue } while ($results[$index]) { $index++ } $requestItem.id = $index $results[$index] = $requestItem $requestItem } $finalList | Sort-Object { $_.id -as [int] } } } process { $batchSize = 20 # Currently hardcoded API limit $counter = [pscustomobject] @{ Value = 0 } $batches = ConvertTo-BatchRequest -Request $Request -Method $Method -Cmdlet $PSCmdlet -Body $Body -Header $Header | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } -AsHashTable foreach ($batch in ($batches.GetEnumerator() | Sort-Object -Property Key)) { [array] $innerResult = try { $jsonbody = @{requests = [array]$batch.Value } | ConvertTo-Json -Depth 42 -Compress (MiniGraph\Invoke-GraphRequest -Query '$batch' -Method Post -Body $jsonbody -ErrorAction Stop).responses } catch { Write-Error -Message "Error sending batch: $($_.Exception.Message)" -TargetObject $jsonbody continue } $throttledRequests = $innerResult | Where-Object status -EQ 429 $failedRequests = $innerResult | Where-Object { $_.status -ne 429 -and $_.status -in (400..499) } $successRequests = $innerResult | Where-Object status -In (200..299) foreach ($failedRequest in $failedRequests) { Write-Error -Message "Error in batch request $($failedRequest.id): $($failedRequest.body.error.message)" } if ($successRequests) { $successRequests } if ($throttledRequests) { $interval = ($throttledRequests.Headers | Sort-Object 'Retry-After' | Select-Object -Last 1).'Retry-After' Write-Verbose -Message "Throttled requests detected, waiting $interval seconds before retrying" Start-Sleep -Seconds $interval $retry = $Request | Where-Object id -In $throttledRequests.id if (-not $retry) { continue } try { (MiniGraph\Invoke-GraphRequestBatch -Name $Name -Request $retry -NoProgress -ErrorAction Stop).responses } catch { Write-Error -Message "Error sending retry batch: $($_.Exception.Message)" -TargetObject $retry } } } } } function Set-GraphEndpoint { <# .SYNOPSIS Specify which graph endpoint to use for subsequent requests. .DESCRIPTION Specify which graph endpoint to use for subsequent requests. .PARAMETER Type Which kind of endpoint to use. v1 or beta .PARAMETER Url Specify a custom Url as endpoint. Used to switch to a government cloud. .EXAMPLE PS C:\> Set-GraphEndpoint -Type beta Switch to using the beta graph endpoint #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Default')] [ValidateSet('v1','beta')] [string] $Type, [Parameter(Mandatory = $true, ParameterSetName = 'Url')] [string] $Url ) if ($Type) { switch ($Type) { 'v1' { $script:baseEndpoint = 'https://graph.microsoft.com/v1.0' } 'beta' { $script:baseEndpoint = 'https://graph.microsoft.com/beta' } } } if ($Url) { $script:baseEndpoint = $Url.Trim("/") } } # Graph Token used for connections $script:token = $null # Endpoint used for queries $script:baseEndpoint = 'https://graph.microsoft.com/v1.0' # Cached Connection Data $script:lastConnect = @{ When = $null Command = $null Parameters = $null Refresh = $null } # Used for Browser-Based interactive logon $script:browserPath = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' |