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 your default browser.
     
    .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,

        [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
        if ($Browser) { & $Browser $uriFinal }
        else { Start-Process $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 Get-GraphToken {
    <#
    .SYNOPSIS
        Retrieve the currently used graph token.
     
    .DESCRIPTION
        Retrieve the currently used graph token.
        Use one of the Connect-Graph* commands to first establish a connection.
        The token retrieved is a static copy of the current token - it will not be automatically refreshed once expired.
     
    .EXAMPLE
        PS C:\> Get-GraphToken
         
        Retrieve the currently used graph token.
    #>

    [CmdletBinding()]
    param (
        
    )
    process {
        [PSCustomObject]@{
            Token = $script:token
            Created = $script:lastConnect.When
            HasRefresh = $script:lastConnect.Refresh -as [bool]
            Endpoint = $script:baseEndpoint
        }
    }
}

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.
        Uses the full query if the query starts with http:// or https://.
     
    .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 ($Query -match '^http://|https://') {
            $parameters.Uri = $Query
        }
        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 -Request $retry -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'