RestConnect.psm1

class Token {
    #region Token Data
    [string]$AccessToken
    [System.DateTime]$ValidAfter
    [System.DateTime]$ValidUntil
    [string[]]$Scopes
    #endregion Token Data
    
    #region Connection Data
    [string]$Type
    [string]$ClientID
    [string]$TenantID
    [string]$ServiceUrl
    [string]$Resource
    
    # Workflow: Client Secret
    [System.Security.SecureString]$ClientSecret
    
    # Workflow: Certificate
    [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate

    # Workflow: Username & Password
    [PSCredential]$Credential
    #endregion Connection Data
    
    #region Extension Data
    [hashtable]$Data = @{ }
    [scriptblock]$GetHeaderCode
    [scriptblock]$RefreshTokenCode
    [hashtable]$ExtraHeaderContent = @{ }
    #endregion Extension Data

    #region Constructors
    Token([string]$ClientID, [string]$TenantID, [Securestring]$ClientSecret, [string]$ServiceUrl, [string]$Resource) {
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.ClientSecret = $ClientSecret
        $this.ServiceUrl = $ServiceUrl
        $this.Resource = $Resource
        if (-not $Resource) {
            $uri = [uri]$ServiceUrl
            $this.Resource = '{0}://{1}' -f $uri.Scheme, $uri.Host
        }
        $this.Type = 'ClientSecret'
    }

    Token([string]$ClientID, [string]$TenantID, [pscredential]$Credential, [string]$ServiceUrl, [string]$Resource) {
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.Credential = $Credential
        $this.ServiceUrl = $ServiceUrl
        $this.Resource = $Resource
        if (-not $Resource) {
            $uri = [uri]$ServiceUrl
            $this.Resource = '{0}://{1}' -f $uri.Scheme, $uri.Host
        }
        $this.Type = 'UsernamePassword'
    }

    Token([string]$ClientID, [string]$TenantID, [bool]$DeviceCode, [string]$ServiceUrl, [string]$Resource) {
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.ServiceUrl = $ServiceUrl
        $this.Resource = $Resource
        if (-not $Resource) {
            $uri = [uri]$ServiceUrl
            $this.Resource = '{0}://{1}' -f $uri.Scheme, $uri.Host
        }
        $this.Type = 'DeviceCode'
    }
    
    Token([string]$ClientID, [string]$TenantID, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [string]$ServiceUrl, [string]$Resource) {
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.Certificate = $Certificate
        $this.ServiceUrl = $ServiceUrl
        $this.Resource = $Resource
        if (-not $Resource) {
            $uri = [uri]$ServiceUrl
            $this.Resource = '{0}://{1}' -f $uri.Scheme, $uri.Host
        }
        $this.Type = 'Certificate'
    }

    Token() {
        
    }
    #endregion Constructors

    [void]SetTokenMetadata([PSObject] $AuthToken) {
        $this.AccessToken = $AuthToken.AccessToken
        $this.ValidAfter = $AuthToken.ValidAfter
        $this.ValidUntil = $AuthToken.ValidUntil
        $this.Scopes = $AuthToken.Scopes
    }

    [hashtable]GetHeader() {
        if ($this.GetHeaderCode) { $headerHash = & $this.GetHeaderCode $this }
        else { $headerHash = @{ Authorization = "Bearer $($this.AccessToken)" } }

        foreach ($pair in $this.ExtraHeaderContent.GetEnumerator()) {
            $headerHash[$pair.Key] = $pair.Value
        }

        return $headerHash
    }
}

function Connect-ServiceCertificate {
    <#
    .SYNOPSIS
        Connects to AAD using a application ID and a certificate.
     
    .DESCRIPTION
        Connects to AAD using a application ID and a certificate.
 
    .PARAMETER Resource
        The resource to authenticate to.
 
    .PARAMETER Certificate
        The certificate to use for authentication.
     
    .PARAMETER TenantID
        The ID of the tenant/directory to connect to.
     
    .PARAMETER ClientID
        The ID of the registered application used to authenticate as.
     
    .EXAMPLE
        PS C:\> Connect-ServiceCertificate -Resource $token.Resource -Certificate $cert -TenantID $tenantID -ClientID $clientID
     
        Connects to the specified tenant using the specified app & cert.
     
    .LINK
        https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $ClientID
    )
    
    #region Build Signature Payload
    $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 '.'
    #endregion Build Signature Payload
    
    $body = @{
        client_id             = $ClientID
        client_assertion      = $jwt
        client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
        scope                 = "$($Resource)/.default"
        grant_type            = 'client_credentials'
    }
    $header = @{
        Authorization = "Bearer $jwt"
    }
    $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
    
    try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop }
    catch { throw }
    
    [pscustomobject]@{
        AccessToken = $authResponse.access_token
        ValidAfter  = Get-Date
        ValidUntil  = (Get-Date).AddSeconds($authResponse.expires_in)
        Scopes      = @()
    }
}

function Connect-ServiceClientSecret {
    <#
    .SYNOPSIS
        Connects using a client secret.
     
    .DESCRIPTION
        Connects using a client secret.
     
    .PARAMETER Resource
        The resource to authenticate to.
 
    .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.
     
    .EXAMPLE
        PS C:\> Connect-ServiceClientSecret -Resource $token.Resource -ClientID '<ClientID>' -TenantID '<TenantID>' -ClientSecret $secret
     
        Connects to the specified tenant using the specified client and secret.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [Parameter(Mandatory = $true)]
        [securestring]
        $ClientSecret
    )
    
    process {
        $body = @{
            resource      = $Resource
            client_id     = $ClientID
            client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password
            grant_type    = 'client_credentials'
        }
        try { $authResponse = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$TenantId/oauth2/token" -Body $body -ErrorAction Stop }
        catch { throw }
        
        [pscustomobject]@{
            AccessToken = $authResponse.access_token
            ValidAfter  = (Get-Date -Date '1970-01-01').AddSeconds($authResponse.not_before).ToLocalTime()
            ValidUntil  = (Get-Date -Date '1970-01-01').AddSeconds($authResponse.expires_on).ToLocalTime()
            Scopes      = @()
        }
    }
}

function Connect-ServiceDeviceCode {
    <#
    .SYNOPSIS
        Connects to Azure AD using the Device Code authentication workflow.
     
    .DESCRIPTION
        Connects to Azure AD using the Device Code authentication workflow.
     
    .PARAMETER Resource
        The resource to authenticate to.
 
    .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
        The scopes to request.
        Automatically scoped to the service specified via Service Url.
        Defaults to ".Default"
     
    .EXAMPLE
        PS C:\> Connect-ServiceDeviceCode -ServiceUrl $url -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]
        $Resource,

        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [string[]]
        $Scopes = '.default'
    )

    $actualScopes = foreach ($scope in $Scopes) {
        if ($scope -like 'https://*/*') { $scope; continue }
        if ($scope -like "$Resource/*") { $scope; continue }
        "$Resource/$scope"
    }
    if (@($actualScopes).Count -gt 1 -and ($actualScopes | Where-Object { $_ -like '*/.default' })) {
        $actualScopes = $actualScopes | Where-Object { $_ -notlike '*/.default' }
    }

    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 { continue }
        if ($authResponse) {
            break
        }
    }

    $notBefore = Get-Date
    if ($authResponse.not_before) {
        $notBefore = (Get-Date -Date '1970-01-01').AddSeconds($authResponse.not_before).ToLocalTime()
    }
    $notAfter = (Get-Date).AddHours(1)
    if ($authResponse.expires_on) {
        $notAfter = (Get-Date -Date '1970-01-01').AddSeconds($authResponse.expires_on).ToLocalTime()
    }
    if ($authResponse.expires_in) {
        $notAfter = (Get-Date).AddSeconds($authResponse.expires_in).ToLocalTime()
    }
    

    [pscustomobject]@{
        AccessToken = $authResponse.access_token
        ValidAfter  = $notBefore
        ValidUntil  = $notAfter
        Scopes      = $authResponse.scope -split " "
    }
}

function Connect-ServicePassword {
    <#
    .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 Resource
        The resource to authenticate to.
 
    .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 -Service MyAPI -ServiceUrl $url -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)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCredential]
        $Credential,
        
        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [string[]]
        $Scopes = '.default'
    )

    $actualScopes = foreach ($scope in $Scopes) {
        if ($scope -like 'https://*/*') { $scope; continue }
        if ($scope -like "$Resource/*") { $scope; continue }
        "$Resource/$scope"
    }
    if (@($actualScopes).Count -gt 1 -and ($actualScopes | Where-Object { $_ -like '*/.default' })) {
        $actualScopes = $actualScopes | Where-Object { $_ -notlike '*/.default' }
    }
    
    $request = @{
        client_id  = $ClientID
        scope      = $actualScopes -join " "
        username   = $Credential.UserName
        password   = $Credential.GetNetworkCredential().Password
        grant_type = 'password'
    }
    
    try { $authResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop }
    catch { throw }
    
    [pscustomobject]@{
        AccessToken = $authResponse.access_token
        ValidAfter  = (Get-Date).AddMinutes(-5)
        ValidUntil  = (Get-Date).AddSeconds($authResponse.expires_in)
        Scopes      = @($scope -split " ")
    }
}

function ConvertTo-Base64 {
<#
    .SYNOPSIS
        Converts the input-string to its base 64 encoded string form.
     
    .DESCRIPTION
        Converts the input-string to its base 64 encoded string form.
     
    .PARAMETER Text
        The text to convert.
     
    .PARAMETER Encoding
        The encoding of the input text.
        Used to correctly translate the input string into bytes before converting those to base 64.
        Defaults to UTF8
     
    .EXAMPLE
        PS C:\> Get-Content .\code.ps1 -Raw | ConvertTo-Base64
     
        Reads the input file and converts its content into base64.
#>

    [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-QueryString {
    <#
    .SYNOPSIS
        Convert conditions in a hashtable to a Query string to append to a webrequest.
     
    .DESCRIPTION
        Convert conditions in a hashtable to a Query string to append to a webrequest.
     
    .PARAMETER QueryHash
        Hashtable of query modifiers - usually filter conditions - to include in a web request.
     
    .EXAMPLE
        PS C:\> ConvertTo-QueryString -QueryHash $Query
 
        Converts the conditions in the specified hashtable to a Query string to append to a webrequest.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [Hashtable]
        $QueryHash
    )

    process {
        $elements = foreach ($pair in $QueryHash.GetEnumerator()) {
            '{0}={1}' -f $pair.Name, ($pair.Value -join ",")
        }
        '?{0}' -f ($elements -join '&')
    }
}

function ConvertTo-SignedString {
<#
    .SYNOPSIS
        Signs input string with the offered certificate.
     
    .DESCRIPTION
        Signs input string with the offered certificate.
     
    .PARAMETER Text
        The text to sign.
     
    .PARAMETER Certificate
        The certificate to sign with.
        The Private Key must be available.
     
    .PARAMETER Padding
        What RSA Signature padding to use.
        Defaults to Pkcs1
     
    .PARAMETER Algorithm
        What algorithm to use for signing.
        Defaults to SHA256
     
    .PARAMETER Encoding
        The encoding to use for transforming the text to bytes before signing it.
        Defaults to UTF8
     
    .EXAMPLE
        PS C:\> ConvertTo-SignedString -Text $token
     
        Signs the specified token
#>

    [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
    )
    
    process {
        foreach ($entry in $Text) {
            $inBytes = $Encoding.GetBytes($entry)
            $outBytes = $Certificate.PrivateKey.SignData($inBytes, $Algorithm, $Padding)
            [convert]::ToBase64String($outBytes)
        }
    }
}

function Get-Token {
<#
    .SYNOPSIS
        Retrieve the OAuth token to use for rest queries.
     
    .DESCRIPTION
        Retrieve the OAuth token to use for rest queries.
     
    .PARAMETER Service
        The service for which the token should be returned.
     
    .PARAMETER RequiredScopes
        Which scopes are needed for the token.
        In user-delegate authentication workflows, it will automatically try to add thoose scopes if not present yet.
        NOT YET IMPLEMENTED (no user-delegate authentication workflows implemented yet)
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the caller.
        Used to kill the caller with in case of error.
     
    .EXAMPLE
        PS C:\> Get-Token -Service Endpoint -Cmdlet $PSCmdlet
     
        Retrieve the current access token for defender for endpoint.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Service,
        
        [string[]]
        $RequiredScopes = @(),
        
        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    
    begin {
        #region Utility Functions
        function Update-Token {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [string]
                $Service
            )
            
            $token = $script:tokens[$Service]
            $param = @{
                Service = $Service
                Resource = $token.Resource
                ClientID = $token.ClientID
                TenantID = $token.TenantID
            }
            switch ($token.Type) {
                'Certificate' {
                    try { Connect-RestService @param -Certificate $token.Certificate }
                    catch { throw }
                }
                'ClientSecret' {
                    try { Connect-RestService @param -ClientSecret $token.ClientSecret }
                    catch { throw }
                }
                'UsernamePassword' {
                    try { Connect-RestService @param -Credential $token.Credential }
                    catch { throw }
                }
                'DeviceCode' {
                    try { Connect-RestService @param -Scopes $token.Scopes -DeviceCode }
                    catch { throw }
                }
                default {
                    if (-not $token.RefreshTokenCode) { throw "Unable to refresh connection to $Service - no refresh logic registered!" }
                    try { & $token.RefreshTokenCode $token }
                    catch { throw }
                }
            }
        }
        #endregion Utility Functions
    }
    process {
        $token = $script:tokens[$Service]
        if (-not $token) {
            Invoke-TerminatingException -Cmdlet $Cmdlet -Message "No token found for Service $Service. Establish a connection first!"
        }
        if ($token.ValidUntil -gt (Get-Date).AddMinutes(2)) {
            return $token
        }
        
        try { Update-Token -Service $Service -ErrorAction Stop }
        catch {
            Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Failed to refresh the access token" -ErrorRecord $_
        }
        
        $script:tokens[$Service]
    }
}

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) {
            try { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) }
            catch { $newException = [System.Exception]::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 Resolve-Certificate {
    <#
    .SYNOPSIS
        Resolves the certificate to use for authentication.
     
    .DESCRIPTION
        Resolves the certificate to use for authentication.
        Offers a centralized way to resolve certificate based on parameters passed through.
 
        Silently returns nothing if no match was found, which needs to be handled by the caller.
     
    .PARAMETER BoundParameters
        The parameters passed to the calling command.
        Will pick certificate-related parameters and figure out the cert to use from that.
 
        - Certificate: Assumes a full certificate specified and returns it
        - CertificateThumbprint: Searches current user and system cert store for a matching cert to use
        - CertificateName: Searches current user and system cert store for a matching cert to use (based on subject)
        - CertificatePath & CertificatePassword: Loads certificate from file
 
        Will be processed in the order above if multiple options are specified.
     
    .EXAMPLE
        PS C:\> Resolve-Certificate -BoundParameters $PSBoundParameters
 
        Resolves the certificate to use for authentication.
    #>

    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    [CmdletBinding()]
    param (
        $BoundParameters
    )
        
    if ($BoundParameters.Certificate) { return $BoundParameters.Certificate }
    if ($BoundParameters.CertificateThumbprint) {
        if (Test-Path -Path "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)") {
            return Get-Item "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)"
        }
        if (Test-Path -Path "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)") {
            return Get-Item "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)"
        }
    }
    if ($BoundParameters.CertificateName) {
        if ($certificate = (Get-ChildItem 'Cert:\CurrentUser\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) {
            return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1
        }
        if ($certificate = (Get-ChildItem 'Cert:\LocalMachine\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) {
            return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1
        }
    }
    if ($BoundParameters.CertificatePath) {
        try { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($BoundParameters.CertificatePath, $BoundParameters.CertificatePassword) }
        catch {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to load certificate from file '$($BoundParameters.CertificatePath)': $_" -ErrorRecord $_
        }
    }
}

function Assert-RestConnection
{
<#
    .SYNOPSIS
        Asserts a connection to the specified rest api has been established.
     
    .DESCRIPTION
        Asserts a connection to the specified rest api has been established.
        Fails the calling command in a terminating exception if not connected yet.
         
    .PARAMETER Service
        The service to which a connection needs to be established.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to execute the terminating exception in the caller scope if needed.
     
    .EXAMPLE
        PS C:\> Assert-Connection -Service 'Endpoint' -Cmdlet $PSCmdlet
     
        Silently does nothing if already connected to the service 'Endpoint'.
        Kills the calling command if not yet connected.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Service,
        
        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    
    process
    {
        if ($script:tokens[$Service]) { return }
        
        $message = "Not connected yet! Use Connect-RestService (or a service specific connection method) to establish a connection first."
        if ($script:serviceMetadata.$Service.NotConnectedMessage) {
            $message = $script:serviceMetadata.$Service.NotConnectedMessage
        }

        Invoke-TerminatingException -Cmdlet $Cmdlet -Message $message -Category ConnectionError
    }
}

function Connect-RestService {
    <#
        .SYNOPSIS
            Establish a connection to a REST API.
         
        .DESCRIPTION
            Establish a connection to a REST API.
            Prerequisite before executing any requests / commands.
             
            Note:
            Used for authenticating against Microsoft Authentication services.
            For other authentication services, use "Set-RestServiceConnection" instead.
 
        .PARAMETER Service
            The name of the service to connect to.
            Label associated with the token generated, the same must be used when
            callng the Invoke-RestCommand command to associate the request with the connection.
         
        .PARAMETER ServiceUrl
            The base url to the service connecting to.
            Used for authentication, scopes and executing requests.
 
        .PARAMETER ClientID
            ID of the registered/enterprise application used for authentication.
         
        .PARAMETER TenantID
            The ID of the tenant/directory to connect to.
         
        .PARAMETER Scopes
            Any scopes to include in the request.
            Only used for interactive/delegate workflows, ignored for Certificate based authentication or when using Client Secrets.
 
        .PARAMETER DeviceCode
            Use the Device Code delegate authentication flow.
            This will prompt the user to complete login via browser.
         
        .PARAMETER Certificate
            The Certificate object used to authenticate with.
             
            Part of the Application Certificate authentication workflow.
         
        .PARAMETER CertificateThumbprint
            Thumbprint of the certificate to authenticate with.
            The certificate must be stored either in the user or computer certificate store.
             
            Part of the Application Certificate authentication workflow.
         
        .PARAMETER CertificateName
            The name/subject of the certificate to authenticate with.
            The certificate must be stored either in the user or computer certificate store.
            The newest certificate with a private key will be chosen.
             
            Part of the Application Certificate authentication workflow.
         
        .PARAMETER CertificatePath
            Path to a PFX file containing the certificate to authenticate with.
             
            Part of the Application Certificate authentication workflow.
         
        .PARAMETER CertificatePassword
            Password to use to read a PFX certificate file.
            Only used together with -CertificatePath.
             
            Part of the Application Certificate authentication workflow.
         
        .PARAMETER ClientSecret
            The client secret configured in the registered/enterprise application.
             
            Part of the Client Secret Certificate authentication workflow.
 
        .PARAMETER Credential
            The credentials to use to authenticate as a user.
 
            Part of the Username and Password delegate authentication workflow.
            Note: This workflow only works with cloud-only accounts and requires scopes to be pre-approved.
 
        .PARAMETER Resource
            The resource to authenticate to.
            Defaults to the service url's base-path.
         
        .EXAMPLE
            PS C:\> Connect-RestService -Service MyAPI -ServiceUrl $url -ClientID $clientID -TenantID $tenantID -Certificate $cert
         
            Establish a connection to a rest API using the provided certificate.
         
        .EXAMPLE
            PS C:\> Connect-RestService -Service MyAPI -ServiceUrl $url -ClientID $clientID -TenantID $tenantID -CertificatePath C:\secrets\certs\mde.pfx -CertificatePassword (Read-Host -AsSecureString)
         
            Establish a connection to a rest API using the provided certificate file.
            Prompts you to enter the certificate-file's password first.
         
        .EXAMPLE
            PS C:\> Connect-RestService -Service MyAPI -ServiceUrl $url -ClientID $clientID -TenantID $tenantID -ClientSecret $secret
         
            Establish a connection to a rest API using a client secret.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Service,

        [Parameter(Mandatory = $true)]
        [string]
        $ServiceUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
            
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
            
        [string[]]
        $Scopes,

        [Parameter(ParameterSetName = 'DeviceCode')]
        [switch]
        $DeviceCode,
            
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
            
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateThumbprint,
            
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateName,
            
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificatePath,
            
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.SecureString]
        $CertificatePassword,
            
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [System.Security.SecureString]
        $ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [PSCredential]
        $Credential,

        [string]
        $Resource
    )
    
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'AppSecret' {
                $serviceToken = [Token]::new($ClientID, $TenantID, $ClientSecret, $ServiceUrl, $Resource)
                try { $authToken = Connect-ServiceClientSecret -Resource $serviceToken.Resource -ClientID $ClientID -TenantID $TenantID -ClientSecret $ClientSecret -ErrorAction Stop }
                catch {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_
                }
                $serviceToken.SetTokenMetadata($authToken)
                $script:tokens[$Service] = $serviceToken
            }
            'AppCertificate' {
                try { $cert = Resolve-Certificate -BoundParameters $PSBoundParameters }
                catch {
                    throw
                }
                if (-not $cert) {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "No certificate found to authenticate with!"
                }
                if (-not $cert.HasPrivateKey) {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Certificate has no private key: $($cert.Thumbprint)"
                }
                if (-not $cert.PrivateKey) {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to access private key on Certificate $($cert.Thumbprint)"
                }
                
                $serviceToken = [Token]::new($ClientID, $TenantID, $cert, $ServiceUrl, $Resource)
                try { $authToken = Connect-ServiceCertificate -Resource $serviceToken.Resource -ClientID $ClientID -TenantID $TenantID -Certificate $cert -ErrorAction Stop }
                catch {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_
                }
                $serviceToken.SetTokenMetadata($authToken)
                $script:tokens[$Service] = $serviceToken
            }
            'UsernamePassword' {
                $serviceToken = [Token]::new($ClientID, $TenantID, $Credential, $ServiceUrl, $Resource)
                try { $authToken = Connect-ServicePassword -Resource $serviceToken.Resource -ClientID $ClientID -TenantID $TenantID -Credential $Credential -ErrorAction Stop }
                catch {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_
                }
                $serviceToken.SetTokenMetadata($authToken)
                $script:tokens[$Service] = $serviceToken
            }
            'DeviceCode' {
                $serviceToken = [Token]::new($ClientID, $TenantID, $true, $ServiceUrl, $Resource)
                try { $authToken = Connect-ServiceDeviceCode -Resource $serviceToken.Resource -ClientID $ClientID -TenantID $TenantID -Scopes $Scopes -ErrorAction Stop }
                catch {
                    Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_
                }
                $serviceToken.SetTokenMetadata($authToken)
                $script:tokens[$Service] = $serviceToken
            }
        }
    }
}

function Invoke-RestRequest
{
<#
    .SYNOPSIS
        Executes a web request against a rest API.
     
    .DESCRIPTION
        Executes a web request against a rest API.
        Handles all the authentication details once connected using Connect-RestService.
     
    .PARAMETER Path
        The relative path of the endpoint to query.
     
    .PARAMETER Body
        Any body content needed for the request.
 
    .PARAMETER Query
        Any query content to include in the request.
        In opposite to -Body this is attached to the request Url and usually used for filtering.
     
    .PARAMETER Method
        The Rest Method to use.
        Defaults to GET
     
    .PARAMETER RequiredScopes
        NOT IMPLEMENTED YET
        Any authentication scopes needed.
        When connected as a user, it will automatically try to re-authenticate with the correct scopes if they are missing in the current session.
     
    .PARAMETER Service
        Which service to execute against.
 
    .PARAMETER Header
        Additional header data to include.
     
    .EXAMPLE
        PS C:\> Invoke-RestRequest -Path 'alerts' -RequiredScopes 'Alert.Read' -Service mde
     
        Executes a GET request against the "mde" service's alerts endpoint.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Path,
        
        [Hashtable]
        $Body = @{ },

        [Hashtable]
        $Query = @{ },
        
        [string]
        $Method = 'GET',
        
        [string[]]
        $RequiredScopes,
        
        [Parameter(Mandatory = $true)]
        [string]
        $Service,

        [Hashtable]
        $Header = @{ }
    )
    
    begin{
        Assert-RestConnection -Service $Service -Cmdlet $PSCmdlet
        $token = Get-Token -Service $Service -RequiredScopes $RequiredScopes -Cmdlet $PSCmdlet
        $baseUri = $token.ServiceUrl
    }
    process
    {
        $parameters = @{
            Method = $Method
            Uri = "$($baseUri)/$($Path.TrimStart('/'))"
            Headers = $token.GetHeader() + $Header
        }
        if ($Path -match '^http://|^https://') { $parameters.Uri = $Path }
        
        if ($Body.Count -gt 0) {
            $parameters.Body = $Body | ConvertTo-Json -Compress -Depth 99
        }
        if ($Query.Count -gt 0) {
            $parameters.Uri += ConvertTo-QueryString -QueryHash $Query
        }
        while ($parameters.Uri) {
            try { $result = Invoke-RestMethod @parameters -ErrorAction Stop }
            catch {
                Write-Error $_
                break
            }
            if ($result.Value) { $result.Value }
            else { $result }
            $parameters.Uri = $result.'@odata.nextLink'
        }
    }
}

function Set-RestConnection {
    <#
    .SYNOPSIS
        Register an externally provided authenticated connection.
     
    .DESCRIPTION
        Register an externally provided authenticated connection.
        Use this to connect to services not covered behind Azure AD authentication.
     
    .PARAMETER Service
        Name of the service to configure.
        Creates a new service/connection registration if the name doesn't exist yet.
     
    .PARAMETER ServiceUrl
        Url used to connect to the service.
        For example, "https://graph.microsoft.com/beta" is the ServiceUrl for the beta version of the MS graph api.
     
    .PARAMETER ValidAfter
        Starting when the token is valid
     
    .PARAMETER ValidUntil
        Until when the token is valid
     
    .PARAMETER AccessToken
        The token string used for authentication.
        Optional if your connection is established via data provided in -Data
     
    .PARAMETER Scopes
        The scopes/permissions applicable to your session.
        For documentation purposes only at the moment.
     
    .PARAMETER GetHeaderCode
        A scriptblock that will return a hashtable used as header in each webrequest.
        This is generally the "Authorization" header used to authenticate individual requests.
        Receives the token object as argument.
     
    .PARAMETER RefreshTokenCode
        Logic used to refresh the connection.
        Receives the token object as argument.
     
    .PARAMETER Data
        A hashtable of additional data to store in the token object.
        Use this for any data needed by the scriptblock logic for producing the header or refreshing the connection.
 
    .PARAMETER ExtraHeaderContent
        Additional header data to include in any request sent through this connection.
        This header is _not_ used for any connections, but on each request started with Invoke-RestRequest.
     
    .EXAMPLE
        PS C:\> Set-RestConnection -Service MyService -ServiceUrl "https://MyService.contoso.com/api" -Data $data -GetHeaderCode $headerCode -RefreshTokenCode $refreshCode
 
        Registers a new service connection to the MyService API
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Service,

        [string]
        $ServiceUrl,

        [DateTime]
        $ValidAfter,

        [datetime]
        $ValidUntil,

        [string]
        $AccessToken,

        [string[]]
        $Scopes,

        [scriptblock]
        $GetHeaderCode,

        [scriptblock]
        $RefreshTokenCode,

        [hashtable]
        $Data,

        [hashtable]
        $ExtraHeaderContent
    )
    $commonParameters = 'Verbose','Debug','ErrorAction','WarningAction','InformationAction','ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable'
    $token = $script:tokens[$Service]
    if (-not $token) {
        $token = [Token]::new()
        $token.Type = 'Custom'
        $script:tokens[$Service] = $token
    }

    foreach ($key in $PSBoundParameters.Keys) {
        if ($key -eq 'Service') { continue }
        if ($key -in $commonParameters) { continue }
        $token.$key = $PSBoundParameters.$key
    }
}

function Set-RestServiceMetadata {
    <#
    .SYNOPSIS
        Define service metadata for a connected service.
     
    .DESCRIPTION
        Define service metadata for a connected service.
        This allows defining some behavior around a registered service, even before connection is established.
     
    .PARAMETER Service
        The name of the service to configure.
     
    .PARAMETER NotConnectedMessage
        The message to display when trying to execute over a service not yet connected to.
        Allows overriding the default "Please run Connect-RestService" message.
     
    .EXAMPLE
        PS C:\> Set-RestServiceMetadata -Service AzureDevOps -NotConnectedMessage $message
         
        Override the default error message when trying to run AzureDevOps commands before connecting.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Service,

        [string]
        $NotConnectedMessage
    )

    if (-not $script:serviceMetadata[$Service]) {
        $script:serviceMetadata[$Service] = @{
            Name = $Service
            NotConnectedMessage = ''
        }
    }

    if ($PSBoundParameters.Keys -contains 'NotConnectedMessage') {
        $script:serviceMetadata[$Service].NotConnectedMessage = $NotConnectedMessage
    }
}

# Store the tokens connected through
$script:tokens = @{ }

# Store Service Metadata
$script:serviceMetadata = @{ }