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 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.
     
    .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
    )
    
    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 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.
 
    .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
    )

    $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                 = 'https://graph.microsoft.com/.default'
        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($_) }
}

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.
             
        .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/'
    )
        
    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
    }
}

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.
     
    .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'
    )
    
    $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
}

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.
     
    .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/'
    )

    $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 -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":"authorization_pending"') { continue }
            $PSCmdlet.ThrowTerminatingError($_)
        }
        if ($authResponse) {
            break
        }
    }

    $script:token = $authResponse.access_token
}

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
            Headers     = @{ Authorization = "Bearer $($script:Token)" } + $Header
            ContentType = $ContentType
        }
        if ($Body) {
            if ($Body -is [string]) { $parameters.Body = $Body }
            else { $parameters.Body = $Body | ConvertTo-Json -Compress -Depth 99 }
        }
        do {
            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 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'