Azure.Function.Tools.psm1

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 Connect-ClientCertificate {
    <#
    .SYNOPSIS
        Connect to Azure AD as an application using a certificate
 
    .DESCRIPTION
        Connect to Azure AD 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 Scope
        The scope to request.
        Used to identify the service authenticating to.
        Example: 'https://graph.microsoft.com/.default'
 
    .EXAMPLE
        PS C:\> $cert = Get-Item -Path 'Cert:\CurrentUser\My\082D5CB4BA31EED7E2E522B39992E34871C92BF5'
        PS C:\> Connect-ClientCertificate -TenantID '0639f07d-76e1-49cb-82ac-abcdefabcdefa' -ClientID '0639f07d-76e1-49cb-82ac-1234567890123' -Certificate $cert
 
        Connect to Azure AD 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,

        [Parameter(Mandatory = $true)]
        [string]
        $Scope
    )

    $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                 = $Scope
        grant_type            = 'client_credentials'
    }
    $header = @{
        Authorization = "Bearer $jwt"
    }
    $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
    try { Invoke-RestMethod -Uri $uri -Method Post -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop }
    catch { $PSCmdlet.ThrowTerminatingError($_) }
}

function Connect-ClientSecret {
    <#
        .SYNOPSIS
            Connects to AzureAD using a client secret.
         
        .DESCRIPTION
            Connects to AzureAD 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 Resource
            The resource the token grants access to.
             
        .EXAMPLE
            PS C:\> Connect-ClientSecret -ClientID $clientID -TenantID $tenantID -ClientSecret $secret -Resource $apiID
         
            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,

        [Parameter(Mandatory = $true)]
        [string]
        $Resource
    )
        
    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($_) }
        $authResponse
    }
}

function Connect-DeviceCode {
    <#
    .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
        The scopes to request.
     
    .EXAMPLE
        PS C:\> Connect-DeviceCode -ClientID $clientID -TenantID $tenantID -Scopes 'api://d9b68662-0add-46ec-aab2-0123456788910/.default'
     
        Connects to the specified tenant using the specified client, prompting the user to authorize via Browser.
        Requestss the default scopes for the specified custom API
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    [CmdletBinding()]
    param (

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

    try {
        $initialResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/devicecode" -Body @{
            client_id = $ClientID
            scope     = $Scopes -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) {
            throw "Timelimit exceeded, device code authentication failed"
        }
        Start-Sleep -Seconds $initialResponse.interval
        try { $authResponse = Invoke-RestMethod @paramRetrieve }
        catch {
            if ($_ -match '"error":"authorization_pending"') { continue }
            $PSCmdlet.ThrowTerminatingError($_)
        }
        if ($authResponse) {
            break
        }
    }

    $authResponse
}

function Get-ConfigValue {
    <#
    .SYNOPSIS
        Returns a configured value.
     
    .DESCRIPTION
        Returns a configured value.
        Use Import-Config to define configuration values.
        Will write warnings if no data found.
     
    .PARAMETER Name
        The name of the setting to retrieve.
     
    .EXAMPLE
        PS C:\> Get-COnfigValue -Name VaultName
 
        Returns the value configured for the "VaultName" setting
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name
    )

    if (-not $Global:config) {
        Write-Warning "[Get-ConfigValue] No configuration defined yet."
        return
    }

    if ($Global:config.Keys -notcontains $Name) {
        Write-Warning "[Get-ConfigValue] Configuration entry not found: $Name"
        return
    }
    $Global:config.$Name
}

function Get-RestParameter {
    <#
    .SYNOPSIS
        Parses the rest request parameters for all values matching parameters on the specified command.
     
    .DESCRIPTION
        Parses the rest request parameters for all values matching parameters on the specified command.
        Returns a hashtable ready for splatting.
        Does NOT assert mandatory parameters are specified, so command invocation may fail.
     
    .PARAMETER Request
        The original rest request object, containing the caller's information such as parameters.
     
    .PARAMETER Command
        The command to which to bind input parameters.
     
    .EXAMPLE
        PS C:\> Get-RestParameter -Request $Request -Command Get-AzUser
 
        Retrieves all parameters on the incoming request that match a parameter on Get-AzUser
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        $Request,

        [Parameter(Mandatory = $true)]
        [string]
        $Command
    )

    $commandInfo = Get-Command -Name $Command
    $results = @{ }
    foreach ($parameter in $commandInfo.Parameters.Keys) {
        $value = Get-RestParameterValue -Request $Request -Name $parameter
        if ($null -ne $value) { $results[$parameter] = $value }
    }
    $results
}

function Get-RestParameterValue {
    <#
    .SYNOPSIS
        Extract the exact value of a parameter provided by the user.
     
    .DESCRIPTION
        Extract the exact value of a parameter provided by the user.
        Expects either query or body parameters from the rest call to the http trigger.
     
    .PARAMETER Request
        The request object provided as part of the function call.
     
    .PARAMETER Name
        The name of the parameter to provide.
     
    .EXAMPLE
        PS C:\> Get-RestParameterValue -Request $Request -Name Type
 
        Returns the value of the parameter "Type", as provided by the caller
    #>

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

        [Parameter(Mandatory = $true)]
        [string]
        $Name
    )

    if ($Request.Query.$Name) {
        return $Request.Query.$Name
    }
    $Request.Body.$Name
}

function Get-VaultCertificate {
    <#
    .SYNOPSIS
        Retrieve a certificate from Azure KeyVault.
     
    .DESCRIPTION
        Retrieve a certificate from Azure KeyVault.
        Expects to already be logged in using Connect-AzAccount.
     
    .PARAMETER SecretName
        The name of the secret under which the certificate is stored.
     
    .PARAMETER VaultName
        The name of the Key Vault from which to retrieve the certificate.
        Defaults to whatever is configured under the configuration entry "VaultName"
        (See Import-Config and Get-ConfigValue for details)
     
    .EXAMPLE
        PS C:\> Get-VaultCertificate -SecretName myCert
 
        Retrieves the certificate "myCert" from the configured default Key Vault
    #>

    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $SecretName,

        [string]
        $VaultName = (Get-ConfigValue -Name VaultName)
    )

    if (-not $VaultName) {
        throw "No vault name found! Either use the -VaultName parameter or provide a configuration setting for 'VaultName'"
    }

    try { $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -ErrorAction Stop }
    catch { throw }
    $certString = [PSCredential]::New("irrelevant", $secret.SecretValue).GetNetworkCredential().Password
    $bytes = [convert]::FromBase64String($certString)
    [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)
}

function Get-VaultCredential {
    <#
    .SYNOPSIS
        Reads a credential object from Azure KeyVault.
     
    .DESCRIPTION
        Reads a credential object from Azure KeyVault.
        There are two ways to provide a credential object through this command:
 
        + Inline: The credentials are stored in a single secret, separated by a pipe symbol:
                  <username>|<password>
        + Dual: The credentials are stored in two secrets. The secret names must then both start with the specified -SecretName,
                then end in ".UserName" and ".Password". E.g. "MySecret.UserName" and "MySecret.Password"
     
    .PARAMETER SecretName
        The name of the secret storing the credentials.
     
    .PARAMETER Type
        The type of credentials to retrieve.
        This can be either "Inline" or "Dual".
        See the description for details.
     
    .PARAMETER VaultName
        The name of the Key Vault from which to retrieve the certificate.
        Defaults to whatever is configured under the configuration entry "VaultName"
        (See Import-Config and Get-ConfigValue for details)
     
    .EXAMPLE
        PS C:\> Get-VaultCredential -SecretName myCred -Type Inline
         
        Retrieves the credentials stored in the myCred secret within the configured default Key Vault.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')]
    [OutputType([PSCredential])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $SecretName,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Inline','Dual')]
        [string]
        $Type,

        [string]
        $VaultName = (Get-ConfigValue -Name VaultName)
    )

    if (-not $VaultName) {
        throw "No vault name found! Either use the -VaultName parameter or provide a configuration setting for 'VaultName'"
    }

    switch ($Type) {
        'Inline' {
            try { $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -ErrorAction Stop }
            catch { throw }

            $value = [PSCredential]::New("Irrelevant", $secret.SecretValue).GetNetworkCredential().Password
            $name, $password = $value -split "\|",2
            [PSCredential]::new($name, ($password | ConvertTo-SecureString -AsPlainText -Force))
        }
        'Dual' {
            try { $secretName = Get-AzKeyVaultSecret -VaultName $VaultName -Name "$SecretName.UserName" -ErrorAction Stop }
            catch { throw }
            try { $secretPassword = Get-AzKeyVaultSecret -VaultName $VaultName -Name "$SecretName.Password" -ErrorAction Stop }
            catch { throw }
            $userName = [PSCredential]::New("Irrelevant", $secretName.SecretValue).GetNetworkCredential().Password
            [PSCredential]::new($userName, $secretPassword.SecretValue)
        }
    }
}

function Import-Config {
    <#
    .SYNOPSIS
        Imports a set of data into a global config variable.
     
    .DESCRIPTION
        Imports a set of data into a global config variable.
        Used by other commands for default values.
        Use Get-ConfigValue to read config settings.
 
        Supports both Json and psd1 files, does not resolve any nesting of values.
     
    .PARAMETER Path
        Path to the config file to read
     
    .EXAMPLE
        PS C:\> Import-Config -Path "$PSScriptRoot\config.psd1"
 
        Loads the config.psd1 file from the folder of the calling file's.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Path
    )

    $configData = if ($Path -like "*.psd1") {
        Import-PowerShellDataFile -Path $Path
    }
    else {
        Get-Content -Path $Path | ConvertFrom-Json
    }

    if (-not $global:config) {
        $global:config = @{ }
    }

    if ($configData -is [Hashtable]) {
        foreach ($pair in $configData.GetEnumerator()) {
            $global:config[$pair.Key] = $pair.Value
        }
        return
    }

    foreach ($property in $configData.PSObject.Properties) {
        $global:config[$property.Name] = $property.Value
    }
}

function Read-TokenScope {
    <#
    .SYNOPSIS
        Reads the scopes of the JWT token provided.
     
    .DESCRIPTION
        Reads the scopes of the JWT token provided.
        Use this to verify, whether a connecting user has the scopes required.
     
    .PARAMETER Token
        The JWT token to parse
     
    .PARAMETER Trigger
        The trigger object provided by an Azure Function Endpoint
     
    .EXAMPLE
        PS C:\> Read-TokenScope -Trigger $TriggerMetadata
         
        Returns the scopes of the user triggering the Azure Function App
    #>

    [cmdletbinding()]
    param(
        [Parameter(Mandatory=$true, ParameterSetName = 'Token')]
        [string]
        $Token,

        [Parameter(Mandatory=$true, ParameterSetName = 'Trigger')]
        $Trigger
    )

    if ($Trigger) {
        $Token = $Trigger.Headers.Authorization -replace "^Bearer "
    }

    $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/')
    # Pad with "=" until string length modulus 4 reaches 0
    while ($tokenPayload.Length % 4) { $tokenPayload += "=" }
    $bytes = [System.Convert]::FromBase64String($tokenPayload)
    ([System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json).roles
}

function Set-ConfigValue {
    <#
    .SYNOPSIS
        Set a config value.
     
    .DESCRIPTION
        Set a config value.
        Use Get-ConfigValue to later retrieve it.
     
    .PARAMETER Name
        Name of the setting to set
     
    .PARAMETER Value
        Value to set the setting to
     
    .EXAMPLE
        PS C:\> Set-ConfigValue -Name VaultName -Value myVault
     
        Set the config setting "VaultName" to the value "myVault"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $Name,
        [Parameter(Mandatory = $true, Position = 1)]
        $Value
    )

    if (-not $Global:config) {
        $Global:config = @{ }
    }
    $Global:config[$Name] = $Value
}

function Write-FunctionResult {
    <#
    .SYNOPSIS
        Reports back the output / result of the function app.
     
    .DESCRIPTION
        Reports back the output / result of the function app.
     
    .PARAMETER Status
        Whether the function succeeded or not.
     
    .PARAMETER Body
        Any data to include in the response.
     
    .EXAMPLE
        PS C:\> Write-FunctionResult -Status OK -Body $newUser
 
        Reports success while returning the content of $newUser as output
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.Net.HttpStatusCode]
        $Status,

        [AllowNull()]
        $Body
    )

    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = $Status
            Body       = $Body
        })
}