MSGraphPSEssentials.psm1

#Requires -Version 5.1
using namespace System
using namespace System.Management.Automation.Host
using namespace System.Runtime.InteropServices
using namespace System.Security.Cryptography
using namespace System.Security.Cryptography.X509Certificates

<# Release Notes for v0.5.5 (2021-09-08):
 
    - Added [-ExoEwsAppOnlyScope] switch parameter for New-MSGraphAccessToken's ClientCredentials parameter sets.
        -- This will change the scope to https://outlook.office365.com/.default instead of the typical
        https://graph.microsoft.com/.default, to enable OAuth app-only authentication with Exchange Online for EWS
        applications.
        -- Delegated permissions / user-present auth. flows for EWS are already covered in the DeviceCode and
        RefreshToken parameter sets (i.e., supply -Scopes Ews.AccessUser.All).
#>


function New-MSGraphAccessToken {
    [CmdletBinding(
        DefaultParameterSetName = 'DeviceCode_Endpoint'
    )]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_Certificate')]
        [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_CertificateStorePath')]
        [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_TenantId')]
        [string]$TenantId, # Guid / FQDN

        [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_Certificate')]
        [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_CertificateStorePath')]
        [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'DeviceCode_Endpoint')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshToken_Endpoint')]
        [Guid]$ApplicationId,

        [Parameter(
            Mandatory,
            ParameterSetName = 'ClientCredentials_Certificate',
            HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [X509Certificate2]$Certificate,

        [Parameter(
            Mandatory,
            ParameterSetName = 'ClientCredentials_CertificateStorePath',
            HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [ValidateScript(
            {
                if (Test-Path -Path $_) { $true } else {

                    throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'."
                }
            }
        )]
        [string]$CertificateStorePath,

        [Parameter(ParameterSetName = 'ClientCredentials_Certificate')]
        [Parameter(ParameterSetName = 'ClientCredentials_CertificateStorePath')]
        [switch]$ExoEwsAppOnlyScope,

        [Parameter(ParameterSetName = 'ClientCredentials_Certificate')]
        [Parameter(ParameterSetName = 'ClientCredentials_CertificateStorePath')]
        [ValidateRange(1, 10)]
        [int16]$JWTExpMinutes = 2,

        [Parameter(ParameterSetName = 'DeviceCode_Endpoint')]
        [Parameter(ParameterSetName = 'RefreshToken_Endpoint')]
        [Parameter(ParameterSetName = 'RefreshTokenCredential_Endpoint')]
        [ValidateSet('Common', 'Consumers', 'Organizations')]
        [string]$Endpoint = 'Common',

        [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId', HelpMessage = 'E.g. Mail.Send, Ews.AccessAsUser.All')]
        [Parameter(Mandatory, ParameterSetName = 'DeviceCode_Endpoint', HelpMessage = 'E.g. Mail.Send, Ews.AccessAsUser.All')]
        [Parameter(ParameterSetName = 'RefreshToken_TenantId')]
        [Parameter(ParameterSetName = 'RefreshToken_Endpoint')]
        [Parameter(ParameterSetName = 'RefreshTokenCredential_TenantId')]
        [Parameter(ParameterSetName = 'RefreshTokenCredential_Endpoint')]
        [string[]]$Scopes,

        [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshToken_Endpoint')]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.refresh_token) { $true } else {

                    throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken -Scopes offline_access...'
                }
            }
        )]
        [Object]$RefreshToken,

        [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_TenantId')]
        [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_Endpoint')]
        [PSCredential]$RefreshTokenCredential
    )

    #region Initialization
    if ($PSCmdlet.ParameterSetName -eq 'ClientCredentials_CertificateStorePath') {
        try {
            $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop
        }
        catch { throw }
    }
    elseif ($PSCmdlet.ParameterSetName -eq 'ClientCredentials_Certificate') {

        $Script:Certificate = $Certificate
    }

    if ($PSCmdlet.ParameterSetName -like 'ClientCredentials_*') {

        if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) {

            throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " +
            'and the SHA-256 hashing algorithm. ' +
            'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.'
        }
    }

    if ($PSCmdlet.ParameterSetName -like '*_TenantId') { $Script:Endpoint = $TenantId } else { $Script:Endpoint = $Endpoint }

    if ($PSCmdlet.ParameterSetName -like 'RefreshTokenCredential_*') {

        try {
            $Script:ApplicationId = [Guid]$RefreshTokenCredential.UserName
            $Script:RefreshToken = ConvertFrom-Json (ConvertFrom-SecureStringToPlainText $RefreshTokenCredential.Password)
        }
        catch {
            'Failed to validate refresh token credential object. ' +
            'Supply $RefreshTokenObject where $RefreshTokenObject = New-RefreshTokenObject ...' |
            Write-Warning
            throw
        }
    }
    elseif ($PSCmdlet.ParameterSetName -like 'RefreshToken_*') {

        $Script:ApplicationId = $ApplicationId
        $Script:RefreshToken = $RefreshToken
    }
    #endregion Initialization

    #region Functions
    function New-AppOnlyAccessToken ($TenantId, $ApplicationId, $Certificate, $JWTExpMinutes) {
        try {
            $NowUTC = [datetime]::UtcNow

            $EncodedHeader = [Convert]::ToBase64String(
                [Text.Encoding]::UTF8.GetBytes(
                    (ConvertTo-Json -InputObject (
                            @{
                                alg = 'RS256'
                                typ = 'JWT'
                                x5t = ConvertTo-Base64Url -String ([Convert]::ToBase64String($Certificate.GetCertHash()))
                            }
                        )
                    )
                )
            )

            $EncodedPayload = [Convert]::ToBase64String(
                [Text.Encoding]::UTF8.GetBytes(
                    (ConvertTo-Json -InputObject (
                            @{
                                aud = "https://login.microsoftonline.com/$TenantId/oauth2/token"
                                exp = (Get-Date $NowUTC.AddMinutes($JWTExpMinutes) -UFormat '%s') -replace '\..*'
                                iss = $ApplicationId.Guid
                                jti = [Guid]::NewGuid()
                                nbf = (Get-Date $NowUTC -UFormat '%s') -replace '\..*'
                                sub = $ApplicationId.Guid
                            }
                        )
                    )
                )
            )

            $JWT = (ConvertTo-Base64Url -String $EncodedHeader, $EncodedPayload) -join '.'

            $Signature = ConvertTo-Base64Url -String (
                [Convert]::ToBase64String(
                    $Script:Certificate.PrivateKey.SignData(
                        [Text.Encoding]::UTF8.GetBytes($JWT),
                        [HashAlgorithmName]::SHA256,
                        [RSASignaturePadding]::Pkcs1
                    )
                )
            )

            $JWT = $JWT + '.' + $Signature

            $trBody = @{

                client_id             = $ApplicationId
                client_assertion      = $JWT
                client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                scope                 = "$(if ($ExoEwsAppOnlyScope) {'https://outlook.office365.com' } else { 'https://graph.microsoft.com' })/.default"
                grant_type            = "client_credentials"
            }

            $trParams = @{

                Method      = 'POST'
                Uri         = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
                Body        = $trBody
                Headers     = @{ Authorization = "Bearer $($JWT)" }
                ContentType = 'application/x-www-form-urlencoded'
                UserAgent   = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)"
                ErrorAction = 'Stop'
            }

            $trResponse = Invoke-RestMethod @trParams

            # Output the token request response:
            $trResponse
        }
        catch { throw }
    }

    function New-DeviceCodeAccessToken ($Endpoint, $ApplicationId, $Scopes) {
        try {
            $dcrBody = @(
                "client_id=$($ApplicationId)",
                "scope=$($Scopes -join ' ')"
            ) -join '&'

            $dcrParams = @{

                Method      = 'POST'
                Uri         = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/devicecode"
                Body        = $dcrBody
                ContentType = 'application/x-www-form-urlencoded'
                UserAgent   = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)"
                ErrorAction = 'Stop'
            }
            $dcrResponse = Invoke-RestMethod @dcrParams

            $dtNow = [datetime]::Now
            $sw1 = [Diagnostics.Stopwatch]::StartNew()
            $dcExpiration = "$($dtNow.AddSeconds($dcrResponse.expires_in).ToString('yyyy-MM-dd hh:mm:ss tt'))"

            $trBody = @(
                "grant_type=urn:ietf:params:oauth:grant-type:device_code",
                "client_id=$($ApplicationId)",
                "device_code=$($dcrResponse.device_code)"
            ) -join '&'

            # Wait for user to enter code before starting to poll token endpoint:
            switch (
                $host.UI.PromptForChoice(

                    "Authorization started (expires at $($dcExpiration))",
                    "$($dcrResponse.message)",
                    [ChoiceDescription]('&Done'),
                    0
                )
            ) { 0 { <##> } }

            if ($sw1.Elapsed.Minutes -lt 15) {

                $sw2 = [Diagnostics.Stopwatch]::StartNew()
                $successfulResponse = $false
                $pollCount = 0
                do {
                    if ($sw2.Elapsed.Seconds -ge $dcrResponse.interval) {

                        $sw2.Restart()
                        $pollCount++

                        try {
                            $trParams = @{

                                Method      = 'POST'
                                Uri         = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/token"
                                Body        = $trBody
                                ContentType = 'application/x-www-form-urlencoded'
                                UserAgent   = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)"
                                ErrorAction = 'Stop'
                            }
                            $trResponse = Invoke-RestMethod @trParams
                            $successfulResponse = $true
                        }
                        catch {
                            if ($_.ErrorDetails.Message) {

                                $badResponse = ConvertFrom-Json -InputObject $_.ErrorDetails.Message

                                if ($badResponse.error -eq 'authorization_pending') {

                                    if ($pollCount -eq 1) {

                                        "The user hasn't finished authenticating, but hasn't canceled the flow (error: authorization_pending). " +
                                        "Continuing to poll the token endpoint at the requested interval ($($dcrResponse.interval) seconds)." |
                                        Write-Warning
                                    }
                                }
                                elseif ($badResponse.error -match '^(authorization_declined)|(bad_verification_code)|(expired_token)$') {

                                    # https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#expected-errors
                                    throw "Authorization failed due to foreseeable error: $($badResponse.error)."
                                }
                                elseif ($_.errorDetails.message -match '(AADSTS7000218)') {

                                    "Authorization failed due to 'invalid_client' (AADSTS7000218). " +
                                    'Ensure the Application is enabled for public client flows in Azure AD. ' +
                                    'If this is app is intended for app-only/unattended use, you should instead use the -Certificate/-CertificateStorePath parameters.' |
                                    Write-Warning
                                    throw
                                }
                                else {
                                    Write-Warning 'Authorization failed due to an unexpected error.'
                                    throw $badResponse.error_description
                                }
                            }
                            else {
                                Write-Warning 'An error was encountered with the Invoke-RestMethod command. Authorization request did not complete.'
                                throw
                            }
                        }
                    }
                    if (-not $successfulResponse) { Start-Sleep -Seconds 1 }
                }
                while ($sw1.Elapsed.Minutes -lt 15 -and -not $successfulResponse)

                # Output the token request response:
                $trResponse
            }
            else {
                throw "Authorization request expired at $($dcExpiration), please try again."
            }
        }
        catch {
            if ($_.errorDetails.message -match '(AADSTS50059)') {

                "Device code request failed due to 'invalid_request' (AADSTS50059). " +
                'This appears to be a single-tenant app. Retry the command, supplying -TenantId <tenant Domain|Guid>.' |
                Write-Warning
                throw
            }
            elseif ($_.errorDetails.message -match '(AADSTS70011)') {

                "Device code request failed due to 'invalid_scope' (AADSTS70011), which typically means the requested scope(s) do not work with the specified endpoint ('Common' by default). " +
                'Retry the command with either -Endpoint Organizations or -TenantId <tenant Domain|Guid>.' |
                Write-Warning
                throw
            }
            else { throw }
        }
    }

    function Get-RefreshedAcessToken ($Endpoint, $ApplicationId, $RefreshToken, $Scopes) {
        try {
            $trBody = @(
                "client_id=$($ApplicationId)",
                'grant_type=refresh_token',
                "refresh_token=$($RefreshToken.refresh_token)"
            )
            if ($Scopes) { $trBody += "scope=$($Scopes -join ' ')" }

            $trBody = $trBody -join '&'

            $trParams = @{

                Method      = 'POST'
                Uri         = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/token"
                Body        = $trBody
                ContentType = 'application/x-www-form-urlencoded'
                UserAgent   = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)"
                ErrorAction = 'Stop'
            }
            $trResponse = Invoke-RestMethod @trParams

            # Output the token request response:
            $trResponse
        }
        catch { throw }
    }
    #endregion Functions

    #region Main
    try {
        switch -Wildcard ($PSCmdlet.ParameterSetName) {

            'ClientCredentials_*' {

                New-AppOnlyAccessToken $TenantId $ApplicationId $Script:Certificate $JWTExpMinutes
            }

            'DeviceCode_*' {

                New-DeviceCodeAccessToken $Script:Endpoint $ApplicationId $Scopes
            }

            'RefreshToken*' {

                Get-RefreshedAcessToken $Script:Endpoint $Script:ApplicationId $Script:RefreshToken $Scopes
            }
        }
    }
    catch {
        if ($_.errorDetails.message -match '(AADSTS50194)') {

            "Application $($ApplicationId) appears to be a single-tenant application. " +
            'Please supply either -Endpoint:Organizations or -TenantId:<Tenant Id/Guid>' |
            Write-Warning
            throw "$((ConvertFrom-Json $_.errorDetails.message).error_description)"
        }
        else { throw }
    }
    #endregion Main
}

function New-MSGraphRequest {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Request,

        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else {

                    throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...'
                }
            }
        )]
        [Object]$AccessToken,

        [Alias('API', 'Version', 'Endpoint')]
        [ValidateSet('v1.0', 'beta')]
        [string]$ApiVersion = 'v1.0',

        [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
        [string]$Method = 'GET',

        [string]$Body,

        [ValidateSet('Ignore', 'Warn', 'Inquire', 'Continue', 'SilentlyContinue')]
        [string]$nextLinkAction = 'Warn'
    )

    try {
        $RequestParams = @{

            Headers     = @{ Authorization = "Bearer $($AccessToken.access_token)" }
            Uri         = "https://graph.microsoft.com/$($ApiVersion)/$($Request)"
            Method      = $Method
            ContentType = 'application/json'
            UserAgent   = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)"
            ErrorAction = 'Stop'
        }

        if ($PSBoundParameters.ContainsKey('Body')) {

            if ($Method -notmatch '(POST)|(PATCH)') {

                throw "Body is not allowed when the method is $($Method), only POST or PATCH."
            }
            else { $RequestParams['Body'] = $Body }
        }

        Invoke-RestMethod @RequestParams -OutVariable requestResponse

        if ($requestResponse.'@odata.nextLink') {

            $Script:Continue = $true

            switch ($nextLinkAction) {

                Ignore { $Script:Continue = $false }

                Warn {
                    Write-Warning -Message "There are more results available. Next page: $($requestResponse.'@odata.nextLink')"
                    $Script:Continue = $false
                }
                'Continue' {
                    Write-Information -MessageData 'There are more results available. Getting the next page...' -InformationAction Continue

                }
                Inquire {
                    switch (
                        $host.UI.PromptForChoice(

                            'There are more results available (i.e. response included @odata.nextLink).',
                            'Get more results?',
                            [ChoiceDescription[]]@('&Yes', 'Yes to &All', '&No'),
                            2
                        )
                    ) {
                        0 { <# Will prompt for choice again if the next response includes another @odata.nextLink.#> }
                        1 { $nextLinkAction = 'SilentlyContinue' }
                        2 { $Script:Continue = $false }
                    }
                }
            }

            if ($Script:Continue) {

                $nextLinkRequestParams = @{

                    AccessToken    = $AccessToken
                    ApiVersion     = $ApiVersion
                    Request        = "$($requestResponse.'@odata.nextLink' -replace 'https://graph.microsoft.com/(v1\.0|beta)/')"
                    nextLinkAction = $nextLinkAction
                    ErrorAction    = 'Stop'
                }

                New-MSGraphRequest @nextLinkRequestParams
            }
        }
    }
    catch {
        if ($_.Exception.Response.StatusCode.value__ -eq 429) {

            "The request was throttled by Microsoft Graph/Azure AD. " +
            "Please wait $($_.Exception.Response.Headers['Retry-After']) seconds before retrying the request, per the Retry-After response header (see `$Error[0].Exception.Response.Headers['Retry-After'])." | Write-Warning
        }
        throw
    }
}

function New-SelfSignedMSGraphApplicationCertificate {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Subject,

        [string]$FriendlyName,

        [ValidateScript(
            {
                if (Test-Path -Path $_) { $true } else {

                    throw "An example proper location would be 'cert:\CurrentUser\My'."
                }
            }
        )]
        [string]$CertStoreLocation = 'cert:\CurrentUser\My',

        [datetime]$NotAfter = [datetime]::Now.AddDays(90),

        [ValidateSet('Signature', 'KeyExchange')]
        [string]$KeySpec = 'Signature'
    )

    $NewCertParams = @{

        Subject           = $Subject
        FriendlyName      = $FriendlyName
        CertStoreLocation = $CertStoreLocation
        NotAfter          = $NotAfter
        KeySpec           = $KeySpec
        Provider          = 'Microsoft Enhanced RSA and AES Cryptographic Provider'
        HashAlgorithm     = 'SHA256'
        ErrorAction       = 'Stop'
    }

    try {
        if ($PSVersionTable.PSEdition -eq 'Desktop') { New-SelfSignedCertificate @NewCertParams }
        else {
            # PowerShell Core's PKI module has an issue with allowing the private key to be exportable:
            # https://github.com/PowerShell/PowerShell/issues/12081
            try {
                #Pre-import the PKI module to make the WinPSCompatSession available to Invoke-Command:
                Import-Module -Name PKI -UseWindowsPowerShell -WarningAction:SilentlyContinue

                $WinPSCompatSession = Get-PSSession -Name WinPSCompatSession -ErrorAction:Stop
                if ($WinPSCompatSession) {

                    Invoke-Command -Session $WinPSCompatSession -ScriptBlock { $Global:tmpCertificate = New-SelfSignedCertificate @using:NewCertParams } -ErrorAction:Stop
                    Get-ChildItem -Path "$($CertStoreLocation)\$((Invoke-Command -Session $WinPSCompatSession -ScriptBlock {$tmpCertificate}).Thumbprint)" -ErrorAction:Stop
                }
                else { throw }
            }
            catch { throw "Failed to use WinPSCompatSession to generate valid self-signed certificate. please use Windows PowerShell 5.1 instead." }
        }
    }
    catch { throw }
}

function New-MSGraphPoPToken {
    [CmdletBinding(
        DefaultParameterSetName = 'Certificate'
    )]
    param (
        [Parameter(Mandatory)]
        [Alias('ClientId')]
        [Guid]$ApplicationObjectId,

        [Parameter(
            Mandatory,
            ParameterSetName = 'Certificate',
            HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [X509Certificate2]$Certificate,

        [Parameter(
            Mandatory,
            ParameterSetName = 'CertificateStorePath',
            HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [ValidateScript(
            {
                if (Test-Path -Path $_) { $true } else {

                    throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'."
                }
            }
        )]
        [string]$CertificateStorePath,

        [ValidateRange(1, 10)]
        [int16]$JWTExpMinutes = 2
    )

    try {
        if ($PSCmdlet.ParameterSetName -eq 'CertificateStorePath') {

            $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop
        }
        else { $Script:Certificate = $Certificate }

        if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) {

            throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " +
            'and the SHA-256 hashing algorithm. ' +
            'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.'
        }

        $NowUTC = [datetime]::UtcNow

        $EncodedHeader = [Convert]::ToBase64String(
            [Text.Encoding]::UTF8.GetBytes(
                (ConvertTo-Json -InputObject (
                        @{
                            alg = 'RS256'
                            typ = 'JWT'
                            x5t = ConvertTo-Base64Url -String ([Convert]::ToBase64String($Script:Certificate.GetCertHash()))
                        }
                    )
                )
            )
        )

        $EncodedPayload = [Convert]::ToBase64String(
            [Text.Encoding]::UTF8.GetBytes(
                (ConvertTo-Json -InputObject (
                        @{
                            aud = '00000002-0000-0000-c000-000000000000'
                            iss = $ApplicationObjectId.Guid
                            exp = (Get-Date $NowUTC.AddMinutes($JWTExpMinutes)-UFormat '%s') -replace '\..*'
                            nbf = (Get-Date $NowUTC -UFormat '%s') -replace '\..*'
                        }
                    )
                )
            )
        )

        $JWT = (ConvertTo-Base64Url -String $EncodedHeader, $EncodedPayload) -join '.'

        $Signature = ConvertTo-Base64Url -String (
            [Convert]::ToBase64String(
                $Script:Certificate.PrivateKey.SignData(
                    [Text.Encoding]::UTF8.GetBytes($JWT),
                    [HashAlgorithmName]::SHA256,
                    [RSASignaturePadding]::Pkcs1
                )
            )
        )

        $JWT = $JWT + '.' + $Signature

        # Output the token:
        $JWT
    }
    catch { throw }
}

function Add-MSGraphApplicationKeyCredential {
    [CmdletBinding(
        DefaultParameterSetName = 'Certificate'
    )]
    param (
        [Parameter(Mandatory)]
        [Guid]$ApplicationObjectId,

        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else {

                    throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...'
                }
            }
        )]
        [Object]$AccessToken,

        [Parameter(Mandatory)]
        [ValidatePattern('^[-\w]+\.[-\w]+\.[-\w]+$')]
        [string]$PoPToken,

        [Parameter(
            Mandatory,
            ParameterSetName = 'Certificate',
            HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [X509Certificate2]$Certificate,

        [Parameter(
            Mandatory,
            ParameterSetName = 'CertificateStorePath',
            HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'
        )]
        [ValidateScript(
            {
                if (Test-Path -Path $_) { $true } else {

                    throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'."
                }
            }
        )]
        [string]$CertificateStorePath
    )

    try {
        if ($PSCmdlet.ParameterSetName -eq 'CertificateStorePath') {

            $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop
        }
        else { $Script:Certificate = $Certificate }

        if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) {

            throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " +
            'and the SHA-256 hashing algorithm. ' +
            'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.'
        }

        $Body = @{

            proof         = $PoPToken
            keyCredential = @{

                type  = "AsymmetricX509Cert"
                usage = "Verify"
                key   = [Convert]::ToBase64String($Script:Certificate.GetRawCertData())
            }
        }

        $AddKeyParams = @{

            AccessToken = $AccessToken
            Method      = 'POST'
            Request     = "applications/$($ApplicationObjectId)/addKey"
            Body        = (ConvertTo-Json $Body)
            ErrorAction = 'Stop'
        }

        New-MSGraphRequest @AddKeyParams
    }
    catch { throw }
}

function Remove-MSGraphApplicationKeyCredential {
    [CmdletBinding(
        DefaultParameterSetName = 'CertificateThumbprint'
    )]
    param (
        [Parameter(Mandatory)]
        [Guid]$ApplicationObjectId,

        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else {

                    throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...'
                }
            }
        )]
        [Object]$AccessToken,

        [Parameter(Mandatory)]
        [ValidatePattern('^[-\w]+\.[-\w]+\.[-\w]+$')]
        [string]$PoPToken,

        [Parameter(
            Mandatory,
            ParameterSetName = 'CertificateThumbprint'
        )]
        [ValidatePattern('^[a-fA-F0-9]{40,40}$')]
        [string]$CertificateThumbprint,

        [Parameter(
            Mandatory,
            ParameterSetName = 'KeyId'
        )]
        [Guid]$KeyId
    )

    try {
        if ($PSCmdlet.ParameterSetName -eq 'CertificateThumbprint') {

            $GetApplicationParams = @{

                AccessToken = $AccessToken
                Request     = "applications/$($ApplicationObjectId)"
                ErrorAction = 'Stop'
            }

            $Application = New-MSGraphRequest @GetApplicationParams

            $MatchingKeyCredentials = $Application.keyCredentials |
            Where-Object { $_.customKeyIdentifier -eq $CertificateThumbprint }

            if ($MatchingKeyCredentials.Count -gt 1) {

                "Multiple keyCredentials matching certificate thumbprint $($CertificateThumbprint) were found. " +
                "List these with the command below, then re-run this command using -KeyId instead of -CertificateThumbprint:`n" +
                "New-MSGraphRequest -AccessToken <AccessTokenObject> -Request 'applications/$($ApplicationObjectId)' | select -expand keyCredentials" |
                Write-Warning
                break
            }
            elseif ($MatchingKeyCredentials.Count -lt 1) {

                throw "No KeyCredential was found with certificate thumbprint $($CertificateThumbprint)."
            }
            else { $Script:KeyId = $MatchingKeyCredentials.KeyId }
        }
        else { $Script:KeyId = $KeyId.Guid }

        $Body = @{

            keyId = $Script:KeyId
            proof = $PoPToken
        }

        $RemoveKeyParams = @{

            AccessToken = $AccessToken
            Request     = "applications/$($ApplicationObjectId)/removeKey"
            Method      = 'POST'
            Body        = (ConvertTo-Json $Body)
            ErrorAction = 'Stop'
        }

        New-MSGraphRequest @RemoveKeyParams
    }
    catch { throw }
}

function Test-SigningCertificate ([X509Certificate2]$Certificate) {

    if ($PSVersionTable.PSEdition -eq 'Desktop') {

        $Provider = $Certificate.PrivateKey.CspKeyContainerInfo.ProviderName
    }
    else { $Provider = $Certificate.PrivateKey.Key.Provider }

    if (
        $Provider -eq 'Microsoft Enhanced RSA and AES Cryptographic Provider' -and
        $Certificate.SignatureAlgorithm.FriendlyName -match '(sha256)'
    ) {
        $true
    }
    else { $false }
}

function ConvertTo-Base64Url {
    param (
        [ValidatePattern('^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$')]
        [string[]]$String
    )

    $String -replace '\+', '-' -replace '/', '_' -replace '='
}

function ConvertFrom-Base64Url ([string[]]$String) {

    foreach ($s in $String) {

        while ($s.Length % 4) { $s += '=' }
        $s -replace '-', '\+' -replace '_', '/'
    }
}

function ConvertFrom-JWTAccessToken {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_ -match '^eyJ[-\w]+\.[-\w]+\.[-\w]+$') { $true } else { throw 'Invalid JWT.' }
            }
        )]
        [Object]$JWT
    )

    $Headers, $Payload = ($JWT -split '\.')[0, 1]

    [PSCustomObject]@{
        Headers = ConvertFrom-Json (
            [Text.Encoding]::ASCII.GetString(
                [Convert]::FromBase64String((ConvertFrom-Base64Url $Headers))
            )
        )
        Payload = ConvertFrom-Json(
            [Text.Encoding]::ASCII.GetString(
                [Convert]::FromBase64String((ConvertFrom-Base64Url $Payload))
            )
        )
    }
}

function ConvertFrom-SecureStringToPlainText ([SecureString]$SecureString) {

    [Marshal]::PtrToStringAuto(
        [Marshal]::SecureStringToBSTR($SecureString)
    )
}

function New-RefreshTokenCredential {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Guid]$ApplicationId,

        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.refresh_token) { $true } else {

                    throw 'Invalid token object. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken -Scopes offline_access...'
                }
            }
        )]
        [Object]$TokenObject
    )

    [PSCredential]::new(
        $ApplicationId,
        (ConvertTo-Json $TokenObject | ConvertTo-SecureString -AsPlainText -Force)
    )
}

function Get-AccessTokenExpiration {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else {

                    throw 'Invalid token object. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken...'
                }
            }
        )]
        [Object]$TokenObject
    )

    $JWT = ConvertFrom-JWTAccessToken -JWT $TokenObject.access_token
    $Epoch = [datetime]"1970-01-01"
    $Now = [datetime]::Now
    $exp = $Epoch.AddSeconds($JWT.Payload.exp).ToLocalTime()

    [PSCustomObject]@{

        IssuedAt_LocalTime       = $Epoch.AddSeconds($JWT.Payload.iat).ToLocalTime()
        NotBefore_LocalTime      = $Epoch.AddSeconds($JWT.Payload.nbf).ToLocalTime()
        ExpirationTime_LocalTime = $exp
        TimeUntilExpiration      = $exp - $Now
        IsExpired                = $Now -gt $exp
    }
}