MSIdentityTools.psm1

#Requires -Version 7.0
#Requires -PSEdition Core,Desktop
#Requires -Module @{'ModuleVersion'='1.9.2';'ModuleName'='Microsoft.Graph.Authentication';'GUID'='883916f2-9184-46ee-b1f8-b6a2fb784cee'}
 #Requires -Module @{'ModuleVersion'='7.8.6';'ModuleName'='ImportExcel';'GUID'='60dd4136-feff-401a-ba27-a84458c57ede'}
#Requires -Module @{'ModuleVersion'='1.9.2';'ModuleName'='Microsoft.Graph.Authentication';'GUID'='883916f2-9184-46ee-b1f8-b6a2fb784cee'}
 #Requires -Module @{'ModuleVersion'='7.8.6';'ModuleName'='ImportExcel';'GUID'='60dd4136-feff-401a-ba27-a84458c57ede'}

<#
.SYNOPSIS
    MSIdentityTools
.DESCRIPTION
    Tools for managing, troubleshooting, and reporting on various aspects of Microsoft Identity products and services, primarily Microsoft Entra ID.
.NOTES
    ModuleVersion: 2.0.64
    GUID: 69790621-e75d-4303-b06e-02704b7ca42f
    Author: Microsoft Identity
    CompanyName: Microsoft Corporation
    Copyright: (c) 2023 Microsoft Corporation. All rights reserved.
.FUNCTIONALITY
    Add-MsIdServicePrincipal, Confirm-MsIdJwtTokenSignature, ConvertFrom-MsIdAadcAadConnectorSpaceDn, ConvertFrom-MsIdAadcSourceAnchor, ConvertFrom-MsIdUniqueTokenIdentifier, ConvertFrom-MsIdJwtToken, ConvertFrom-MsIdSamlMessage, Expand-MsIdJwtTokenPayload, Export-MsIdAppConsentGrantReport, Export-MsIdAzureMfaReport, Find-MsIdUnprotectedUsersWithAdminRoles, Get-MsIdProvisioningLogStatistics, Get-MsIdAdfsSamlToken, Get-MsIdAdfsWsFedToken, Get-MsIdAdfsWsTrustToken, Get-MsIdApplicationIdByAppId, Get-MsIdAuthorityUri, Get-MsIdAzureIpRange, Get-MsIdAzureUsers, Get-MsIdCrossTenantAccessActivity, Get-MsIdGroupWithExpiration, Get-MsIdMsftIdentityAssociation, Get-MsIdO365Endpoints, Get-MsIdOpenIdProviderConfiguration, Get-MsIdSamlFederationMetadata, Get-MsIdServicePrincipalIdByAppId, Get-MsIdUnmanagedExternalUser, Invoke-MsIdAzureAdSamlRequest, New-MsIdWsTrustRequest, New-MsIdClientSecret, New-MsIdSamlRequest, New-MsIdTemporaryUserPassword, Remove-MsidUserAuthenticationMethod, Reset-MsIdExternalUser, Resolve-MsIdTenant, Revoke-MsIdServicePrincipalConsent, Set-MsIdWindowsTlsSettings, Resolve-MsIdAzureIpAddress, Show-MsIdJwtToken, Show-MsIdSamlToken, Test-MsIdAzureAdDeviceRegConnectivity, Test-MsIdCBATrustStoreConfiguration, Get-MsIdSigningKeyThumbprint, Update-MsIdApplicationSigningKeyThumbprint, Get-MsIdIsViralUser, Get-MsIdHasMicrosoftAccount, Get-MsIdGroupWritebackConfiguration, Update-MsIdGroupWritebackConfiguration, Get-MsIdUnredeemedInvitedUser, Get-MsIdAdfsSampleApp, Import-MsIdAdfsSampleApp, Import-MsIdAdfsSamplePolicy, Get-MsIdInactiveSignInUser, Set-MsIdServicePrincipalVisibleInMyApps, Split-MsIdEntitlementManagementConnectedOrganization, Update-InvitedUserSponsorsFromInvitedBy
.LINK
    https://aka.ms/msid
#>


#region NestedModules Script(s)

#region Compress-Data.ps1

<#
.SYNOPSIS
    Compress data using DEFLATE (RFC 1951) and optionally GZIP file format (RFC 1952).
.DESCRIPTION

.EXAMPLE
    PS C:\>Compress-Data 'A string for compression'
    Compress string using Deflate.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Compress-Data {
    [CmdletBinding()]
    [Alias('Deflate-Data')]
    [OutputType([byte[]])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # Output gzip format
        [Parameter(Mandatory = $false)]
        [switch] $GZip,
        # Level of compression
        [Parameter(Mandatory = $false)]
        [System.IO.Compression.CompressionLevel] $CompressionLevel = ([System.IO.Compression.CompressionLevel]::Optimal),
        # Input encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default',
        # Set gzip OS header byte to unknown
        [Parameter(Mandatory = $false)]
        [switch] $GZipUnknownOS
    )

    begin {
        function Compress ([byte[]]$InputBytes, [bool]$GZip) {
            try {
                $streamInput = New-Object System.IO.MemoryStream -ArgumentList @($InputBytes, $false)
                try {
                    $streamOutput = New-Object System.IO.MemoryStream
                    try {
                        if ($GZip) {
                            $streamCompression = New-Object System.IO.Compression.GZipStream -ArgumentList $streamOutput, $CompressionLevel, $true
                        }
                        else {
                            $streamCompression = New-Object System.IO.Compression.DeflateStream -ArgumentList $streamOutput, $CompressionLevel, $true
                        }
                        $streamInput.CopyTo($streamCompression)
                    }
                    finally { $streamCompression.Dispose() }
                    if ($GZip) {
                        [void] $streamOutput.Seek(8, [System.IO.SeekOrigin]::Begin)
                        switch ($CompressionLevel) {
                            'Optimal' { $streamOutput.WriteByte(2) }
                            'Fastest' { $streamOutput.WriteByte(4) }
                            Default { $streamOutput.WriteByte(0) }
                        }
                        if ($GZipUnknownOS) { $streamOutput.WriteByte(255) }
                        elseif ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { $streamOutput.WriteByte(11) }
                        elseif ($IsLinux) { $streamOutput.WriteByte(3) }
                        elseif ($IsMacOS) { $streamOutput.WriteByte(7) }
                        else { $streamOutput.WriteByte(255) }
                    }
                    [byte[]] $OutputBytes = $streamOutput.ToArray()
                }
                finally { $streamOutput.Dispose() }
            }
            finally { $streamInput.Dispose() }

            Write-Output $OutputBytes -NoEnumerate
        }

        ## Create list to capture byte stream from piped input.
        [System.Collections.Generic.List[byte]] $listBytes = New-Object System.Collections.Generic.List[byte]
    }

    process {
        if ($InputObjects -is [byte[]]) {
            Write-Output (Compress $InputObjects -GZip:$GZip) -NoEnumerate
        }
        else {
            foreach ($InputObject in $InputObjects) {
                [byte[]] $InputBytes = $null
                if ($InputObject -is [byte]) {
                    ## Populate list with byte stream from piped input.
                    if ($listBytes.Count -eq 0) {
                        Write-Verbose 'Creating byte array from byte stream.'
                        Write-Warning ('For better performance when piping a single byte array, use "Write-Output $byteArray -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                    }
                    $listBytes.Add($InputObject)
                }
                elseif ($InputObject -is [byte[]]) {
                    $InputBytes = $InputObject
                }
                elseif ($InputObject -is [string]) {
                    $InputBytes = [Text.Encoding]::$Encoding.GetBytes($InputObject)
                }
                elseif ($InputObject -is [bool] -or $InputObject -is [char] -or $InputObject -is [single] -or $InputObject -is [double] -or $InputObject -is [int16] -or $InputObject -is [int32] -or $InputObject -is [int64] -or $InputObject -is [uint16] -or $InputObject -is [uint32] -or $InputObject -is [uint64]) {
                    $InputBytes = [System.BitConverter]::GetBytes($InputObject)
                }
                elseif ($InputObject -is [guid]) {
                    $InputBytes = $InputObject.ToByteArray()
                }
                elseif ($InputObject -is [System.IO.FileSystemInfo]) {
                    if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -AsByteStream
                    }
                    else {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -Encoding Byte
                    }
                }
                else {
                    ## Non-Terminating Error
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot compress input of type {0}.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'CompressDataFailureTypeNotSupported' -TargetObject $InputObject
                }

                if ($null -ne $InputBytes -and $InputBytes.Count -gt 0) {
                    Write-Output (Compress $InputBytes -GZip:$GZip) -NoEnumerate
                }
            }
        }
    }

    end {
        ## Output captured byte stream from piped input.
        if ($listBytes.Count -gt 0) {
            Write-Output (Compress $listBytes.ToArray() -GZip:$GZip) -NoEnumerate
        }
    }
}

#endregion

#region Confirm-JsonWebSignature.ps1

<#
.SYNOPSIS
    Validate the digital signature for JSON Web Signature.
.EXAMPLE
    PS C:\>Confirm-JsonWebSignature $Base64JwsString -SigningCertificate $SigningCertificate
    Validate the JWS string was signed by provided certificate.
.INPUTS
    System.String
#>

function Confirm-JsonWebSignature {
    [CmdletBinding()]
    [Alias('Confirm-Jws')]
    [OutputType([bool])]
    param (
        # JSON Web Signature (JWS)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObjects,
        # Certificate used to sign the data
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2] $SigningCertificate
    )

    process {
        foreach ($InputObject in $InputObjects) {
            $Jws = ConvertFrom-JsonWebSignature $InputObject
            $JwsData = $InputObject.Substring(0, $InputObject.LastIndexOf('.'))
            [Security.Cryptography.HashAlgorithmName] $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::"SHA$($Jws.Header.alg.Substring(2,3))"
            switch ($Jws.Header.alg.Substring(0, 2)) {
                'RS' {
                    $RSAKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($SigningCertificate)
                    [bool] $Result = $RSAKey.VerifyData([System.Text.Encoding]::UTF8.GetBytes($JwsData), $Jws.Signature, $HashAlgorithm, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
                }
                'PS' {
                    $RSAKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($SigningCertificate)
                    [bool] $Result = $RSAKey.VerifyData([System.Text.Encoding]::UTF8.GetBytes($JwsData), $Jws.Signature, $HashAlgorithm, [Security.Cryptography.RSASignaturePadding]::Pss)
                }
                'ES' {
                    $ECDsaKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($SigningCertificate)
                    [bool] $Result = $ECDsaKey.VerifyData([System.Text.Encoding]::UTF8.GetBytes($JwsData), $Jws.Signature, $HashAlgorithm)
                }
            }
            Write-Output $Result
        }
    }
}

#endregion

#region ConvertFrom-Base64String.ps1

<#
.SYNOPSIS
    Convert Base64 String to Byte Array or Plain Text String.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertFrom-Base64String "QSBzdHJpbmcgd2l0aCBiYXNlNjQgZW5jb2Rpbmc="
    Convert Base64 String to String with Default Encoding.
.EXAMPLE
    PS C:\>"QVNDSUkgc3RyaW5nIHdpdGggYmFzZTY0dXJsIGVuY29kaW5n" | ConvertFrom-Base64String -Base64Url -Encoding Ascii
    Convert Base64Url String to String with Ascii Encoding.
.EXAMPLE
    PS C:\>[guid](ConvertFrom-Base64String "5oIhNbCaFUGAe8NsiAKfpA==" -RawBytes)
    Convert Base64 String to GUID.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertFrom-Base64String {
    [CmdletBinding()]
    [OutputType([byte[]], [string])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObjects,
        # Use base64url variant
        [Parameter (Mandatory = $false)]
        [switch] $Base64Url,
        # Output raw byte array
        [Parameter (Mandatory = $false)]
        [switch] $RawBytes,
        # Encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default'
    )

    process {
        foreach ($InputObject in $InputObjects) {
            [string] $strBase64 = $InputObject
            if (!$PSBoundParameters.ContainsValue('Base64Url') -and ($strBase64.Contains('-') -or $strBase64.Contains('_'))) { $Base64Url = $true }
            if ($Base64Url) { $strBase64 = $strBase64.Replace('-', '+').Replace('_', '/').PadRight($strBase64.Length + (4 - $strBase64.Length % 4) % 4, '=') }
            [byte[]] $outBytes = [System.Convert]::FromBase64String($strBase64)
            if ($RawBytes) {
                Write-Output $outBytes -NoEnumerate
            }
            else {
                [string] $outString = ([Text.Encoding]::$Encoding.GetString($outBytes))
                Write-Output $outString
            }
        }
    }
}

#endregion

#region ConvertFrom-HexString.ps1

<#
.SYNOPSIS
   Convert from Hex String
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertFrom-HexString "57 68 61 74 20 69 73 20 61 20 68 65 78 20 73 74 72 69 6E 67 3F"
    Convert hex byte string seperated by spaces to string.
.EXAMPLE
    PS C:\>"415343494920737472696E6720746F2068657820737472696E67" | ConvertFrom-HexString -Delimiter "" -Encoding Ascii
    Convert hex byte string with no seperation to ASCII string.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertFrom-HexString {
    [CmdletBinding()]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObject,
        # Delimiter between Hex pairs
        [Parameter (Mandatory = $false)]
        [string] $Delimiter = ' ',
        # Output raw byte array
        [Parameter (Mandatory = $false)]
        [switch] $RawBytes,
        # Encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default'
    )

    process {
        $listBytes = New-Object object[] $InputObject.Count
        for ($iString = 0; $iString -lt $InputObject.Count; $iString++) {
            [string] $strHex = $InputObject[$iString]
            if ($strHex.Substring(2, 1) -eq $Delimiter) {
                [string[]] $listHex = $strHex -split $Delimiter
            }
            else {
                [string[]] $listHex = New-Object string[] ($strHex.Length / 2)
                for ($iByte = 0; $iByte -lt $strHex.Length; $iByte += 2) {
                    $listHex[[System.Math]::Truncate($iByte / 2)] = $strHex.Substring($iByte, 2)
                }
            }

            [byte[]] $outBytes = New-Object byte[] $listHex.Count
            for ($iByte = 0; $iByte -lt $listHex.Count; $iByte++) {
                $outBytes[$iByte] = [byte]::Parse($listHex[$iByte], [System.Globalization.NumberStyles]::HexNumber)
            }

            if ($RawBytes) { $listBytes[$iString] = $outBytes }
            else {
                $outString = ([Text.Encoding]::$Encoding.GetString($outBytes))
                Write-Output $outString
            }
        }
        if ($RawBytes) {
            return $listBytes
        }
    }
}

#endregion

#region ConvertFrom-JsonWebSignature.ps1

<#
.SYNOPSIS
   Convert Json Web Signature (JWS) structure to PowerShell object.
.EXAMPLE
    PS C:\>$MsalToken.IdToken | ConvertFrom-JsonWebSignature
    Convert OAuth IdToken JWS to PowerShell object.
.INPUTS
    System.String
#>

function ConvertFrom-JsonWebSignature {
    [CmdletBinding()]
    [Alias('ConvertFrom-Jws')]
    [Alias('ConvertFrom-JsonWebToken')]
    [Alias('ConvertFrom-Jwt')]
    [OutputType([PSCustomObject])]
    param (
        # JSON Web Signature (JWS)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObjects,
        # Content Type of the Payload
        [Parameter(Mandatory = $false)]
        [ValidateSet('text/plain', 'application/json', 'application/octet-stream')]
        [string] $ContentType = 'application/json'
    )

    process {
        foreach ($InputObject in $InputObjects) {
            [string[]] $JwsComponents = $InputObject.Split('.')
            switch ($ContentType) {
                'application/octet-stream' { [byte[]] $JwsPayload = $JwsComponents[1] | ConvertFrom-Base64String -Base64Url -RawBytes }
                'text/plain' { [string] $JwsPayload = $JwsComponents[1] | ConvertFrom-Base64String -Base64Url }
                'application/json' { [PSCustomObject] $JwsPayload = $JwsComponents[1] | ConvertFrom-Base64String -Base64Url | ConvertFrom-Json }
                Default { [string] $JwsPayload = $JwsComponents[1] | ConvertFrom-Base64String -Base64Url }
            }
            [PSCustomObject] $JwsDecoded = New-Object PSCustomObject -Property @{
                Header    = $JwsComponents[0] | ConvertFrom-Base64String -Base64Url | ConvertFrom-Json
                Payload   = $JwsPayload
                Signature = $JwsComponents[2] | ConvertFrom-Base64String -Base64Url -RawBytes
            }
            Write-Output ($JwsDecoded | Select-Object -Property Header, Payload, Signature)
        }
    }
}

#endregion

#region ConvertFrom-QueryString.ps1

<#
.SYNOPSIS
    Convert Query String to object.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertFrom-QueryString '?name=path/file.json&index=10'
    Convert query string to object.
.EXAMPLE
    PS C:\>'name=path/file.json&index=10' | ConvertFrom-QueryString -AsHashtable
    Convert query string to hashtable.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertFrom-QueryString {
    [CmdletBinding()]
    [OutputType([psobject])]
    [OutputType([hashtable])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string[]] $InputStrings,
        # URL decode parameter names
        [Parameter(Mandatory = $false)]
        [switch] $DecodeParameterNames,
        # Converts to hash table object
        [Parameter(Mandatory = $false)]
        [switch] $AsHashtable
    )

    process {
        foreach ($InputString in $InputStrings) {
            if ($AsHashtable) { [hashtable] $OutputObject = @{ } }
            else { [psobject] $OutputObject = New-Object psobject }

            if ($InputString) {
                if ($InputString[0] -eq '?') { $InputString = $InputString.Substring(1) }
                [string[]] $QueryParameters = $InputString.Split('&')
                foreach ($QueryParameter in $QueryParameters) {
                    [string[]] $QueryParameterPair = $QueryParameter.Split('=')
                    if ($DecodeParameterNames) { $QueryParameterPair[0] = [System.Net.WebUtility]::UrlDecode($QueryParameterPair[0]) }
                    if ($OutputObject -is [hashtable]) {
                        $OutputObject.Add($QueryParameterPair[0], [System.Net.WebUtility]::UrlDecode($QueryParameterPair[1]))
                    }
                    else {
                        $OutputObject | Add-Member $QueryParameterPair[0] -MemberType NoteProperty -Value ([System.Net.WebUtility]::UrlDecode($QueryParameterPair[1]))
                    }
                }
            }
            Write-Output $OutputObject
        }
    }

}

#endregion

#region ConvertFrom-SamlMessage.ps1

<#
.SYNOPSIS
   Convert Saml Message to XML object.
.EXAMPLE
    PS C:\>ConvertFrom-SamlMessage 'Base64String'
    Convert Saml Message to XML object.
.INPUTS
    System.String
.OUTPUTS
    SamlMessage : System.Xml.XmlDocument
#>

function ConvertFrom-SamlMessage {
    [CmdletBinding()]
    [Alias('ConvertFrom-SamlRequest')]
    [Alias('ConvertFrom-SamlResponse')]
    #[OutputType([xml])]
    param (
        # SAML Message
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObject
    )

    process {
        foreach ($_InputObject in $InputObject) {
            [byte[]] $bytesInput = $null
            $xmlOutput = New-Object SamlMessage
            try {
                $xmlOutput.LoadXml($_InputObject)
            }
            catch {
                try {
                    $bytesInput = [System.Convert]::FromBase64String($_InputObject)
                }
                catch {
                    $bytesInput = [System.Convert]::FromBase64String([System.Net.WebUtility]::UrlDecode($_InputObject))
                }
            }
            if ($bytesInput) {
                try {
                    $streamInput = New-Object System.IO.MemoryStream -ArgumentList @($bytesInput, $false)
                    try {
                        $xmlOutput.Load($streamInput)
                    }
                    catch {
                        $streamInput = New-Object System.IO.MemoryStream -ArgumentList @($bytesInput, $false)
                        try {
                            $streamOutput = New-Object System.IO.MemoryStream
                            try {
                                [System.IO.Compression.DeflateStream] $streamCompression = New-Object System.IO.Compression.DeflateStream -ArgumentList $streamInput, ([System.IO.Compression.CompressionMode]::Decompress), $true
                                $streamCompression.CopyTo($streamOutput)
                            }
                            finally { $streamCompression.Dispose() }
                            $streamOutput.Position = 0
                            $xmlOutput.Load($streamOutput)
                            #[string] $strOutput = ([Text.Encoding]::$Encoding.GetString($streamOutput.ToArray()))
                            #$xmlOutput.LoadXml($strOutput)
                        }
                        finally { $streamOutput.Dispose() }
                    }
                }
                finally { $streamInput.Dispose() }
            }

            Write-Output $xmlOutput
        }
    }
}

#endregion

#region ConvertFrom-SecureStringAsPlainText.ps1

<#
.SYNOPSIS
    Convert/Decrypt SecureString to Plain Text String.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertFrom-SecureStringAsPlainText (ConvertTo-SecureString 'SuperSecretString' -AsPlainText -Force) -Force
    Convert plain text to SecureString and then convert it back.
.INPUTS
    System.Security.SecureString
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertFrom-SecureStringAsPlainText {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Secure String Value
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [securestring] $SecureString,
        # Confirms that you understand the implications of using the AsPlainText parameter and still want to use it.
        [Parameter(Mandatory = $false)]
        [switch] $Force
    )

    begin {
        if ($PSVersionTable.PSVersion -ge [version]'7.0') {
            Write-Warning 'PowerShell 7 introduced an AsPlainText parameter to the ConvertFrom-SecureString cmdlet.'
        }
        if (!${Force}) {
            ## Terminating Error
            $Exception = New-Object ArgumentException -ArgumentList 'The system cannot protect plain text output. To suppress this warning and convert a SecureString to plain text, reissue the command specifying the Force parameter.'
            Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::InvalidArgument) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertSecureStringFailureForceRequired' -TargetObject ${SecureString} -ErrorAction Stop
        }
    }

    process {
        try {
            [IntPtr] $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
            Write-Output ([System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($BSTR))
        }
        finally {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
        }
    }
}

#endregion

#region ConvertTo-Base64String.ps1

<#
.SYNOPSIS
    Convert Byte Array or Plain Text String to Base64 String.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertTo-Base64String "A string with base64 encoding"
    Convert String with Default Encoding to Base64 String.
.EXAMPLE
    PS C:\>"ASCII string with base64url encoding" | ConvertTo-Base64String -Base64Url -Encoding Ascii
    Convert String with Ascii Encoding to Base64Url String.
.EXAMPLE
    PS C:\>ConvertTo-Base64String ([guid]::NewGuid())
    Convert GUID to Base64 String.
.INPUTS
    System.Object
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-Base64String {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # Use base64url variant
        [Parameter (Mandatory = $false)]
        [switch] $Base64Url,
        # Output encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default'
    )

    begin {
        function Transform ([byte[]]$InputBytes) {
            [string] $outBase64String = [System.Convert]::ToBase64String($InputBytes)
            if ($Base64Url) { $outBase64String = $outBase64String.Replace('+', '-').Replace('/', '_').Replace('=', '') }
            return $outBase64String
        }

        ## Create list to capture byte stream from piped input.
        [System.Collections.Generic.List[byte]] $listBytes = New-Object System.Collections.Generic.List[byte]
    }

    process {
        if ($InputObjects -is [byte[]]) {
            Write-Output (Transform $InputObjects)
        }
        else {
            foreach ($InputObject in $InputObjects) {
                [byte[]] $InputBytes = $null
                if ($InputObject -is [byte]) {
                    ## Populate list with byte stream from piped input.
                    if ($listBytes.Count -eq 0) {
                        Write-Verbose 'Creating byte array from byte stream.'
                        Write-Warning ('For better performance when piping a single byte array, use "Write-Output $byteArray -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                    }
                    $listBytes.Add($InputObject)
                }
                elseif ($InputObject -is [byte[]]) {
                    $InputBytes = $InputObject
                }
                elseif ($InputObject -is [string]) {
                    $InputBytes = [Text.Encoding]::$Encoding.GetBytes($InputObject)
                }
                elseif ($InputObject -is [bool] -or $InputObject -is [char] -or $InputObject -is [single] -or $InputObject -is [double] -or $InputObject -is [int16] -or $InputObject -is [int32] -or $InputObject -is [int64] -or $InputObject -is [uint16] -or $InputObject -is [uint32] -or $InputObject -is [uint64]) {
                    $InputBytes = [System.BitConverter]::GetBytes($InputObject)
                }
                elseif ($InputObject -is [guid]) {
                    $InputBytes = $InputObject.ToByteArray()
                }
                elseif ($InputObject -is [System.IO.FileSystemInfo]) {
                    if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -AsByteStream
                    }
                    else {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -Encoding Byte
                    }
                }
                else {
                    ## Non-Terminating Error
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to Base64 string.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertBase64StringFailureTypeNotSupported' -TargetObject $InputObject
                }

                if ($null -ne $InputBytes -and $InputBytes.Count -gt 0) {
                    Write-Output (Transform $InputBytes)
                }
            }
        }
    }

    end {
        ## Output captured byte stream from piped input.
        if ($listBytes.Count -gt 0) {
            Write-Output (Transform $listBytes.ToArray())
        }
    }
}

#endregion

#region ConvertTo-HexString.ps1

<#
.SYNOPSIS
   Convert to Hex String
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertTo-HexString "What is a hex string?"
    Convert string to hex byte string seperated by spaces.
.EXAMPLE
    PS C:\>"ASCII string to hex string" | ConvertTo-HexString -Delimiter "" -Encoding Ascii
    Convert ASCII string to hex byte string with no seperation.
.INPUTS
    System.Object
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-HexString {
    [CmdletBinding()]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # Delimiter between Hex pairs
        [Parameter (Mandatory = $false)]
        [string] $Delimiter = ' ',
        # Encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default'
    )

    begin {
        function Transform ([byte[]]$InputBytes) {
            [string[]] $outHexString = New-Object string[] $InputBytes.Count
            for ($iByte = 0; $iByte -lt $InputBytes.Count; $iByte++) {
                $outHexString[$iByte] = $InputBytes[$iByte].ToString('X2')
            }
            return $outHexString -join $Delimiter
        }

        ## Create list to capture byte stream from piped input.
        [System.Collections.Generic.List[byte]] $listBytes = New-Object System.Collections.Generic.List[byte]
    }

    process {
        if ($InputObjects -is [byte[]]) {
            Write-Output (Transform $InputObjects)
        }
        else {
            foreach ($InputObject in $InputObjects) {
                [byte[]] $InputBytes = $null
                if ($InputObject -is [byte]) {
                    ## Populate list with byte stream from piped input.
                    if ($listBytes.Count -eq 0) {
                        Write-Verbose 'Creating byte array from byte stream.'
                        Write-Warning ('For better performance when piping a single byte array, use "Write-Output $byteArray -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                    }
                    $listBytes.Add($InputObject)
                }
                elseif ($InputObject -is [byte[]]) {
                    $InputBytes = $InputObject
                }
                elseif ($InputObject -is [string]) {
                    $InputBytes = [Text.Encoding]::$Encoding.GetBytes($InputObject)
                }
                elseif ($InputObject -is [bool] -or $InputObject -is [char] -or $InputObject -is [single] -or $InputObject -is [double] -or $InputObject -is [int16] -or $InputObject -is [int32] -or $InputObject -is [int64] -or $InputObject -is [uint16] -or $InputObject -is [uint32] -or $InputObject -is [uint64]) {
                    $InputBytes = [System.BitConverter]::GetBytes($InputObject)
                }
                elseif ($InputObject -is [guid]) {
                    $InputBytes = $InputObject.ToByteArray()
                }
                elseif ($InputObject -is [System.IO.FileSystemInfo]) {
                    if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -AsByteStream
                    }
                    else {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -Encoding Byte
                    }
                }
                else {
                    ## Non-Terminating Error
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to Hex string.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertHexFailureTypeNotSupported' -TargetObject $InputObject
                }

                if ($null -ne $InputBytes -and $InputBytes.Count -gt 0) {
                    Write-Output (Transform $InputBytes)
                }
            }
        }
    }

    end {
        ## Output captured byte stream from piped input.
        if ($listBytes.Count -gt 0) {
            Write-Output (Transform $listBytes.ToArray())
        }
    }
}

#endregion

#region ConvertTo-PsParameterString.ps1

<#
.SYNOPSIS
    Convert splatable PowerShell paramters to PowerShell parameter string syntax.
.EXAMPLE
    PS C:\>ConvertTo-PsParameterString @{ key1='value1'; key2='value2' }
    Convert hashtable to PowerShell parameters string.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-PsParameterString {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        #
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [AllowNull()]
        [object] $InputObjects,
        #
        [Parameter(Mandatory = $false)]
        [switch] $Compact,
        #
        [Parameter(Mandatory = $false, Position = 1)]
        [type[]] $RemoveTypes = ([string], [bool], [int], [long]),
        #
        [Parameter(Mandatory = $false)]
        [switch] $NoEnumerate
    )

    begin {
        function GetPsParameterString ($InputObject) {
            $OutputString = New-Object System.Text.StringBuilder

            ## Add Value
            switch ($InputObject.GetType()) {
                { $_.Equals([Hashtable]) -or $_.Equals([System.Collections.Specialized.OrderedDictionary]) -or $_.FullName.StartsWith('System.Collections.Generic.Dictionary') -or ($_.BaseType -and $_.BaseType.FullName.StartsWith('System.Collections.Generic.Dictionary')) } {
                    foreach ($Parameter in $InputObject.GetEnumerator()) {
                        [string] $ParameterValue = (ConvertTo-PsString $Parameter.Value -Compact:$Compact -NoEnumerate)
                        if ($ParameterValue.StartsWith('[')) { $ParameterValue = '({0})' -f $ParameterValue }
                        [void]$OutputString.AppendFormat(' -{0} {1}', $Parameter.Key, $ParameterValue)
                    }
                    break
                }
                { $_.BaseType.Equals([Array]) -or $_.Equals([System.Collections.ArrayList]) -or $_.FullName.StartsWith('System.Collections.Generic.List') } {
                    foreach ($Parameter in $InputObject) {
                        [string] $ParameterValue = (ConvertTo-PsString $Parameter -Compact:$Compact -NoEnumerate)
                        if ($ParameterValue.StartsWith('[')) { $ParameterValue = '({0})' -f $ParameterValue }
                        [void]$OutputString.AppendFormat(' {0}', $ParameterValue)
                    }
                    break
                }
                Default {
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to PowerShell parameter string. Use -NoEnumerate if providing a single splatable array.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertPowerShellParameterStringFailureTypeNotSupported' -TargetObject $InputObject -ErrorAction Stop
                }
            }

            if ($NoEnumerate) {
                $listOutputString.Add($OutputString.ToString())
            }
            else {
                Write-Output $OutputString.ToString()
            }
        }

        if ($NoEnumerate) {
            $listOutputString = New-Object System.Collections.Generic.List[string]
        }
    }

    process {
        if ($PSCmdlet.MyInvocation.ExpectingInput -or $NoEnumerate) {
            GetPsParameterString $InputObjects
        }
        else {
            foreach ($InputObject in $InputObjects) {
                GetPsParameterString $InputObject
            }
        }
    }

    end {
        if ($NoEnumerate) {
            $OutputArray = New-Object System.Text.StringBuilder
            if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                [void]$OutputArray.AppendJoin('', $listOutputString)
            }
            else {
                [void]$OutputArray.Append(($listOutputString -join ''))
            }
            Write-Output $OutputArray.ToString()
        }
    }
}

#endregion

#region ConvertTo-PsString.ps1

<#
.SYNOPSIS
    Convert PowerShell data types to PowerShell string syntax.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertTo-PsString @{ key1='value1'; key2='value2' }
    Convert hashtable to PowerShell string.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-PsString {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        #
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [AllowNull()]
        [object] $InputObjects,
        #
        [Parameter(Mandatory = $false)]
        [switch] $Compact,
        #
        [Parameter(Mandatory = $false, Position = 1)]
        [type[]] $RemoveTypes = ([string], [bool], [int], [long]),
        #
        [Parameter(Mandatory = $false)]
        [switch] $NoEnumerate
    )

    begin {
        if ($Compact) {
            [System.Collections.Generic.Dictionary[string, type]] $TypeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::get
            [System.Collections.Generic.Dictionary[type, string]] $TypeAcceleratorsLookup = New-Object 'System.Collections.Generic.Dictionary[type,string]'
            foreach ($TypeAcceleratorKey in $TypeAccelerators.Keys) {
                if (!$TypeAcceleratorsLookup.ContainsKey($TypeAccelerators[$TypeAcceleratorKey])) {
                    $TypeAcceleratorsLookup.Add($TypeAccelerators[$TypeAcceleratorKey], $TypeAcceleratorKey)
                }
            }
        }

        function Resolve-Type {
            param (
                #
                [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
                [type] $ObjectType,
                #
                [Parameter(Mandatory = $false, Position = 1)]
                [switch] $Compact,
                #
                [Parameter(Mandatory = $false, Position = 1)]
                [type[]] $RemoveTypes
            )

            [string] $OutputString = ''
            if ($ObjectType.IsGenericType -or ($ObjectType.BaseType -and $ObjectType.BaseType.IsGenericType)) {
                if (!$ObjectType.IsGenericType) { $ObjectType = $ObjectType.BaseType }
                if ($ObjectType.FullName.StartsWith('System.Collections.Generic.Dictionary')) {
                    #$OutputString += '[hashtable]'
                    if ($Compact) {
                        $OutputString += '(Invoke-Command { $D = New-Object ''Collections.Generic.Dictionary['
                    }
                    else {
                        $OutputString += '(Invoke-Command { $D = New-Object ''System.Collections.Generic.Dictionary['
                    }
                    $iInput = 0
                    foreach ($GenericTypeArgument in $ObjectType.GenericTypeArguments) {
                        if ($iInput -gt 0) { $OutputString += ',' }
                        $OutputString += Resolve-Type $GenericTypeArgument -Compact:$Compact -RemoveTypes @()
                        $iInput++
                    }
                    $OutputString += ']'''
                }
                elseif ($InputObject.GetType().FullName -match '^(System.(Collections.Generic.[a-zA-Z]+))`[0-9]\[(?:\[(.+?), .+?, Version=.+?, Culture=.+?, PublicKeyToken=.+?\],?)+?\]$') {
                    if ($Compact) {
                        $OutputString += '[{0}[' -f $Matches[2]
                    }
                    else {
                        $OutputString += '[{0}[' -f $Matches[1]
                    }
                    $iInput = 0
                    foreach ($GenericTypeArgument in $ObjectType.GenericTypeArguments) {
                        if ($iInput -gt 0) { $OutputString += ',' }
                        $OutputString += Resolve-Type $GenericTypeArgument -Compact:$Compact -RemoveTypes @()
                        $iInput++
                    }
                    $OutputString += ']]'
                }
            }
            elseif ($ObjectType -eq [System.Collections.Specialized.OrderedDictionary]) {
                $OutputString += '[ordered]'  # Explicit cast does not work with full name. Only [ordered] works.
            }
            elseif ($Compact) {
                if ($ObjectType -notin $RemoveTypes) {
                    if ($TypeAcceleratorsLookup.ContainsKey($ObjectType)) {
                        $OutputString += '[{0}]' -f $TypeAcceleratorsLookup[$ObjectType]
                    }
                    elseif ($ObjectType.FullName.StartsWith('System.')) {
                        $OutputString += '[{0}]' -f $ObjectType.FullName.Substring(7)
                    }
                    else {
                        $OutputString += '[{0}]' -f $ObjectType.FullName
                    }
                }
            }
            else {
                $OutputString += '[{0}]' -f $ObjectType.FullName
            }
            return $OutputString
        }

        function GetPSString ($InputObject) {
            $OutputString = New-Object System.Text.StringBuilder

            if ($null -eq $InputObject) { [void]$OutputString.Append('$null') }
            else {
                ## Add Casting
                [void]$OutputString.Append((Resolve-Type $InputObject.GetType() -Compact:$Compact -RemoveTypes $RemoveTypes))

                ## Add Value
                switch ($InputObject.GetType()) {
                    { $_.Equals([String]) } {
                        [void]$OutputString.AppendFormat("'{0}'", $InputObject.Replace("'", "''")) #.Replace('"','`"')
                        break
                    }
                    { $_.Equals([Char]) } {
                        [void]$OutputString.AppendFormat("'{0}'", ([string]$InputObject).Replace("'", "''"))
                        break
                    }
                    { $_.Equals([Boolean]) -or $_.Equals([switch]) } {
                        [void]$OutputString.AppendFormat('${0}', $InputObject)
                        break
                    }
                    { $_.Equals([DateTime]) } {
                        [void]$OutputString.AppendFormat("'{0}'", $InputObject.ToString('O'))
                        break
                    }
                    { $_.Equals([guid]) } {
                        [void]$OutputString.AppendFormat("'{0}'", $InputObject)
                        break
                    }
                    { $_.BaseType -and $_.BaseType.Equals([Enum]) } {
                        [void]$OutputString.AppendFormat('::{0}', $InputObject)
                        break
                    }
                    { $_.BaseType -and $_.BaseType.Equals([ValueType]) } {
                        [void]$OutputString.AppendFormat('{0}', $InputObject)
                        break
                    }
                    { $_.BaseType.Equals([System.IO.FileSystemInfo]) -or $_.Equals([System.Uri]) } {
                        [void]$OutputString.AppendFormat("'{0}'", $InputObject.ToString().Replace("'", "''")) #.Replace('"','`"')
                        break
                    }
                    { $_.Equals([System.Xml.XmlDocument]) } {
                        [void]$OutputString.AppendFormat("'{0}'", $InputObject.OuterXml.Replace("'", "''")) #.Replace('"','""')
                        break
                    }
                    { $_.Equals([Hashtable]) -or $_.Equals([System.Collections.Specialized.OrderedDictionary]) } {
                        [void]$OutputString.Append('@{')
                        $iInput = 0
                        foreach ($enumHashtable in $InputObject.GetEnumerator()) {
                            if ($iInput -gt 0) { [void]$OutputString.Append(';') }
                            [void]$OutputString.AppendFormat('{0}={1}', (ConvertTo-PsString $enumHashtable.Key -Compact:$Compact -NoEnumerate), (ConvertTo-PsString $enumHashtable.Value -Compact:$Compact -NoEnumerate))
                            $iInput++
                        }
                        [void]$OutputString.Append('}')
                        break
                    }
                    { $_.FullName.StartsWith('System.Collections.Generic.Dictionary') -or ($_.BaseType -and $_.BaseType.FullName.StartsWith('System.Collections.Generic.Dictionary')) } {
                        $iInput = 0
                        foreach ($enumHashtable in $InputObject.GetEnumerator()) {
                            [void]$OutputString.AppendFormat('; $D.Add({0},{1})', (ConvertTo-PsString $enumHashtable.Key -Compact:$Compact -NoEnumerate), (ConvertTo-PsString $enumHashtable.Value -Compact:$Compact -NoEnumerate))
                            $iInput++
                        }
                        [void]$OutputString.Append('; $D })')
                        break
                    }
                    { $_.BaseType -and $_.BaseType.Equals([Array]) } {
                        [void]$OutputString.Append('(Write-Output @(')
                        $iInput = 0
                        for ($iInput = 0; $iInput -lt $InputObject.Count; $iInput++) {
                            if ($iInput -gt 0) { [void]$OutputString.Append(',') }
                            [void]$OutputString.Append((ConvertTo-PsString $InputObject[$iInput] -Compact:$Compact -RemoveTypes $InputObject.GetType().DeclaredMembers.Where( { $_.Name -eq 'Set' })[0].GetParameters()[1].ParameterType -NoEnumerate))
                        }
                        [void]$OutputString.Append(') -NoEnumerate)')
                        break
                    }
                    { $_.Equals([System.Collections.ArrayList]) } {
                        [void]$OutputString.Append('@(')
                        $iInput = 0
                        for ($iInput = 0; $iInput -lt $InputObject.Count; $iInput++) {
                            if ($iInput -gt 0) { [void]$OutputString.Append(',') }
                            [void]$OutputString.Append((ConvertTo-PsString $InputObject[$iInput] -Compact:$Compact -NoEnumerate))
                        }
                        [void]$OutputString.Append(')')
                        break
                    }
                    { $_.FullName.StartsWith('System.Collections.Generic.List') } {
                        [void]$OutputString.Append('@(')
                        $iInput = 0
                        for ($iInput = 0; $iInput -lt $InputObject.Count; $iInput++) {
                            if ($iInput -gt 0) { [void]$OutputString.Append(',') }
                            [void]$OutputString.Append((ConvertTo-PsString $InputObject[$iInput] -Compact:$Compact -RemoveTypes $_.GenericTypeArguments -NoEnumerate))
                        }
                        [void]$OutputString.Append(')')
                        break
                    }
                    ## Convert objects with object initializers
                    { $_ -is [object] -and ($_.GetConstructors() | ForEach-Object { if ($_.IsPublic -and !$_.GetParameters()) { $true } }) } {
                        [void]$OutputString.Append('@{')
                        $iInput = 0
                        foreach ($Item in ($InputObject | Get-Member -MemberType Property, NoteProperty)) {
                            if ($iInput -gt 0) { [void]$OutputString.Append(';') }
                            $PropertyName = $Item.Name
                            [void]$OutputString.AppendFormat('{0}={1}', (ConvertTo-PsString $PropertyName -Compact:$Compact -NoEnumerate), (ConvertTo-PsString $InputObject.$PropertyName -Compact:$Compact -NoEnumerate))
                            $iInput++
                        }
                        [void]$OutputString.Append('}')
                        break
                    }
                    Default {
                        $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to PowerShell string.' -f $InputObject.GetType())
                        Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertPowerShellStringFailureTypeNotSupported' -TargetObject $InputObject
                    }
                }
            }

            if ($NoEnumerate) {
                $listOutputString.Add($OutputString.ToString())
            }
            else {
                Write-Output $OutputString.ToString()
            }
        }

        if ($NoEnumerate) {
            $listOutputString = New-Object System.Collections.Generic.List[string]
        }
    }

    process {
        if ($PSCmdlet.MyInvocation.ExpectingInput -or $NoEnumerate -or $null -eq $InputObjects) {
            GetPSString $InputObjects
        }
        else {
            foreach ($InputObject in $InputObjects) {
                GetPSString $InputObject
            }
        }
    }

    end {
        if ($NoEnumerate) {
            if (($null -eq $InputObjects -and $listOutputString.Count -eq 0) -or $listOutputString.Count -gt 1) {
                Write-Warning ('To avoid losing strong type on outermost enumerable type when piping, use "Write-Output $Array -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                $OutputArray = New-Object System.Text.StringBuilder
                [void]$OutputArray.Append('(Write-Output @(')
                if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                    [void]$OutputArray.AppendJoin(',', $listOutputString)
                }
                else {
                    [void]$OutputArray.Append(($listOutputString -join ','))
                }
                [void]$OutputArray.Append(') -NoEnumerate)')
                Write-Output $OutputArray.ToString()
            }
            else {
                Write-Output $listOutputString[0]
            }

        }
    }
}

#endregion

#region ConvertTo-QueryString.ps1

<#
.SYNOPSIS
    Convert Hashtable to Query String.
.DESCRIPTION

.EXAMPLE
    PS C:\>ConvertTo-QueryString @{ name = 'path/file.json'; index = 10 }
    Convert hashtable to query string.
.EXAMPLE
    PS C:\>[ordered]@{ title = 'convert&prosper'; id = [guid]'352182e6-9ab0-4115-807b-c36c88029fa4' } | ConvertTo-QueryString
    Convert ordered dictionary to query string.
.INPUTS
    System.Collections.Hashtable
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-QueryString {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # URL encode parameter names
        [Parameter(Mandatory = $false)]
        [switch] $EncodeParameterNames
    )

    process {
        foreach ($InputObject in $InputObjects) {
            $QueryString = New-Object System.Text.StringBuilder
            if ($InputObject -is [hashtable] -or $InputObject -is [System.Collections.Specialized.OrderedDictionary] -or $InputObject.GetType().FullName.StartsWith('System.Collections.Generic.Dictionary')) {
                foreach ($Item in $InputObject.GetEnumerator()) {
                    if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') }
                    [string] $ParameterName = $Item.Key
                    if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) }
                    [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($Item.Value))
                }
            }
            elseif ($InputObject -is [object] -and $InputObject -isnot [ValueType]) {
                foreach ($Item in ($InputObject | Get-Member -MemberType Property, NoteProperty)) {
                    if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') }
                    [string] $ParameterName = $Item.Name
                    if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) }
                    [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($InputObject.($Item.Name)))
                }
            }
            else {
                ## Non-Terminating Error
                $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to query string.' -f $InputObject.GetType())
                Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertQueryStringFailureTypeNotSupported' -TargetObject $InputObject
                continue
            }

            Write-Output $QueryString.ToString()
        }
    }
}

#endregion

#region Expand-Data.ps1

<#
.SYNOPSIS
    Decompress data using DEFLATE (RFC 1951) or GZIP file format (RFC 1952).
.DESCRIPTION

.EXAMPLE
    [byte[]] $byteArray = @(115,84,40,46,41,202,204,75,87,72,203,47,82,72,206,207,45,40,74,45,46,206,204,207,3,0)
    PS C:\>Expand-Data $byteArray
    Decompress string using Deflate.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Expand-Data {
    [CmdletBinding()]
    [Alias('Decompress-Data')]
    [Alias('Inflate-Data')]
    [OutputType([string], [byte[]])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # Input is gzip file format
        [Parameter(Mandatory = $false)]
        [switch] $GZip,
        # Output raw byte array
        [Parameter (Mandatory = $false)]
        [switch] $RawBytes,
        # Encoding to use for text strings
        [Parameter (Mandatory = $false)]
        [ValidateSet('Ascii', 'UTF32', 'UTF7', 'UTF8', 'BigEndianUnicode', 'Unicode')]
        [string] $Encoding = 'Default'
    )

    begin {
        function Expand ([byte[]]$InputBytes) {
            try {
                $streamOutput = New-Object System.IO.MemoryStream
                try {
                    $streamInput = New-Object System.IO.MemoryStream -ArgumentList @($InputBytes, $false)
                    try {
                        if ($GZip) {
                            $streamCompression = New-Object System.IO.Compression.GZipStream -ArgumentList $streamInput, ([System.IO.Compression.CompressionMode]::Decompress)
                        }
                        else {
                            $streamCompression = New-Object System.IO.Compression.DeflateStream -ArgumentList $streamInput, ([System.IO.Compression.CompressionMode]::Decompress)
                        }
                        $streamCompression.CopyTo($streamOutput)
                    }
                    catch {
                        Write-Error -Exception $_.Exception.InnerException -Category ([System.Management.Automation.ErrorCategory]::InvalidData) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ExpandDataFailureInvalidData' -TargetObject $InputBytes -ErrorAction Stop
                    }
                    finally { $streamCompression.Dispose() }
                    [byte[]] $OutputBytes = $streamOutput.ToArray()
                }
                finally { $streamInput.Dispose() }
            }
            finally { $streamOutput.Dispose() }

            Write-Output $OutputBytes
        }

        ## Create list to capture byte stream from piped input.
        [System.Collections.Generic.List[byte]] $listBytes = New-Object System.Collections.Generic.List[byte]
    }

    process {
        if ($InputObjects -is [byte[]]) {
            [byte[]] $outBytes = Expand $InputObjects
            if ($RawBytes) {
                Write-Output $outBytes -NoEnumerate
            }
            else {
                [string] $outString = ([Text.Encoding]::$Encoding.GetString($outBytes))
                Write-Output $outString
            }
        }
        else {
            foreach ($InputObject in $InputObjects) {
                [byte[]] $InputBytes = $null
                if ($InputObject -is [byte]) {
                    ## Populate list with byte stream from piped input.
                    if ($listBytes.Count -eq 0) {
                        Write-Verbose 'Creating byte array from byte stream.'
                        Write-Warning ('For better performance when piping a single byte array, use "Write-Output $byteArray -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                    }
                    $listBytes.Add($InputObject)
                }
                elseif ($InputObject -is [byte[]]) {
                    $InputBytes = $InputObject
                }
                elseif ($InputObject -is [bool] -or $InputObject -is [char] -or $InputObject -is [single] -or $InputObject -is [double] -or $InputObject -is [int16] -or $InputObject -is [int32] -or $InputObject -is [int64] -or $InputObject -is [uint16] -or $InputObject -is [uint32] -or $InputObject -is [uint64]) {
                    $InputBytes = [System.BitConverter]::GetBytes($InputObject)
                }
                elseif ($InputObject -is [System.IO.FileSystemInfo]) {
                    if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -AsByteStream
                    }
                    else {
                        $InputBytes = Get-Content $InputObject.FullName -Raw -Encoding Byte
                    }
                }
                else {
                    ## Non-Terminating Error
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot compress input of type {0}.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'CompressDataFailureTypeNotSupported' -TargetObject $InputObject
                }

                if ($null -ne $InputBytes -and $InputBytes.Count -gt 0) {
                    [byte[]] $outBytes = Expand $InputBytes
                    if ($RawBytes) {
                        Write-Output $outBytes -NoEnumerate
                    }
                    else {
                        [string] $outString = ([Text.Encoding]::$Encoding.GetString($outBytes))
                        Write-Output $outString
                    }
                }
            }
        }
    }

    end {
        ## Output captured byte stream from piped input.
        if ($listBytes.Count -gt 0) {
            [byte[]] $outBytes = Expand $listBytes.ToArray()
            if ($RawBytes) {
                Write-Output $outBytes -NoEnumerate
            }
            else {
                [string] $outString = ([Text.Encoding]::$Encoding.GetString($outBytes))
                Write-Output $outString
            }
        }
    }
}

#endregion

#region Get-GraphBaseUri.ps1

<#
.SYNOPSIS
    Return the base URI for Graph API based on the current Graph Context's environment.
.DESCRIPTION

#>


function Get-GraphBaseUri {
    [CmdletBinding()]
    [OutputType([string])]
    param ()

    begin {
        $baseUri = 'https://graph.microsoft.com'
        try {
            $context = Get-MgContext
            $environment =  Get-ObjectPropertyValue $context -Name 'Environment'
            if($null -eq $environment){
                $environment = 'Global'
            }
            $baseUri = (Get-MgEnvironment -Name $environment).GraphEndpoint
        }
        catch {

        }
        Write-Output $baseUri
    }
}

#endregion

#region Get-MsftUserRealm.ps1

<#
.SYNOPSIS
   Get User Realm Information for a Microsoft user account.
.EXAMPLE
   Get-MsftUserRealm user@domain.com
.EXAMPLE
   'user1@domainA.com','user2@domainA.com','user@domainB.com' | Get-MsftUserRealm
#>

function Get-MsftUserRealm {
    [CmdletBinding()]
    [OutputType([PsCustomObject[]])]
    param (
        # User Principal Name
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [string[]] $User,
        # Check For Microsoft Account
        [Parameter(Mandatory = $false)]
        [switch] $CheckForMicrosoftAccount,
        # API Version
        [Parameter(Mandatory = $false)]
        [string] $ApiVersion = '2.1'
    )

    process {
        foreach ($_User in $User) {
            $uriUserRealm = New-Object System.UriBuilder 'https://login.microsoftonline.com/common/userrealm'
            $uriUserRealm.Query = ConvertTo-QueryString @{
                'api-version' = $ApiVersion
                'checkForMicrosoftAccount' = $CheckForMicrosoftAccount
                'user'        = $_User
            }

            $Result = Invoke-RestMethod -UseBasicParsing -Method Get -Uri $uriUserRealm.Uri.AbsoluteUri
            Write-Output $Result
        }
    }
}

#endregion

#region Get-ObjectPropertyValue.ps1

<#
.SYNOPSIS
    Get object property value.
.EXAMPLE
    PS C:\>$object = New-Object psobject -Property @{ title = 'title value' }
    PS C:\>$object | Get-ObjectPropertyValue -Property 'title'
    Get value of object property named title.
.EXAMPLE
    PS C:\>$object = New-Object psobject -Property @{ lvl1 = (New-Object psobject -Property @{ nextLevel = 'lvl2 data' }) }
    PS C:\>Get-ObjectPropertyValue $object -Property 'lvl1', 'nextLevel'
    Get value of nested object property named nextLevel.
.INPUTS
    System.Collections.Hashtable
    System.Management.Automation.PSObject
#>

function Get-ObjectPropertyValue {
    [CmdletBinding()]
    [OutputType([psobject])]
    param (
        # Object containing property values
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [AllowNull()]
        [psobject] $InputObjects,
        # Name of property. Specify an array of property names to tranverse nested objects.
        [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
        [string[]] $Property
    )

    process {
        foreach ($InputObject in $InputObjects) {
            for ($iProperty = 0; $iProperty -lt $Property.Count; $iProperty++) {
                ## Get property value
                if ($InputObject -is [hashtable]) {
                    if ($InputObject.ContainsKey($Property[$iProperty])) {
                        $PropertyValue = $InputObject[$Property[$iProperty]]
                    }
                    else { $PropertyValue = $null }
                }
                else {
                    $PropertyValue = Select-Object -InputObject $InputObject -ExpandProperty $Property[$iProperty] -ErrorAction Ignore
                    if ($null -eq $PropertyValue) { break }
                }
                ## Check for more nested properties
                if ($iProperty -lt $Property.Count - 1) {
                    $InputObject = $PropertyValue
                    if ($null -eq $InputObject) { break }
                }
                else {
                    Write-Output $PropertyValue
                }
            }
        }
    }
}

#endregion

#region Get-OpenIdProviderConfiguration.ps1

<#
.SYNOPSIS
    Parse OpenId Provider Configuration and Keys
.EXAMPLE
    PS C:\>Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com | Get-OpenIdProviderConfiguration
    Get OpenId Provider Configuration for a specific Microsoft organizational tenant (Azure AD).
.EXAMPLE
    PS C:\>Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com | Get-OpenIdProviderConfiguration -Keys
    Get public keys for OpenId Provider for a specific Microsoft organizational tenant (Azure AD).
.EXAMPLE
    PS C:\>Get-MsIdAuthorityUri -Msa | Get-OpenIdProviderConfiguration
    Get OpenId Provider Configuration for Microsoft consumer accounts (MSA).
.EXAMPLE
    PS C:\>Get-OpenIdProviderConfiguration 'https://accounts.google.com/'
    Get OpenId Provider Configuration for Google Accounts.
.INPUTS
    System.Uri
#>

function Get-OpenIdProviderConfiguration {
    [CmdletBinding()]
    [OutputType([PsCustomObject[]])]
    param (
        # Identity Provider Authority URI
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [uri] $Issuer,
        # Return configuration keys
        [Parameter(Mandatory = $false)]
        [switch] $Keys
    )
    ## Build common OpenId provider configuration URI
    $uriOpenIdProviderConfiguration = New-Object System.UriBuilder $Issuer.AbsoluteUri
    if (!$uriOpenIdProviderConfiguration.Path.EndsWith('/.well-known/openid-configuration')) { $uriOpenIdProviderConfiguration.Path += '/.well-known/openid-configuration' }

    ## Download and parse configuration
    $OpenIdProviderConfiguration = Invoke-RestMethod -UseBasicParsing -Uri $uriOpenIdProviderConfiguration.Uri.AbsoluteUri  # Should return ContentType 'application/json'
    if ($Keys) {
        $OpenIdProviderConfigurationJwks = Invoke-RestMethod -UseBasicParsing -Uri $OpenIdProviderConfiguration.jwks_uri  # Should return ContentType 'application/json'
        return $OpenIdProviderConfigurationJwks.keys
    }
    else {
        return $OpenIdProviderConfiguration
    }
}

#endregion

#region Get-ParsedTokenFromResponse.ps1

<#
.SYNOPSIS
    Parses token from response as plain text string.
.EXAMPLE
    PS C:\>Get-ParsedTokenFromResponse $response
    Parses token from $response as plain text string.
#>

function Get-ParsedTokenFromResponse {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # HTTP response
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string] $HttpResponse,
        [Parameter(Mandatory=$true, Position = 1)]
        # Protocol SAML or WsFed
        [ValidateSet("SAML", "WsFed")]
        [string]$Protocol
  
    )

    $token = ""

    if ($Protocol -eq "SAML") {
        # <input type="hidden" name="SAMLResponse" value=" ... " />
        if($HttpResponse -match '<input type=\"hidden\" name=\"SAMLResponse\" value=\"(.+)\" \/><noscript>') {
            # $token = $Matches[1] | ConvertFrom-Base64String
            $token = $Matches[1] | ConvertFrom-SamlMessage
        }
    }
    else {
        # <input type="hidden" name="wresult" value=" ... " />
        if($HttpResponse -match '<input type=\"hidden\" name=\"wresult\" value=\"(.+)\" \/><noscript>') {
            $token = [System.Net.WebUtility]::HtmlDecode($Matches[1]) | ConvertFrom-SamlMessage
        }
    }


    return $token
}
#endregion

#region Get-SamlFederationMetadata.ps1

<#
.SYNOPSIS
    Parse Federation Metadata
.EXAMPLE
    PS C:\>Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com -AppType 'Saml' | Get-SamlFederationMetadata
    Get SAML or WS-Fed Federation Metadata for a specific Microsoft tenant.
.EXAMPLE
    PS C:\>Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com -AppType 'Saml' | Get-SamlFederationMetadata -AppId 00000000-0000-0000-0000-000000000000
    Get SAML or WS-Fed Federation Metadata for a specific application within a specific Microsoft tenant.
.EXAMPLE
    PS C:\>Get-SamlFederationMetadata 'https://adfs.contoso.com'
    Get SAML or WS-Fed Federation Metadata for an ADFS farm.
.INPUTS
    System.Uri
#>

function Get-SamlFederationMetadata {
    [CmdletBinding()]
    [Alias('Get-WsFedFederationMetadata')]
    [OutputType([xml], [System.Xml.XmlElement[]])]
    param (
        # Identity Provider Authority URI
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [uri] $Issuer,
        # Azure AD Application Id
        [Parameter(Mandatory = $false, Position = 2)]
        [guid] $AppId
    )

    ## Remove Microsoft v2.0 endpoint because it is only for OAuth2
    if ($Issuer.Authority -eq 'login.microsoftonline.com') { $Issuer = $Issuer.AbsoluteUri -replace '[/\\]v2.0[/\\]?$', '' }

    ## Build common federation metadata URI
    $uriFederationMetadata = New-Object System.UriBuilder $Issuer.AbsoluteUri
    if (!$uriFederationMetadata.Path.EndsWith('/FederationMetadata/2007-06/FederationMetadata.xml', $true, $null)) { $uriFederationMetadata.Path += '/FederationMetadata/2007-06/FederationMetadata.xml' }
    if ($AppId) {
        $uriFederationMetadata.Query = ConvertTo-QueryString @{
            AppId = $AppId
        }
    }

    ## Download and parse federation metadata
    $FederationMetadata = Invoke-RestMethod -UseBasicParsing -Uri $uriFederationMetadata.Uri.AbsoluteUri -ErrorAction Stop  # Should return ContentType 'application/samlmetadata+xml'
    if ($FederationMetadata -is [string]) {
        try {
            [xml] $xmlFederationMetadata = $FederationMetadata -replace '^[^<]*', ''
        }
        catch { throw }
    }
    else {
        [xml] $xmlFederationMetadata = $FederationMetadata
    }

    return $xmlFederationMetadata.GetElementsByTagName('EntityDescriptor')
}

#endregion

#region Get-X509Certificate.ps1

<#
.SYNOPSIS
    Get certificate object for X509 certificate.
.DESCRIPTION
    Get certificate object for X509 certificate.
.EXAMPLE
    PS C:\>[byte[]] $DERCert = @(48,130,4,18,48,130,2,250,160,3,2,1,2,2,15,0,193,0,139,60,60,136,17,209,62,246,99,236,223,64,48,13,6,9,42,134,72,134,247,13,1,1,4,5,0,48,112,49,43,48,41,6,3,85,4,11,19,34,67,111,112,121,114,105,103,104,116,32,40,99,41,32,49,57,57,55,32,77,105,99,114,111,115,111,102,116,32,67,111,114,112,46,49,30,48,28,6,3,85,4,11,19,21,77,105,99,114,111,115,111,102,116,32,67,111,114,112,111,114,97,116,105,111,110,49,33,48,31,6,3,85,4,3,19,24,77,105,99,114,111,115,111,102,116,32,82,111,111,116,32,65,117,116,104,111,114,105,116,121,48,30,23,13,57,55,48,49,49,48,48,55,48,48,48,48,90,23,13,50,48,49,50,51,49,48,55,48,48,48,48,90,48,112,49,43,48,41,6,3,85,4,11,19,34,67,111,112,121,114,105,103,104,116,32,40,99,41,32,49,57,57,55,32,77,105,99,114,111,115,111,102,116,32,67,111,114,112,46,49,30,48,28,6,3,85,4,11,19,21,77,105,99,114,111,115,111,102,116,32,67,111,114,112,111,114,97,116,105,111,110,49,33,48,31,6,3,85,4,3,19,24,77,105,99,114,111,115,111,102,116,32,82,111,111,116,32,65,117,116,104,111,114,105,116,121,48,130,1,34,48,13,6,9,42,134,72,134,247,13,1,1,1,5,0,3,130,1,15,0,48,130,1,10,2,130,1,1,0,169,2,189,193,112,230,59,242,78,27,40,159,151,120,94,48,234,162,169,141,37,95,248,254,149,76,163,183,254,157,162,32,62,124,81,162,155,162,143,96,50,107,209,66,100,121,238,172,118,201,84,218,242,235,156,134,28,143,159,132,102,179,197,107,122,98,35,214,29,60,222,15,1,146,232,150,196,191,45,102,154,154,104,38,153,208,58,44,191,12,181,88,38,193,70,231,10,62,56,150,44,169,40,57,168,236,73,131,66,227,132,15,187,154,108,85,97,172,130,124,161,96,45,119,76,233,153,180,100,59,154,80,28,49,8,36,20,159,169,231,145,43,24,230,61,152,99,20,96,88,5,101,159,29,55,82,135,247,167,239,148,2,198,27,211,191,85,69,179,137,128,191,58,236,84,148,78,174,253,167,122,109,116,78,175,24,204,150,9,40,33,0,87,144,96,105,55,187,75,18,7,60,86,255,91,251,164,102,10,8,166,210,129,86,87,239,182,59,94,22,129,119,4,218,246,190,174,128,149,254,176,205,127,214,167,26,114,92,60,202,188,240,8,163,34,48,179,6,133,201,179,32,119,19,133,223,2,3,1,0,1,163,129,168,48,129,165,48,129,162,6,3,85,29,1,4,129,154,48,129,151,128,16,91,208,112,239,105,114,158,35,81,126,20,178,77,142,255,203,161,114,48,112,49,43,48,41,6,3,85,4,11,19,34,67,111,112,121,114,105,103,104,116,32,40,99,41,32,49,57,57,55,32,77,105,99,114,111,115,111,102,116,32,67,111,114,112,46,49,30,48,28,6,3,85,4,11,19,21,77,105,99,114,111,115,111,102,116,32,67,111,114,112,111,114,97,116,105,111,110,49,33,48,31,6,3,85,4,3,19,24,77,105,99,114,111,115,111,102,116,32,82,111,111,116,32,65,117,116,104,111,114,105,116,121,130,15,0,193,0,139,60,60,136,17,209,62,246,99,236,223,64,48,13,6,9,42,134,72,134,247,13,1,1,4,5,0,3,130,1,1,0,149,232,11,192,141,243,151,24,53,237,184,1,36,216,119,17,243,92,96,50,159,158,11,203,62,5,145,136,143,201,58,230,33,242,240,87,147,44,181,160,71,200,98,239,252,215,204,59,59,90,169,54,84,105,254,36,109,63,201,204,170,222,5,124,221,49,141,61,159,16,112,106,187,254,18,79,24,105,192,252,208,67,227,17,90,32,79,234,98,123,175,170,25,200,43,55,37,45,190,101,161,18,138,37,15,99,163,247,84,28,249,33,201,214,21,243,82,172,110,67,50,7,253,130,23,248,229,103,108,13,81,246,189,241,82,199,189,231,196,48,252,32,49,9,136,29,149,41,26,77,213,29,2,165,241,128,224,3,180,91,244,177,221,200,87,238,101,73,199,82,84,182,180,3,40,18,255,144,214,240,8,143,126,184,151,197,171,55,44,228,122,228,168,119,227,118,160,0,208,106,63,193,210,54,138,224,65,18,168,53,106,27,106,219,53,225,212,28,4,228,168,69,4,200,90,51,56,110,77,28,13,98,183,10,162,140,211,213,84,63,70,205,28,85,166,112,219,18,58,135,147,117,159,167,210,160)
    PS C:\>Get-X509Certificate $DERCert -Verbose
    Get certificate details from binary (DER) encoded X509 certificate.
.EXAMPLE
    PS C:\>[string] $Base64Cert = 'MIIEEjCCAvqgAwIBAgIPAMEAizw8iBHRPvZj7N9AMA0GCSqGSIb3DQEBBAUAMHAxKzApBgNVBAsTIkNvcHlyaWdodCAoYykgMTk5NyBNaWNyb3NvZnQgQ29ycC4xHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEhMB8GA1UEAxMYTWljcm9zb2Z0IFJvb3QgQXV0aG9yaXR5MB4XDTk3MDExMDA3MDAwMFoXDTIwMTIzMTA3MDAwMFowcDErMCkGA1UECxMiQ29weXJpZ2h0IChjKSAxOTk3IE1pY3Jvc29mdCBDb3JwLjEeMBwGA1UECxMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSEwHwYDVQQDExhNaWNyb3NvZnQgUm9vdCBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpAr3BcOY78k4bKJ+XeF4w6qKpjSVf+P6VTKO3/p2iID58UaKboo9gMmvRQmR57qx2yVTa8uuchhyPn4Rms8VremIj1h083g8BkuiWxL8tZpqaaCaZ0Dosvwy1WCbBRucKPjiWLKkoOajsSYNC44QPu5psVWGsgnyhYC13TOmZtGQ7mlAcMQgkFJ+p55ErGOY9mGMUYFgFZZ8dN1KH96fvlALGG9O/VUWziYC/OuxUlE6u/ad6bXROrxjMlgkoIQBXkGBpN7tLEgc8Vv9b+6RmCgim0oFWV++2O14WgXcE2va+roCV/rDNf9anGnJcPMq88AijIjCzBoXJsyB3E4XfAgMBAAGjgagwgaUwgaIGA1UdAQSBmjCBl4AQW9Bw72lyniNRfhSyTY7/y6FyMHAxKzApBgNVBAsTIkNvcHlyaWdodCAoYykgMTk5NyBNaWNyb3NvZnQgQ29ycC4xHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEhMB8GA1UEAxMYTWljcm9zb2Z0IFJvb3QgQXV0aG9yaXR5gg8AwQCLPDyIEdE+9mPs30AwDQYJKoZIhvcNAQEEBQADggEBAJXoC8CN85cYNe24ASTYdxHzXGAyn54Lyz4FkYiPyTrmIfLwV5MstaBHyGLv/NfMOztaqTZUaf4kbT/JzKreBXzdMY09nxBwarv+Ek8YacD80EPjEVogT+pie6+qGcgrNyUtvmWhEoolD2Oj91Qc+SHJ1hXzUqxuQzIH/YIX+OVnbA1R9r3xUse958Qw/CAxCYgdlSkaTdUdAqXxgOADtFv0sd3IV+5lScdSVLa0AygS/5DW8AiPfriXxas3LOR65Kh343agANBqP8HSNorgQRKoNWobats14dQcBOSoRQTIWjM4bk0cDWK3CqKM09VUP0bNHFWmcNsSOoeTdZ+n0qA='
    PS C:\>$Base64Cert | Get-X509Certificate -Verbose
    Get certificate details from Base64 encoded X509 certificate.
.EXAMPLE
    PS C:\>Get-Item "certificateFile.cer" | Get-X509Certificate
    Get certificate details from .cer file.
.INPUTS
    System.Object
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Get-X509Certificate {
    [CmdletBinding()]
    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2], [System.Security.Cryptography.X509Certificates.X509Certificate2Collection])]
    param (
        # X.509 certificate that is binary (DER) encoded or Base64-encoded
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [object] $InputObjects,
        # Only return the end-entity certificate
        [Parameter(Mandatory = $false)]
        [switch] $EndEntityCertificateOnly
    )

    begin {
        ## Create list to capture byte stream from piped input.
        [System.Collections.Generic.List[byte]] $listBytes = New-Object System.Collections.Generic.List[byte]

        function Transform ([byte[]]$InputBytes) {
            $X509CertificateCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
            $X509CertificateCollection.Import($InputBytes, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet)
            Write-Output $X509CertificateCollection -NoEnumerate
        }
    }

    process {
        if ($InputObjects -is [byte[]]) {
            $X509CertificateCollection = Transform $InputObjects
            if ($EndEntityCertificateOnly) { Write-Output $X509CertificateCollection[-1] }
            else { Write-Output $X509CertificateCollection }
        }
        else {
            foreach ($InputObject in $InputObjects) {
                [byte[]] $inputBytes = $null
                if ($InputObject -is [byte]) {
                    ## Populate list with byte stream from piped input.
                    if ($listBytes.Count -eq 0) {
                        Write-Verbose 'Creating byte array from byte stream.'
                        Write-Warning ('For better performance when piping a single byte array, use "Write-Output $byteArray -NoEnumerate | {0}".' -f $MyInvocation.MyCommand)
                    }
                    $listBytes.Add($InputObject)
                }
                elseif ($InputObject -is [byte[]]) {
                    $inputBytes = $InputObject
                }
                elseif ($InputObject -is [SecureString]) {
                    Write-Verbose 'Decrypting SecureString and decoding Base64 string to byte array.'
                    if ($PSVersionTable.PSVersion -ge [version]'7.0') {
                        $inputBytes = [System.Convert]::FromBase64String((ConvertFrom-SecureString $InputObject -AsPlainText))
                    }
                    else {
                        $inputBytes = [System.Convert]::FromBase64String((ConvertFrom-SecureStringAsPlainText $InputObject -Force))
                    }
                }
                elseif ($InputObject -is [string]) {
                    Write-Verbose 'Decoding Base64 string to byte array.'
                    $inputBytes = [System.Convert]::FromBase64String($InputObject)
                }
                elseif ($InputObject -is [System.IO.FileSystemInfo]) {
                    Write-Verbose 'Decoding file content to byte array.'
                    if ($PSVersionTable.PSVersion -ge [version]'6.0') {
                        $inputBytes = Get-Content $InputObject.FullName -Raw -AsByteStream
                    }
                    else {
                        $inputBytes = Get-Content $InputObject.FullName -Raw -Encoding Byte
                    }
                }
                else {
                    # Otherwise, write a terminating error message indicating that input object type is not supported.
                    $errorMessage = 'Cannot convert input of type {0} to X.509 certificate.' -f $InputObject.GetType()
                    Write-Error -Message $errorMessage -Category ([System.Management.Automation.ErrorCategory]::ParserError) -ErrorId 'GetX509CertificateFailureTypeNotSupported' -ErrorAction Stop
                }

                ## Only write output if the input is not a byte stream.
                if ($listBytes.Count -eq 0) {
                    $X509CertificateCollection = Transform $inputBytes
                    if ($EndEntityCertificateOnly) { Write-Output $X509CertificateCollection[-1] }
                    else { Write-Output $X509CertificateCollection }
                }
            }
        }
    }

    end {
        ## Output captured byte stream from piped input.
        if ($listBytes.Count -gt 0) {
            $X509CertificateCollection = Transform $listBytes
            if ($EndEntityCertificateOnly) { Write-Output $X509CertificateCollection[-1] }
            else { Write-Output $X509CertificateCollection }
        }
    }
}

#endregion

#region Import-AdfsModule.ps1

<#
.SYNOPSIS
    Imports the AD FS PowerShell module.
.DESCRIPTION
    Imports the AD FS PowerShell module if not imported and returns $true. Returns $false in case it is not installed.
.EXAMPLE
    PS > if (Import-AdfsModule) { Write-Host 'AD FS PowerShell module is present' }

    Displays a string if the AD FS module was sucessfully imported.
#>

function Import-AdfsModule {
    $module = 'ADFS'
    
    if(-not(Get-Module -Name $module)) {
        if(Get-Module -ListAvailable | Where-Object { $_.name -eq $module }) {
            Import-Module -Name $module
            $true
        }
        else { $false }
    }
    else { $true } #module already loaded
}

#endregion

#region Invoke-CommandAsSystem.ps1

<#
.SYNOPSIS
    Run PowerShell commands under system context.
.EXAMPLE
    PS C:\>Invoke-CommandAsSystem { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name }
    Run the ScriptBlock under the system context.
.INPUTS
    System.Management.Automation.ScriptBlock
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Invoke-CommandAsSystem {
    [CmdletBinding()]
    param (
        #
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [ScriptBlock] $ScriptBlock,
        #
        [Parameter(Mandatory = $false, Position = 2)]
        [string[]] $ArgumentList
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        try {
            Import-Module PSScheduledJob, ScheduledTasks -ErrorAction Stop
        }
        catch { Write-Error -ErrorRecord $_ -ErrorVariable CriticalError; return }
    }

    process {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        ## Process
        [guid] $GUID = New-Guid

        try {
            ## Register ScheduleJob
            if ($ArgumentList) {
                $ScheduledJob = Register-ScheduledJob -Name $GUID -ScheduledJobOption (New-ScheduledJobOption -RunElevated) -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
            }
            else {
                $ScheduledJob = Register-ScheduledJob -Name $GUID -ScheduledJobOption (New-ScheduledJobOption -RunElevated) -ScriptBlock $ScriptBlock -ErrorAction Stop
            }
            try {
                ## Register ScheduledTask for ScheduledJob
                $ScheduledTask = Register-ScheduledTask -TaskName $GUID -Action (New-ScheduledTaskAction -Execute $ScheduledJob.PSExecutionPath -Argument $ScheduledJob.PSExecutionArgs) -Principal (New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest) -ErrorAction Stop

                try {
                    ## Execute ScheduledTask Job to Run ScheduledJob Job
                    $ScheduledTask | Start-ScheduledTask -AsJob -ErrorAction Stop | Wait-Job | Remove-Job -Force -Confirm:$False

                    ## Wait for ScheduledTask to finish
                    While (($ScheduledTask | Get-ScheduledTaskInfo).LastTaskResult -eq 267009) { Start-Sleep -Milliseconds 150 }

                    ## Find ScheduledJob and get the result
                    $Job = Get-Job -Name $GUID -ErrorAction SilentlyContinue | Wait-Job
                    $Result = $Job | Receive-Job -Wait -AutoRemoveJob
                }
                finally {
                    ## Unregister ScheduledTask for ScheduledJob
                    $ScheduledTask | Unregister-ScheduledTask -Confirm:$false
                }
            }
            finally {
                ## Unregister ScheduleJob
                $ScheduledJob | Unregister-ScheduledJob -Force -Confirm:$False
            }
        }
        catch { Write-Error -ErrorRecord $_; return }

        return $Result
    }
}

#endregion

#region New-AdfsLoginFormFields.ps1

<#
.SYNOPSIS
    Gets the form fields to login to AD FS server for the login URL and credentials.
.DESCRIPTION
.EXAMPLE
    PS C:\>New-AdfsLoginFormFields -Url $url -Credential $credential
    Gets the form fields for the variables.
#>

function New-AdfsLoginFormFields {
    [CmdletBinding()]
    [OutputType([System.Collections.Generic.Dictionary[string, string]])]
    param (
        # User credential
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [pscredential] $Credential
    )

    $user = $Credential.UserName
    $password = ConvertFrom-SecureStringAsPlainText $Credential.Password -Force

    $fields = New-Object -TypeName "System.Collections.Generic.Dictionary[string,string]"
    $fields.Add("UserName",$user)
    $fields.Add("Password",$password)
    $fields.Add("AuthMethod","FormsAuthentication")

    return $fields
}
#endregion

#region Resolve-XmlAttribute.ps1

function Resolve-XmlAttribute {
    [CmdletBinding(DefaultParameterSetName = "QualifiedName")]
    [OutputType([System.Xml.XmlAttribute])]
    param
    (
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, Position = 1)]
        [System.Xml.XmlElement] $ParentNode,
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "QualifiedName")]
        [string] $QualifiedName,
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $Prefix,
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $LocalName,
        #
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, ParameterSetName = "QualifiedName")]
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $NamespaceURI,
        #
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [Alias("Create")]
        [switch] $CreateMissing
    )

    process {
        [System.Xml.XmlAttribute] $xmlAttribute = $null
        switch ($PSCmdlet.ParameterSetName) {
            'QualifiedName' {
                $resultSelectXml = Select-Xml -Xml $ParentNode -XPath ('@{0}' -f $QualifiedName)
                if ($resultSelectXml) { $xmlAttribute = $resultSelectXml.Node }
                elseif ($CreateMissing) {
                    if ($NamespaceURI) { $xmlAttribute = $ParentNode.SetAttributeNode($ParentNode.OwnerDocument.CreateAttribute($QualifiedName, $NamespaceURI)) }
                    else { $xmlAttribute = $ParentNode.SetAttributeNode(($ParentNode.OwnerDocument.CreateAttribute($QualifiedName))) }
                }
                break 
            }
            'Prefix' {
                if ($Prefix -eq "xmlns") { $resultSelectXml = Select-Xml -Xml $ParentNode -XPath ('namespace::{0}' -f $LocalName) }
                else { $resultSelectXml = Select-Xml -Xml $ParentNode -XPath ('@{0}:{1}' -f $Prefix, $LocalName) -Namespace @{ $Prefix = $ParentNode.GetNamespaceOfPrefix($Prefix) } }
                if ($resultSelectXml) { $xmlAttribute = $resultSelectXml.Node }
                elseif ($CreateMissing) { $xmlAttribute = $ParentNode.SetAttributeNode($ParentNode.OwnerDocument.CreateAttribute($Prefix, $LocalName, $ParentNode.GetNamespaceOfPrefix($Prefix))) }
                break 
            }
        }
        return $xmlAttribute
    }
}

#endregion

#region Resolve-XmlElement.ps1

function Resolve-XmlElement {
    [CmdletBinding(DefaultParameterSetName = "QualifiedName")]
    [OutputType([System.Xml.XmlElement[]])]
    param
    (
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, Position = 1)]
        [System.Xml.XmlElement] $ParentNode,
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "QualifiedName")]
        [string] $QualifiedName,
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $Prefix,
        #
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $LocalName,
        #
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true,  ParameterSetName = "QualifiedName")]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Prefix")]
        [string] $NamespaceURI,
        #
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [Alias("Clear")]
        [switch] $ClearExisting = $false,
        #
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [Alias("Create")]
        [switch] $CreateMissing
    )
        
    process {
        [System.Xml.XmlElement[]] $xmlElement = @()
                
        switch ($PSCmdlet.ParameterSetName) {
            'QualifiedName' {
                [Microsoft.PowerShell.Commands.SelectXmlInfo[]] $resultSelectXml = Select-Xml -Xml $ParentNode -XPath ('./p:{0}' -f $QualifiedName) -Namespace @{ 'p' = $ParentNode.NamespaceURI }
                if ($ClearExisting) {
                    foreach ($result in $resultSelectXml) { $ParentNode.RemoveChild($result.Node) | Out-Null }
                    $resultSelectXml = @()
                }
                if ($resultSelectXml) { $xmlElement = $resultSelectXml.Node }
                elseif ($CreateMissing) { $xmlElement = $ParentNode.AppendChild($ParentNode.OwnerDocument.CreateElement($QualifiedName, $ParentNode.NamespaceURI)) }
                break 
            }
            'Prefix' {
                [Microsoft.PowerShell.Commands.SelectXmlInfo[]] $resultSelectXml = Select-Xml -Xml $ParentNode -XPath ('./{0}:{1}' -f $Prefix, $LocalName) -Namespace @{ $Prefix = $ParentNode.GetNamespaceOfPrefix($Prefix) }
                if ($ClearExisting) {
                    foreach ($result in $resultSelectXml) { $ParentNode.RemoveChild($result.Node) | Out-Null }
                    $resultSelectXml = @()
                }
                if ($resultSelectXml) { $xmlElement = $resultSelectXml.Node }
                elseif ($CreateMissing) { $xmlElement = $ParentNode.AppendChild($ParentNode.OwnerDocument.CreateElement($Prefix, $LocalName, $ParentNode.GetNamespaceOfPrefix($Prefix))) }
                break 
            }
        }
        return $xmlElement
    }
}
#endregion

#region Test-IpAddressInSubnet.ps1

<#
.SYNOPSIS
    Determine if an IP address exists in the specified subnet.
.EXAMPLE
    PS C:\>Test-IpAddressInSubnet 192.168.1.10 -Subnet '192.168.1.1/32','192.168.1.0/24'
    Determine if the IPv4 address exists in the specified subnet.
.EXAMPLE
    PS C:\>Test-IpAddressInSubnet 2001:db8:1234::1 -Subnet '2001:db8:a::123/64','2001:db8:1234::/48'
    Determine if the IPv6 address exists in the specified subnet.
.INPUTS
    System.Net.IPAddress
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Test-IpAddressInSubnet {
    [CmdletBinding()]
    [OutputType([bool], [string[]])]
    param (
        # IP Address to test against provided subnets.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [ipaddress[]] $IpAddresses,
        # List of subnets in CIDR notation. For example, "192.168.1.0/24" or "2001:db8:1234::/48".
        [Parameter(Mandatory = $true)]
        [string[]] $Subnets,
        # Return list of matching subnets rather than a boolean result.
        [Parameter(Mandatory = $false)]
        [switch] $ReturnMatchingSubnets
    )

    begin {
        function ConvertBitArrayToByteArray([System.Collections.BitArray] $BitArray) {
            [byte[]] $ByteArray = New-Object byte[] ([System.Math]::Ceiling($BitArray.Length / 8))
            $BitArray.CopyTo($ByteArray, 0)
            return $ByteArray
        }

        function ConvertBitArrayToBigInt([System.Collections.BitArray] $BitArray) {
            return [bigint][byte[]](ConvertBitArrayToByteArray $BitArray)
        }
    }

    process {
        foreach ($IpAddress in $IpAddresses) {
            if ($IpAddress.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
                [int32] $bitIpAddress = [BitConverter]::ToInt32($IpAddress.GetAddressBytes(), 0)
            }
            else {
                [System.Collections.BitArray] $bitIpAddress = $IpAddress.GetAddressBytes()
            }

            [System.Collections.Generic.List[string]] $listSubnets = New-Object System.Collections.Generic.List[string]
            [bool] $Result = $false
            foreach ($Subnet in $Subnets) {
                [string[]] $SubnetComponents = $Subnet.Split('/')
                [ipaddress] $SubnetAddress = $SubnetComponents[0]
                [int] $SubnetMaskLength = $SubnetComponents[1]

                if ($IpAddress.AddressFamily -eq $SubnetAddress.AddressFamily) {
                    if ($IpAddress.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
                        ## Supports IPv4 (32 bit) only but more performant than BitArray?
                        #[int32] $bitIpAddress = [BitConverter]::ToInt32($IpAddress.GetAddressBytes(), 0)
                        [int32] $bitSubnetAddress = [BitConverter]::ToInt32($SubnetAddress.GetAddressBytes(), 0)
                        [int32] $bitSubnetMaskHostOrder = 0
                        if ($SubnetMaskLength -gt 0) {
                            $bitSubnetMaskHostOrder = -1 -shl (32 - $SubnetMaskLength)
                        }
                        [int32] $bitSubnetMask = [ipaddress]::HostToNetworkOrder($bitSubnetMaskHostOrder)

                        ## Check IP
                        if (($bitIpAddress -band $bitSubnetMask) -eq ($bitSubnetAddress -band $bitSubnetMask)) {
                            if ($ReturnMatchingSubnets) {
                                $listSubnets.Add($Subnet)
                            }
                            else {
                                $Result = $true
                                continue
                            }
                        }
                    }
                    else {
                        ## BitArray supports IPv4 (32 bits) and IPv6 (128 bits). Would Int128 type in .NET 7 improve performance?
                        #[System.Collections.BitArray] $bitIpAddress = $IpAddress.GetAddressBytes()
                        [System.Collections.BitArray] $bitSubnetAddress = $SubnetAddress.GetAddressBytes()
                        [System.Collections.BitArray] $bitSubnetMask = New-Object System.Collections.BitArray -ArgumentList ($bitSubnetAddress.Length - $SubnetMaskLength), $true
                        $bitSubnetMask.Length = $bitSubnetAddress.Length
                        [void]$bitSubnetMask.Not()
                        [byte[]] $ByteArray = ConvertBitArrayToByteArray $bitSubnetMask
                        [array]::Reverse($ByteArray)  # Convert to Network byte order
                        [System.Collections.BitArray] $bitSubnetMask = $ByteArray
                
                        ## Check IP
                        if ((ConvertBitArrayToBigInt $bitIpAddress.And($bitSubnetMask)) -eq (ConvertBitArrayToBigInt $bitSubnetAddress.And($bitSubnetMask))) {
                            if ($ReturnMatchingSubnets) {
                                $listSubnets.Add($Subnet)
                            }
                            else {
                                $Result = $true
                                continue
                            }
                        }
                    }
                }
            }

            ## Return list of matches or boolean result
            if ($ReturnMatchingSubnets) {
                if ($listSubnets.Count -gt 1) { Write-Output $listSubnets.ToArray() -NoEnumerate }
                elseif ($listSubnets.Count -eq 1) { Write-Output $listSubnets.ToArray() }
                else {
                    $Exception = New-Object ArgumentException -ArgumentList ('The IP address {0} does not belong to any of the provided subnets.' -f $IpAddress)
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ObjectNotFound) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'TestIpAddressInSubnetNoMatch' -TargetObject $IpAddress
                }
            }
            else {
                Write-Output $Result
            }
        }
    }
}

#endregion

#region Test-MgCommandPrerequisites.ps1

<#
.SYNOPSIS
    Test Mg Graph Command Prerequisites
.EXAMPLE
    PS > Test-MgCommandPrerequisites 'Get-MgUser'
.INPUTS
    System.String
#>

function Test-MgCommandPrerequisites {
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        # The name of a command.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 1)]
        [Alias('Command')]
        [string[]] $Name,
        # The service API version.
        [Parameter(Mandatory = $false, Position = 2)]
        [ValidateSet('v1.0')]
        [string] $ApiVersion = 'v1.0',
        # Specifies a minimum version.
        [Parameter(Mandatory = $false)]
        [version] $MinimumVersion,
        # Require "list" permissions rather than "get" permissions when Get-Mg* commands are specified.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [switch] $RequireListPermissions
    )

    begin {
        [version] $MgAuthenticationModuleVersion = $null
        $Assembly = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object FullName -Like "Microsoft.Graph.Authentication,*"
        if ($Assembly.FullName -match "Version=(([0-9]+.[0-9]+.[0-9]+).[0-9]+),") {
            $MgAuthenticationModuleVersion = $Matches[2]
        }
        else {
            $MgAuthenticationModuleVersion = Get-Command 'Connect-MgGraph' -Module 'Microsoft.Graph.Authentication' | Select-Object -ExpandProperty Version
        }
        Write-Debug "Microsoft.Graph.Authentication module version loaded: $MgAuthenticationModuleVersion"
    }

    process {
        ## Initialize
        $result = $true

        ## Get Graph Command Details
        [hashtable] $MgCommandLookup = @{}
        foreach ($CommandName in $Name) {
            [array] $MgCommands = Find-MgGraphCommand -Command $CommandName -ApiVersion $ApiVersion

            
            if ($MgCommands.Count -gt 1) {
                $MgCommand = $MgCommands[0]
                ## Resolve from multiple results
                [array] $MgCommandsWithPermissions = $MgCommands | Where-Object Permissions -NE $null
                [array] $MgCommandsWithListPermissions = $MgCommandsWithPermissions | Where-Object URI -NotLike "*}"
                [array] $MgCommandsWithGetPermissions = $MgCommandsWithPermissions | Where-Object URI -Like "*}"
                if ($MgCommandsWithListPermissions -and $RequireListPermissions) {
                    $MgCommand = $MgCommandsWithListPermissions[0]
                }
                elseif ($MgCommandsWithGetPermissions) {
                    $MgCommand = $MgCommandsWithGetPermissions[0]
                }
                else {
                    $MgCommand = $MgCommands[0]
                }
            }

            $MgCommandLookup[$MgCommand.Command] = $MgCommand
        }

        ## Import Required Modules
        [string[]] $MgModules = @()
        foreach ($MgCommand in $MgCommandLookup.Values) {
            if (!$MgModules.Contains($MgCommand.Module)) {
                $MgModules += $MgCommand.Module
                [string] $ModuleName = "Microsoft.Graph.$($MgCommand.Module)"
                try {
                    if ($MgAuthenticationModuleVersion -lt $MinimumVersion) {
                        ## Check for newer module but load will likely fail due to old Microsoft.Graph.Authentication module
                        try {
                            Import-Module $ModuleName -MinimumVersion $MinimumVersion -Scope Global -ErrorAction Stop -Verbose:$false
                        }
                        catch [System.IO.FileLoadException] {
                            $result = $false
                            Write-Error -Exception $_.Exception -Category ResourceUnavailable -ErrorId 'MgModuleOutOfDate' -Message ("The module '{0}' with minimum version '{1}' was found but currently loaded 'Microsoft.Graph.Authentication' module is version '{2}'. To resolve, try opening a new PowerShell session and running the command again." -f $ModuleName, $MinimumVersion, $MgAuthenticationModuleVersion) -TargetObject $ModuleName -RecommendedAction ("Import-Module {0} -MinimumVersion '{1}'" -f $ModuleName, $MinimumVersion)
                        }
                        catch [System.IO.FileNotFoundException] {
                            $result = $false
                            Write-Error -Exception $_.Exception -Category ResourceUnavailable -ErrorId 'MgModuleWithVersionNotFound' -Message ("The module '{0}' with minimum version '{1}' not found. To resolve, try installing module '{0}' with the latest version. For example: Install-Module {0} -MinimumVersion '{1}'" -f $ModuleName, $MinimumVersion) -TargetObject $ModuleName -RecommendedAction ("Install-Module {0} -MinimumVersion '{1}'" -f $ModuleName, $MinimumVersion)
                        }
                    }
                    else {
                        ## Load module to match currently loaded Microsoft.Graph.Authentication module
                        try {
                            Import-Module $ModuleName -RequiredVersion $MgAuthenticationModuleVersion -Scope Global -ErrorAction Stop -Verbose:$false
                        }
                        catch [System.IO.FileLoadException] {
                            $result = $false
                            Write-Error -Exception $_.Exception -Category ResourceUnavailable -ErrorId 'MgModuleOutOfDate' -Message ("The module '{0}' was found but is not a compatible version. To resolve, try updating module '{0}' to version '{1}' to match currently loaded modules. For example: Update-Module {0} -RequiredVersion '{1}'" -f $ModuleName, $MgAuthenticationModuleVersion) -TargetObject $ModuleName -RecommendedAction ("Update-Module {0} -RequiredVersion '{1}'" -f $ModuleName, $MgAuthenticationModuleVersion)
                        }
                        catch [System.IO.FileNotFoundException] {
                            $result = $false
                            Write-Error -Exception $_.Exception -Category ResourceUnavailable -ErrorId 'MgModuleWithVersionNotFound' -Message ("The module '{0}' with version '{1}' not found. To resolve, try installing module '{0}' with version '{1}' to match currently loaded modules. For example: Install-Module {0} -RequiredVersion '{1}'" -f $ModuleName, $MgAuthenticationModuleVersion) -TargetObject $ModuleName -RecommendedAction ("Install-Module {0} -RequiredVersion '{1}'" -f $ModuleName, $MgAuthenticationModuleVersion)
                        }
                    }
                }
                catch {
                    $result = $false
                    Write-Error -ErrorRecord $_
                }
            }
        }
        Write-Verbose ('Required Microsoft Graph Modules: {0}' -f (($MgModules | ForEach-Object { "Microsoft.Graph.$_" }) -join ', '))

        ## Check MgModule Connection
        $MgContext = Get-MgContext
        if ($MgContext) {
            if ($MgContext.AuthType -eq 'Delegated') {
                ## Check MgModule Consented Scopes
                foreach ($MgCommand in $MgCommandLookup.Values) {
                    if ($MgCommand.Permissions -and (!$MgContext.Scopes -or !(Compare-Object $MgCommand.Permissions.Name -DifferenceObject $MgContext.Scopes -ExcludeDifferent -IncludeEqual))) {
                        $Exception = New-Object System.Security.SecurityException -ArgumentList "Additional scope required for command '$($MgCommand.Command)', call Connect-MgGraph with one of the following scopes: $($MgCommand.Permissions.Name -join ', ')"
                        Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::PermissionDenied) -ErrorId 'MgScopePermissionRequired'
                        $result = $false
                    }
                }
            }
            else {
                ## Check MgModule Consented Scopes
                foreach ($MgCommand in $MgCommandLookup.Values) {
                    if ($MgCommand.Permissions -and (!$MgContext.Scopes -or !(Compare-Object $MgCommand.Permissions.Name -DifferenceObject $MgContext.Scopes -ExcludeDifferent -IncludeEqual))) {
                        Write-Warning "Additional scope may be required for command '$($MgCommand.Command), add and consent ClientId '$($MgContext.ClientId)' to one of the following app scopes: $($MgCommand.Permissions.Name -join ', ')"
                    }
                }
            }
        }
        else {
            $Exception = New-Object System.Security.Authentication.AuthenticationException -ArgumentList "Authentication needed, call Connect-MgGraph."
            Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::AuthenticationError) -CategoryReason 'AuthenticationException' -ErrorId 'MgAuthenticationRequired'
            $result = $false
        }

        return $result
    }
}

#endregion

#region Test-MgModulePrerequisites.ps1

<#
.SYNOPSIS
    Test Mg Graph Module Prerequisites
.EXAMPLE
    PS > Test-MgModulePrerequisites 'CrossTenantInformation.ReadBasic.All'
.INPUTS
    System.String
#>

function Test-MgModulePrerequisites {
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        # The name of scope
        [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
        [Alias('Permission')]
        [string[]] $Scope
    )

    process {
        ## Initialize
        $result = $true

        ## Check MgModule Connection
        $MgContext = Get-MgContext
        if ($MgContext) {
            if ($Scope) {
                ## Check MgModule Consented Scopes
                [string[]] $ScopesMissing = Compare-Object $Scope -DifferenceObject $MgContext.Scopes | Where-Object SideIndicator -EQ '<=' | Select-Object -ExpandProperty InputObject
                if ($ScopesMissing) {
                    $Exception = New-Object System.Security.SecurityException -ArgumentList "Additional scope(s) needed, call Connect-MgGraph with all of the following scopes: $($ScopesMissing -join ', ')"
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::PermissionDenied) -ErrorId 'MgScopePermissionRequired' -RecommendedAction ("Connect-MgGraph -Scopes $($ScopesMissing -join ',')")
                    $result = $false
                }
            }
        }
        else {
            $Exception = New-Object System.Security.Authentication.AuthenticationException -ArgumentList "Authentication needed, call Connect-MgGraph."
            Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::AuthenticationError) -CategoryReason 'AuthenticationException' -ErrorId 'MgAuthenticationRequired'
            $result = $false
        }

        return $result
    }
}

#endregion

#region Test-PsElevation.ps1

<#
.SYNOPSIS
    Test if current PowerShell process is elevated to local administrator privileges.
.DESCRIPTION
    Test if current PowerShell process is elevated to local administrator privileges.
.EXAMPLE
    PS C:\>Test-PsElevation
    Test is current PowerShell process is elevated.
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Test-PsElevation {
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try {
        $WindowsIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $WindowsPrincipal = New-Object 'System.Security.Principal.WindowsPrincipal' $WindowsIdentity
        $LocalAdministrator = [System.Security.Principal.WindowsBuiltInRole]::Administrator
        return $WindowsPrincipal.IsInRole($LocalAdministrator)
    }
    catch { 
        if ($_.Exception.InnerException) {
            Write-Error -Exception $_.Exception.InnerException -Category $_.CategoryInfo.Category -CategoryActivity $_.CategoryInfo.Activity -ErrorId $_.FullyQualifiedErrorId -TargetObject $_.TargetObject
        }
        else { Write-Error -ErrorRecord $_ }
    }
}

#endregion

#region Write-HostPrompt.ps1

<#
.SYNOPSIS
    Displays a PowerShell prompt for multiple fields or multiple choices.
.DESCRIPTION
    Displays a PowerShell prompt for multiple fields or multiple choices.
.EXAMPLE
    PS C:\>Write-HostPrompt "Prompt Caption" -Fields "Field 1", "Field 2"
    Display simple prompt for 2 fields.
.EXAMPLE
    PS C:\>$IntegerField = New-Object System.Management.Automation.Host.FieldDescription -ArgumentList "Integer Field" -Property @{ HelpMessage = "Help Message for Integer Field" }
    PS C:\>$IntegerField.SetParameterType([int[]])
    PS C:\>$DateTimeField = New-Object System.Management.Automation.Host.FieldDescription -ArgumentList "DateTime Field" -Property @{ HelpMessage = "Help Message for DateTime Field" }
    PS C:\>$DateTimeField.SetParameterType([datetime])
    PS C:\>Write-HostPrompt "Prompt Caption" "Prompt Message" -Fields $IntegerField, $DateTimeField
    Display prompt for 2 type-specific fields, with int field being an array.
.EXAMPLE
    PS C:\>Write-HostPrompt "Prompt Caption" -Choices "Choice &1", "Choice &2"
    Display simple prompt with 2 choices.
.EXAMPLE
    PS C:\>Write-HostPrompt "Prompt Caption" "Prompt Message" -DefaultChoice 2 -Choices @(
        New-Object System.Management.Automation.Host.ChoiceDescription -ArgumentList "&1`bChoice one" -Property @{ HelpMessage = "Help Message for Choice 1" }
        New-Object System.Management.Automation.Host.ChoiceDescription -ArgumentList "&2`bChoice two" -Property @{ HelpMessage = "Help Message for Choice 2" }
    )
    Display prompt with 2 choices and help messages that defaults to the second choice.
.EXAMPLE
    PS C:\>Write-HostPrompt "Prompt Caption" "Choose a number" -Choices "Menu Item A", "Menu Item B", "Menu Item C" -HelpMessages "Menu Item A Needs Help", "Menu Item B Needs More Help",, "Menu Item C Needs Crazy Help" -NumberedHotKeys
    Display prompt with 3 choices and help message that are automatically numbered.
.INPUTS
    System.Management.Automation.Host.FieldDescription
    System.Management.Automation.Host.ChoiceDescription
.OUTPUTS
    System.Collections.Generic.Dictionary[System.String,System.Management.Automation.PSObject]
    System.Int32
.LINK
    https://github.com/jasoth/Utility.PS
#>

function Write-HostPrompt {
    [CmdletBinding()]
    param
    (
        # Caption to preceed or title the prompt.
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $Caption,
        # A message that describes the prompt.
        [Parameter(Mandatory = $false, Position = 2)]
        [string] $Message,
        # The fields in the prompt.
        [Parameter(Mandatory = $true, ParameterSetName = 'Fields', Position = 3, ValueFromPipeline = $true)]
        [System.Management.Automation.Host.FieldDescription[]] $Fields,
        # The choices the shown in the prompt.
        [Parameter(Mandatory = $true, ParameterSetName = 'Choices', Position = 3, ValueFromPipeline = $true)]
        [System.Management.Automation.Host.ChoiceDescription[]] $Choices,
        # Specifies a help message for each field or choice.
        [Parameter(Mandatory = $false, Position = 4)]
        [string[]] $HelpMessages = @(),
        # The index of the label in the choices to make default.
        [Parameter(Mandatory = $false, ParameterSetName = 'Choices', Position = 5)]
        [int] $DefaultChoice,
        # Use numbered hot keys (aka "keyboard accelerator") for each choice.
        [Parameter(Mandatory = $false, ParameterSetName = 'Choices', Position = 6)]
        [switch] $NumberedHotKeys
    )

    begin {
        ## Create list to capture multiple fields or multiple choices.
        [System.Collections.Generic.List[System.Management.Automation.Host.FieldDescription]] $listFields = New-Object System.Collections.Generic.List[System.Management.Automation.Host.FieldDescription]
        [System.Collections.Generic.List[System.Management.Automation.Host.ChoiceDescription]] $listChoices = New-Object System.Collections.Generic.List[System.Management.Automation.Host.ChoiceDescription]
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Fields' {
                for ($iField = 0; $iField -lt $Fields.Count; $iField++) {
                    if ($iField -lt $HelpMessages.Count -and $HelpMessages[$iField]) { $Fields[$iField].HelpMessage = $HelpMessages[$iField] }
                    $listFields.Add($Fields[$iField])
                }
            }
            'Choices' {
                for ($iChoice = 0; $iChoice -lt $Choices.Count; $iChoice++) {
                    if ($NumberedHotKeys) { $Choices[$iChoice] = New-Object System.Management.Automation.Host.ChoiceDescription -ArgumentList "&$($iChoice+1)`b$($Choices[$iChoice].Label)" -Property @{ HelpMessage = $Choices[$iChoice].HelpMessage } }
                    #elseif (!$Choices[$iChoice].Label.Contains('&')) { $Choices[$iChoice] = New-Object System.Management.Automation.Host.ChoiceDescription -ArgumentList "&$($Choices[$iChoice].Label)" -Property @{ HelpMessage = $Choices[$iChoice].HelpMessage } }
                    if ($iChoice -lt $HelpMessages.Count -and $HelpMessages[$iChoice]) { $Choices[$iChoice].HelpMessage = $HelpMessages[$iChoice] }
                    $listChoices.Add($Choices[$iChoice])
                }
            }
        }
    }

    end {
        try {
            switch ($PSCmdlet.ParameterSetName) {
                'Fields' { return $Host.UI.Prompt($Caption, $Message, $listFields.ToArray()) }
                'Choices' { return $Host.UI.PromptForChoice($Caption, $Message, $listChoices.ToArray(), $DefaultChoice - 1) + 1 }
            }
        }
        catch [System.Management.Automation.PSInvalidOperationException] {
            ## Write Non-Terminating Error When In Non-Interactive Mode.
            Write-Error -ErrorRecord $_ -CategoryActivity $MyInvocation.MyCommand
        }
    }
}

#endregion

#region Add-MsIdServicePrincipal.ps1

<#
.SYNOPSIS
    Create service principal for existing application registration

.EXAMPLE
    PS > Add-MsIdServicePrincipal 10000000-0000-0000-0000-000000000001

    Create service principal for existing appId, 10000000-0000-0000-0000-000000000001.

.INPUTS
    System.String

#>

function Add-MsIdServicePrincipal {
    [CmdletBinding()]
    [OutputType([object])]
    param (
        # AppID of Application
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [string[]] $AppId
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'New-MgServicePrincipal' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        foreach ($_AppId in $AppId) {
            ## Create Service Principal from Application Registration
            New-MgServicePrincipal -AppId $_AppId
        }
    }
}

#endregion

#region Confirm-MsIdJwtTokenSignature.ps1

<#
.SYNOPSIS
    Validate the digital signature for JSON Web Token.

.EXAMPLE
    PS > Confirm-MsIdJwtTokenSignature $OpenIdConnectToken

    Validate the OpenId token was signed by token issuer based on the OIDC Provider Configuration for token issuer.

.EXAMPLE
    PS > Confirm-MsIdJwtTokenSignature $AccessToken

    Validate the access token was signed by token issuer based on the OIDC Provider Configuration for token issuer.

.INPUTS
    System.String
    
#>

function Confirm-MsIdJwtTokenSignature {
    [CmdletBinding()]
    [Alias('Confirm-JwtSignature')]
    [OutputType([bool])]
    param (
        # JSON Web Token (JWT)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $Tokens
    )

    process {
        foreach ($Token in $Tokens) {
            $Jws = ConvertFrom-JsonWebSignature $Token
            $SigningKeys = $Jws.Payload.iss | Get-OpenIdProviderConfiguration -Keys | Where-Object use -EQ 'sig'
            $SigningKey = $SigningKeys | Where-Object kid -EQ $Jws.Header.kid
            $SigningCertificate = Get-X509Certificate $SigningKey.x5c

            Confirm-JsonWebSignature $Token -SigningCertificate $SigningCertificate
        }
    }
}

#endregion

#region ConvertFrom-MsIdAadcAadConnectorSpaceDn.ps1

<#
.SYNOPSIS
    Convert Azure AD connector space object Distinguished Name (DN) in AAD Connect

.EXAMPLE
    PS > ConvertFrom-MsIdAadcAadConnectorSpaceDn 'CN={414141414141414141414141414141414141414141413D3D}'

    Convert Azure AD connector space object DN in AAD Connect to sourceAnchor and sourceGuid.

.EXAMPLE
    PS > 'CN={4F626A656374547970655F30303030303030302D303030302D303030302D303030302D303030303030303030303030}' | ConvertFrom-MsIdAadcAadConnectorSpaceDn
    
    Convert Azure AD connector space object DN in AAD Connect to cloudAnchor and cloudGuid.

.INPUTS
    System.String
    
#>

function ConvertFrom-MsIdAadcAadConnectorSpaceDn {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        # Azure AD Connector Space DN from AAD Connect
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [string] $InputObject
    )

    process {
        ## Extract Hex String
        if ($InputObject -imatch '(?:CN=)?\{?([0-9a-f]+)\}?') {
            [string] $HexString = $Matches[1]
        }
        else {
            [string] $HexString = $InputObject
        }

        ## Decode Hex String
        [string] $DecodedString = ConvertFrom-HexString $HexString
        if ($DecodedString -imatch '([a-z]+)_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})') {
            [guid] $CloudGuid = $Matches[2]
            $Result = [PSCustomObject]@{
                cloudAnchor = $DecodedString
                cloudGuid   = $CloudGuid
            }
        }
        else {
            [guid] $SourceGuid = ConvertFrom-Base64String $DecodedString -RawBytes
            $Result = [PSCustomObject]@{
                sourceAnchor = $DecodedString
                sourceGuid   = $SourceGuid
            }
        }

        Write-Output $Result
    }
}

#endregion

#region ConvertFrom-MsIdAadcSourceAnchor.ps1

<#
.SYNOPSIS
    Convert Azure AD Connect metaverse object sourceAnchor or Azure AD ImmutableId to sourceGuid.
    
.EXAMPLE
    PS > ConvertFrom-MsIdAadcSourceAnchor 'AAAAAAAAAAAAAAAAAAAAAA=='

    Convert Azure AD Connect metaverse object sourceAnchor base64 format to sourceGuid.

.EXAMPLE
    PS > ConvertFrom-MsIdAadcSourceAnchor '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00'

    Convert Azure AD Connect metaverse object sourceAnchor hex format to sourceGuid.

.INPUTS
    System.String

#>

function ConvertFrom-MsIdAadcSourceAnchor {
    [CmdletBinding()]
    [Alias('ConvertFrom-MsIdAzureAdImmutableId')]
    [OutputType([guid], [string])]
    param (
        # Azure AD Connect metaverse object sourceAnchor.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [string] $InputObject
    )

    process {
        if ($InputObject -imatch '(?:^|,)((?:[0-9a-f]{2} ?)+)(?:$|,)') {
            [guid] $SourceGuid = ConvertFrom-HexString $Matches[1].Trim() -RawBytes
        }
        elseif ($InputObject -imatch '(?:^|,)([0-9a-z+/=]+=+)(?:$|,)') {
            [guid] $SourceGuid = ConvertFrom-Base64String $Matches[1] -RawBytes
        }
        else {
            [guid] $SourceGuid = ConvertFrom-Base64String $InputObject -RawBytes
        }

        Write-Output $SourceGuid
    }
}

#endregion

#region ConvertFrom-MsIdUniqueTokenIdentifier.ps1

<#
.SYNOPSIS
    Convert Azure AD Unique Token Identifier to Request Id.
    
.EXAMPLE
    PS > ConvertFrom-MsIdUniqueTokenIdentifier 'AAAAAAAAAAAAAAAAAAAAAA'

    Convert Azure AD Unique Token Identifier to Request Id.

.EXAMPLE
    PS > Get-MgBetaAuditLogSignIn -Top 1 | ConvertFrom-MsIdUniqueTokenIdentifier

    Get a Sign-in Log Entry and Convert Azure AD Unique Token Identifier to Request Id.

.INPUTS
    System.String

#>

function ConvertFrom-MsIdUniqueTokenIdentifier {
    [CmdletBinding()]
    [OutputType([guid])]
    param (
        # Azure AD Unique Token Identifier
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateLength(22,22)]
        [Alias("UniqueTokenIdentifier")]
        [string] $InputObject
    )

    process {
        [guid] $SourceGuid = ConvertFrom-Base64String $InputObject -Base64Url -RawBytes
        return $SourceGuid
    }
}

#endregion

#region ConvertFrom-MsIdJwtToken.ps1

<#
.SYNOPSIS
    Convert Msft Identity token structure to PowerShell object.

.EXAMPLE
    PS > ConvertFrom-MsIdJwtToken $OpenIdConnectToken

    Convert OAuth Id Token JWS to PowerShell object.

.EXAMPLE
    PS > ConvertFrom-MsIdJwtToken $AccessToken

    Convert OAuth Access Token JWS to PowerShell object.

.INPUTS
    System.String
    
#>

function ConvertFrom-MsIdJwtToken {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        # JSON Web Token (JWT)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $Tokens
    )

    process {
        foreach ($Token in $Tokens) {
            ConvertFrom-JsonWebSignature $Token
        }
    }
}

#endregion

#region ConvertFrom-MsIdSamlMessage.ps1

<#
.SYNOPSIS
    Convert SAML Message structure to PowerShell object.

.EXAMPLE
    PS > ConvertFrom-MsIdSamlMessage 'Base64String'

    Convert Saml Message to XML object.

.INPUTS
    System.String

.OUTPUTS
    SamlMessage : System.Xml.XmlDocument
    
#>

function ConvertFrom-MsIdSamlMessage {
    [CmdletBinding()]
    [Alias('ConvertFrom-MsIdSamlRequest')]
    [Alias('ConvertFrom-MsIdSamlResponse')]
    #[OutputType([xml])]
    param (
        # SAML Message
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputObject
    )

    process {
        foreach ($_InputObject in $InputObject) {
            ConvertFrom-SamlMessage $_InputObject
        }
    }
}

#endregion

#region Expand-MsIdJwtTokenPayload.ps1

<#
.SYNOPSIS
    Extract Json Web Token (JWT) payload from JWS structure to PowerShell object.

.EXAMPLE
    PS > $MsalToken.IdToken | Expand-MsIdJwtTokenPayload

    Extract Json Web Token (JWT) payload from JWS structure to PowerShell object.

.INPUTS
    System.String
    
#>

function Expand-MsIdJwtTokenPayload {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        # JSON Web Token (JWT)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $Tokens
    )

    process {
        foreach ($Token in $Tokens) {
            $Jwt = ConvertFrom-JsonWebSignature $Token
            Write-Output $Jwt.Payload
        }
    }
}

#endregion

#region Export-MsIdAppConsentGrantReport.ps1

<#
.SYNOPSIS
    Lists and categorizes privilege for delegated permissions (OAuth2PermissionGrants) and application permissions (AppRoleAssignments).

    Watch the video [Run a quick OAuth app audit of your Microsoft Entra tenant](https://youtu.be/vO0m5yE3dZA?list=PL06Jj3_onEzGBkrZXybUZZWpJpbn1OpiK) for a quick walkthrough and demo of this command.

.DESCRIPTION
    This cmdlet requires the `ImportExcel` module to be installed if you use the `-ReportOutputType ExcelWorkbook` parameter.

.EXAMPLE
    PS > Install-Module ImportExcel
    PS > Connect-MgGraph -Scopes Directory.Read.All
    PS > Export-MsIdAppConsentGrantReport -ReportOutputType ExcelWorkbook -ExcelWorkbookPath .\report.xlsx

    Output a report in Excel format

.EXAMPLE
    PS > Export-MsIdAppConsentGrantReport -ReportOutputType ExcelWorkbook -ExcelWorkbookPath .\report.xlsx -PermissionsTableCsvPath .\table.csv

    Output a report in Excel format and specify a local path for a customized CSV containing consent privilege categorizations

.EXAMPLE
    PS > $appConsent = Export-MsIdAppConsentGrantReport -ReportOutputType PowerShellObjects

    Return the resuls as hashtable for processing or exporting to other formats like csv or json.

.EXAMPLE
    PS > Export-MsIdAppConsentGrantReport -ExcelWorkbookPath .\report.xlsx -ThrottleLimit 5

    Increase the throttle limit to speed things up or reduce if you are getting throttling errors. Default is 20

#>

function Export-MsIdAppConsentGrantReport {
    param (
        # Output file location for Excel Workbook
        [Parameter(ParameterSetName = 'Excel', Mandatory = $true, Position = 1)]
        [string]
        $ExcelWorkbookPath,

        # Output type for the report.
        [ValidateSet("ExcelWorkbook", "PowerShellObjects")]
        [Parameter(ParameterSetName = 'Excel', Mandatory = $false, Position = 2)]
        [Parameter(ParameterSetName = 'PowerShell', Mandatory = $false, Position = 1)]
        [string]
        $ReportOutputType = "ExcelWorkbook",

        # Path to CSV file for Permissions Table
        # If not provided the default table will be downloaded from GitHub https://raw.githubusercontent.com/AzureAD/MSIdentityTools/main/assets/aadconsentgrantpermissiontable.csv
        [string]
        $PermissionsTableCsvPath,

        # The number of parallel threads to use when calling the Microsoft Graph API. Default is 20.
        [int]
        $ThrottleLimit = 20
    )

    $script:ObjectByObjectId = @{} # Cache for all directory objects
    $script:KnownMSTenantIds = @("f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47")

    function Main() {
        if ("ExcelWorkbook" -eq $ReportOutputType) {
            # Determine if the ImportExcel module is installed since the parameter was included
            if ($null -eq (Get-Module -Name ImportExcel -ListAvailable)) {
                throw "The ImportExcel module is not installed. This is used to export the results to an Excel worksheet. Please install the ImportExcel Module before using this parameter or run without this parameter."
            }
        }

        if ($null -eq (Get-MgContext)) {
            Connect-MgGraph -Scopes Directory.Read.All
        }

        $appConsents = GetAppConsentGrants

        if ($null -ne $appConsents) {

            $appConsentsWithRisk = AddConsentRisk $appConsents

            if ("ExcelWorkbook" -eq $ReportOutputType) {
                Write-Verbose "Generating Excel workbook at $ExcelWorkbookPath"

                WriteMainProgress Complete -Status "Saving report..." -ForceRefresh
                GenerateExcelReport -AppConsentsWithRisk $appConsentsWithRisk -Path $ExcelWorkbookPath
            }
            else {
                WriteMainProgress Complete -Status "Finishing up" -ForceRefresh
                Write-Output $appConsentsWithRisk
            }

        }
        else {
            throw "An error occurred while retrieving app consent grants. Please try again."
        }
    }

    function GetAppConsentGrants {
        # Get all ServicePrincipal objects and add to the cache
        Write-Verbose "Retrieving ServicePrincipal objects..."

        WriteMainProgress ServicePrincipal -Status "This can take some time..." -ForceRefresh
        $count = Get-MgServicePrincipalCount -ConsistencyLevel eventual
        WriteMainProgress ServicePrincipal -ChildPercent 5 -Status "Retrieving $count service principals. This can take some time..." -ForceRefresh
        Start-Sleep -Milliseconds 500 #Allow message to update
        $servicePrincipalProps = "id,appId,appOwnerOrganizationId,displayName,appRoles,appRoleAssignmentRequired"
        $script:ServicePrincipals = Get-MgServicePrincipal -ExpandProperty "appRoleAssignments" -Select $servicePrincipalProps -All -PageSize 999


        $appPerms = GetApplicationPermissions
        $delPerms = GetDelegatePermissions

        $allPermissions = @()
        $allPermissions += $appPerms
        $allPermissions += $delPerms
        return $allPermissions
    }

    function CacheObject($Object) {
        if ($Object) {
            $script:ObjectByObjectId[$Object.Id] = $Object
        }
    }

    # Function to retrieve an object from the cache (if it's there), or from Entra ID (if not).
    function GetObjectByObjectId($ObjectId) {
        if (-not $script:ObjectByObjectId.ContainsKey($ObjectId)) {
            Write-Verbose ("Querying Entra ID for object '{0}'" -f $ObjectId)
            try {
                $object = (Get-MgDirectoryObjectById -Ids $ObjectId)
                CacheObject -Object $object
            }
            catch {
                Write-Verbose "Object not found."
            }
        }
        return $script:ObjectByObjectId[$ObjectId]
    }

    function IsMicrosoftApp($AppOwnerOrganizationId) {
        if ($AppOwnerOrganizationId -in $script:KnownMSTenantIds) { return "Yes" }
        else { return "No" }
    }

    function GetScopeLink($scope) {
        if ("ExcelWorkbook" -ne $ReportOutputType) { return $scope }
        if ([string]::IsNullOrEmpty($scope)) { return $scope }
        return "=HYPERLINK(`"https://graphpermissions.merill.net/permission/$scope`",`"$scope`")"
    }

    function GetServicePrincipalLink($spId, $appId, $name) {
        if ("ExcelWorkbook" -ne $ReportOutputType) { return $name }
        if ([string]::IsNullOrEmpty($spId) -or [string]::IsNullOrEmpty($appId) -or [string]::IsNullOrEmpty($name)) { return $name }
        return "=HYPERLINK(`"https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($spId)/appId/$($appId)/preferredSingleSignOnMode~/null/servicePrincipalType/Application/fromNav/`",`"$($name)`")"
    }

    function GetUserLink($userId, $name) {
        $returnValue = $name
        if ([string]::IsNullOrEmpty($name)) { $returnValue = $userId } # If we don't have a name, show the userid

        if ("ExcelWorkbook" -eq $ReportOutputType -and ![string]::IsNullOrEmpty($userId)) { #If Excel and linkable then show name
            $returnValue = "=HYPERLINK(`"https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($userId)/hidePreviewBanner~/true`",`"$($name)`")"
        }
        return $returnValue
    }

    function GetApplicationPermissions() {
        $count = 0
        $permissions = @()

        # We need to call Get-MgServicePrincipal again so we can expand appRoleAssignments

        #$servicePrincipalsWithAppRoleAssignments = Get-MgServicePrincipal -ExpandProperty "appRoleAssignments" -Select $servicePrincipalProps -All -PageSize 999
        foreach ($client in $script:ServicePrincipals) {
            $count++
            $appPercent = (($count / $servicePrincipals.Count) * 100)
            WriteMainProgress AppPerm -Status "[$count of $($servicePrincipals.Count)] $($client.DisplayName)" -ChildPercent $appPercent

            $isMicrosoftApp = IsMicrosoftApp -AppOwnerOrganizationId $client.AppOwnerOrganizationId
            $spLink = GetServicePrincipalLink -spId $client.Id -appId $client.AppId -name $client.DisplayName
            Write-Verbose "Getting app permissions: [$count of $($servicePrincipals.Count)] $($client.DisplayName)"

            foreach ($grant in $client.AppRoleAssignments) {
                # Look up the related SP to get the name of the permission from the AppRoleId GUID
                $appRole = $servicePrincipals.AppRoles | Where-Object { $_.id -eq $grant.AppRoleId } | Select-Object -First 1
                $appRoleValue = $grant.AppRoleId
                if ($null -ne $appRole -and ![string]::IsNullOrEmpty($appRole.value)) {
                    $appRoleValue = $appRole.Value
                }

                $permissions += New-Object PSObject -Property ([ordered]@{
                        "PermissionType"            = "Application"
                        "ConsentTypeFilter"         = "Application"
                        "ClientObjectId"            = $client.Id
                        "AppId"                     = $client.AppId
                        "ClientDisplayName"         = $spLink
                        "ResourceObjectId"          = $grant.ResourceId
                        "ResourceObjectIdFilter"    = $grant.ResourceId
                        "ResourceDisplayName"       = $grant.ResourceDisplayName
                        "ResourceDisplayNameFilter" = $grant.ResourceDisplayName
                        "Permission"                = GetScopeLink $appRoleValue
                        "PermissionFilter"          = $appRoleValue
                        "PrincipalObjectId"         = ""
                        "PrincipalDisplayName"      = ""
                        "MicrosoftApp"              = $isMicrosoftApp
                        "AppOwnerOrganizationId"    = $client.AppOwnerOrganizationId
                    })
            }
        }
        return $permissions
    }

    function GetDelegatePermissions {

        $permissions = @()
        $servicePrincipals = $script:servicePrincipals

        $spList = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
        $spListFailed = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()

        WriteMainProgress DownloadDelegatePerm -Status "Downloading all delegate permissions..." -ForceRefresh
        Write-Verbose "Downloading all delegate permissions using $ThrottleLimit threads"

        $job = $script:servicePrincipals | ForEach-Object -AsJob -ThrottleLimit $ThrottleLimit -Parallel {
            $dict = $using:spList
            $dictFailed = $using:spList
            $servicePrincipalId = $_.Id

            try {
                $oAuth2PermGrants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $servicePrincipalId -All -PageSize 999
                $item = New-Object PSObject -Property ([ordered]@{
                        ServicePrincipal       = $_
                        Oauth2PermissionGrants = $oAuth2PermGrants
                    })
                $success = $dict.TryAdd($servicePrincipalId, $item)
                if (!$success) {
                    $dictFailed.TryAdd($servicePrincipalId, "Failed to add service principal $servicePrincipalId") | Out-Null
                }
            }
            catch {
                $dictFailed.TryAdd($servicePrincipalId, $_) | Out-Null
            }
        }

        while ($job.State -eq 'Running') {
            $count = $spList.Count
            if ($count -eq 0) {
                Start-Sleep -Seconds 1
            }
            else {
                $totalCount = $servicePrincipals.Count
                # get the last item by index
                $lastSp = $servicePrincipals[$count]

                $delPercent = (($count / $totalCount) * 100)
                WriteMainProgress DownloadDelegatePerm -Status "$count of $totalCount - $($lastSp.DisplayName)" -ChildPercent $delPercent -ForceRefresh
            }
        }

        if ($spListFailed.Count -gt 0) {
            Write-Error "Failed to retrieve delegate permissions for $($spListFailed.Count) service principals."
            Write-Error "Try reducing the -ParallelBatchSize parameter to avoid throttling issues."
            Write-Error $spListFailed.Values
            throw
        }

        $totalCount = $spList.Values.Count
        $count = 0
        foreach ($sp in $spList.Values) {
            $client = $sp.ServicePrincipal

            $count++
            $delPercent = (($count / $totalCount) * 100)
            WriteMainProgress ProcessDelegatePerm -status "[$count of $($totalCount)] $($client.DisplayName)" -childPercent $delPercent
            Write-Verbose "Processing delegate permissions for $($client.DisplayName)"

            $isMicrosoftApp = IsMicrosoftApp -AppOwnerOrganizationId $client.AppOwnerOrganizationId
            $spLink = GetServicePrincipalLink -spId $client.Id -appId $client.AppId -name $client.DisplayName
            $oAuth2PermGrants = $sp.Oauth2PermissionGrants

            foreach ($grant in $oAuth2PermGrants) {
                if ($grant.Scope) {
                    $grant.Scope.Split(" ") | Where-Object { $_ } | ForEach-Object {
                        $scope = $_
                        $resource = GetObjectByObjectId -ObjectId $grant.ResourceId
                        $principalDisplayName = ""

                        if ($grant.PrincipalId) {
                            $principal = GetObjectByObjectId -ObjectId $grant.PrincipalId
                            $principalDisplayName = $principal.AdditionalProperties.displayName
                        }

                        $simplifiedgranttype = ""
                        if ($grant.ConsentType -eq "AllPrincipals") {
                            $simplifiedgranttype = "Delegated-AllPrincipals"
                        }
                        elseif ($grant.ConsentType -eq "Principal") {
                            $simplifiedgranttype = "Delegated-Principal"
                        }

                        $permissions += New-Object PSObject -Property ([ordered]@{
                                "PermissionType"            = $simplifiedgranttype
                                "ConsentTypeFilter"         = $simplifiedgranttype
                                "ClientObjectId"            = $client.Id
                                "AppId"                     = $client.AppId
                                "ClientDisplayName"         = $spLink
                                "ResourceObjectId"          = $grant.ResourceId
                                "ResourceObjectIdFilter"    = $grant.ResourceId
                                "ResourceDisplayName"       = $resource.AdditionalProperties.displayName
                                "ResourceDisplayNameFilter" = $resource.AdditionalProperties.displayName
                                "Permission"                = GetScopeLink $scope
                                "PermissionFilter"          = $scope
                                "PrincipalObjectId"         = $grant.PrincipalId
                                "PrincipalDisplayName"      = GetUserLink -userId $grant.PrincipalId -name $principalDisplayName
                                "MicrosoftApp"              = $isMicrosoftApp
                                "AppOwnerOrganizationId"    = $client.AppOwnerOrganizationId
                            })
                    }
                }
            }
        }
        return $permissions
    }

    function AddConsentRisk ($AppConsents) {

        $permstable = GetPermissionsTable -PermissionsTableCsvPath $PermissionsTableCsvPath
        $permsHash = @{}

        foreach ($perm in $permstable) {
            $key = $perm.Type + $perm.Permission
            $permsHash[$key] = $perm
            if ($perm.permission -Match ".") {
                $key = $perm.Type + $perm.Permission.Split(".")[0]
                $permsHash[$key] = $perm
            }
        }
        # Process Privilege for gathered data
        $count = 0
        $AppConsents | ForEach-Object {

            $consent = $_
            $count++

            WriteMainProgress GenerateExcel -Status "[$count of $($AppConsents.Count)] $($consent.PermissionFilter)" -ChildPercent (($count / $AppConsents.Count) * 100)
            $scope = $consent.PermissionFilter
            $type = ""
            if ($consent.PermissionType -eq "Delegated-AllPrincipals" -or $consent.PermissionType -eq "Delegated-Principal") {
                $type = "Delegated"
            }
            elseif ($consent.PermissionType -eq "Application") {
                $type = "Application"
            }

            # Check permission table for an exact match
            Write-Debug ("Permission Scope: $Scope")

            $scoperoot = $scope.Split(".")[0]

            $risk = "Unranked"
            # Search for matching root level permission if there was no exact match
            if ($permsHash.ContainsKey($type + $scope)) {
                # Exact match e.g. Application.Read.All
                $risk = $permsHash[$type + $scope].Privilege
            }
            elseif ($permsHash.ContainsKey($type + $scoperoot)) {
                #Matches top level e.g. Application.
                $risk = $permsHash[$type + $scoperoot].Privilege
            }
            elseif ($type -eq "Application") {
                # Application permissions without exact or root matches with write scope
                $risk = "Medium"
                if ($scope -like "*Write*") {
                    $risk = "High"
                }
            }
            # Add the privilege to the current object
            Add-Member -InputObject $_ -MemberType NoteProperty -Name Privilege -Value $risk
            Add-Member -InputObject $_ -MemberType NoteProperty -Name PrivilegeFilter -Value $risk
        }

        return $AppConsents
    }

    function GetPermissionsTable {
        param ($PermissionsTableCsvPath)

        if ($null -like $PermissionsTableCsvPath) {
            # Create hash table of permissions and permissions privilege
            $permstable = Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/AzureAD/MSIdentityTools/main/assets/aadconsentgrantpermissiontable.csv' | ConvertFrom-Csv -Delimiter ','
        }
        else {

            $permstable = Import-Csv $PermissionsTableCsvPath -Delimiter ','
        }

        return $permstable
    }

    function WriteMainProgress(
        # The current step of the overal generation
        [ValidateSet("ServicePrincipal", "AppPerm", "DownloadDelegatePerm", "ProcessDelegatePerm", "GenerateExcel", "Complete")]
        $MainStep,
        $Status = "Processing...",
        # The percentage of completion within the child step
        $ChildPercent,
        [switch]$ForceRefresh) {
        $percent = 0
        switch ($MainStep) {
            "ServicePrincipal" {
                $percent = GetNextPercent $ChildPercent 2 10
                $activity = "Downloading service principals"
            }
            "AppPerm" {
                $percent = GetNextPercent $ChildPercent 10 50
                $activity = "Downloading application permissions"
            }
            "DownloadDelegatePerm" {
                $percent = GetNextPercent $ChildPercent 50 75
                $activity = "Downloading delegate permissions"
            }
            "ProcessDelegatePerm" {
                $percent = GetNextPercent $ChildPercent 75 90
                $activity = "Processing delegate permissions"
            }
            "GenerateExcel" {
                $percent = GetNextPercent $ChildPercent 90 99
                $activity = "Processing risk information"
            }
            "Complete" {
                $percent = 100
                $activity = "Complete"
            }
        }

        if ($ForceRefresh.IsPresent) {
            Start-Sleep -Milliseconds 250
        }
        Write-Progress -Id 0 -Activity $activity -PercentComplete $percent -Status $Status
    }

    function GetNextPercent($childPercent, $parentPercent, $nextPercent) {
        if ($childPercent -eq 0) { return $parentPercent }

        $gap = $nextPercent - $parentPercent
        return (($childPercent / 100) * $gap) + $parentPercent
    }

    function GenerateExcelReport ($AppConsentsWithRisk, $Path) {

        $maxRows = $AppConsentsWithRisk.Count + 1

        # Delete the existing output file if it already exists
        $OutputFileExists = Test-Path $Path
        if ($OutputFileExists -eq $true) {
            Get-ChildItem $Path | Remove-Item -Force
        }

        $servicePrincipalAssignedToList = @{}
        $highprivilegeobjects = $AppConsentsWithRisk | Where-Object { $_.PrivilegeFilter -eq "High" }
        $highprivilegeobjects | ForEach-Object {
            $clientId = $_.ClientObjectId
            if (!$servicePrincipalAssignedToList.ContainsKey($clientId)) {
                # If we already have the value, don't call graph again
                $servicePrincipal = $script:ServicePrincipals | Where-Object { $_.Id -eq $clientId }

                $assignedTo = ""
                if ($servicePrincipal.AppRoleAssignmentRequired -eq $true) {
                    $userAssignments = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $_.ClientObjectId -All:$true
                    $group = $userAssignments | Group-Object -Property PrincipalType
                    foreach ($g in $group) {
                        if ($g.Name -eq "User") {
                            $assignedTo += "$($g.Count) $($g.Name)s "
                        }
                    }
                }
                elseif ($servicePrincipal.AppRoleAssignmentRequired -eq $false) {
                    $assignedTo = "All Users"
                }
                $servicePrincipalAssignedToList[$clientId] = $assignedTo
            }
            $assignedToValue = $servicePrincipalAssignedToList[$clientId]
            Add-Member -InputObject $_ -MemberType NoteProperty -Name AssignedTo -Value $assignedToValue
        }
        $highprivilegeusers = $highprivilegeobjects | Where-Object { ![string]::IsNullOrEmpty($_.PrincipalObjectId) } | Select-Object PrincipalDisplayName, Privilege | Sort-Object PrincipalDisplayName -Unique
        $highprivilegeapps = $highprivilegeobjects | Select-Object ClientDisplayName, Privilege, AssignedTo, MicrosoftApp | Sort-Object ClientDisplayName -Unique | Sort-Object AssignedTo -Descending

        # Pivot table by user
        $pt = New-PivotTableDefinition -SourceWorksheet ConsentGrantData `
            -PivotTableName "PermissionsByUser" `
            -PivotFilter PrivilegeFilter, PermissionFilter, ResourceDisplayNameFilter, ConsentTypeFilter, ClientDisplayName, MicrosoftApp `
            -PivotRows PrincipalDisplayName `
            -PivotColumns Privilege, PermissionType `
            -PivotData @{Permission = 'Count' } `
            -IncludePivotChart `
            -ChartType ColumnStacked `
            -ChartHeight 800 `
            -ChartWidth 1200 `
            -ChartRow 4 `
            -ChartColumn 14 `
            -WarningAction SilentlyContinue

        # Pivot table by resource
        $pt += New-PivotTableDefinition -SourceWorksheet ConsentGrantData `
            -PivotTableName "PermissionsByResource" `
            -PivotFilter PrivilegeFilter, ResourceDisplayNameFilter, ConsentTypeFilter, PrincipalDisplayName, MicrosoftApp `
            -PivotRows ResourceDisplayName, PermissionFilter `
            -PivotColumns Privilege, PermissionType `
            -PivotData @{Permission = 'Count' } `
            -IncludePivotChart `
            -ChartType ColumnStacked `
            -ChartHeight 800 `
            -ChartWidth 1200 `
            -ChartRow 4 `
            -ChartColumn 14 `
            -WarningAction SilentlyContinue

        # Pivot table by privilege rating
        $pt += New-PivotTableDefinition -SourceWorksheet ConsentGrantData `
            -PivotTableName "PermissionsByPrivilegeRating" `
            -PivotFilter PrivilegeFilter, PermissionFilter, ResourceDisplayNameFilter, ConsentTypeFilter, PrincipalDisplayName, MicrosoftApp `
            -PivotRows Privilege, ResourceDisplayName `
            -PivotColumns PermissionType `
            -PivotData @{Permission = 'Count' } `
            -IncludePivotChart `
            -ChartType ColumnStacked `
            -ChartHeight 800 `
            -ChartWidth 1200 `
            -ChartRow 4 `
            -ChartColumn 5 `
            -WarningAction SilentlyContinue


        $styles = @(
            New-ExcelStyle -FontColor White -BackgroundColor DarkBlue -Bold -Range "A1:R1" -Height 20 -FontSize 12 -VerticalAlignment Center
            New-ExcelStyle -FontColor Blue -Underline -Range "E2:E$maxRows"
            New-ExcelStyle -FontColor Blue -Underline -Range "J2:J$maxRows"
            New-ExcelStyle -FontColor Blue -Underline -Range "M2:M$maxRows"
        )

        $excel = $AppConsentsWithRisk | Export-Excel -Path $Path -WorksheetName ConsentGrantData `
            -PivotTableDefinition $pt `
            -FreezeTopRow `
            -AutoFilter `
            -Activate `
            -Style $styles `
            -HideSheet "None" `
            -PassThru

        $userStyle = @(
            New-ExcelStyle -FontColor White -BackgroundColor DarkBlue -Bold -Range "A1:B1" -Height 20 -FontSize 12 -VerticalAlignment Center
            New-ExcelStyle -FontColor Blue -Underline -Range "A2:A$maxRows"
        )
        $highprivilegeusers | Export-Excel -ExcelPackage $excel -WorksheetName HighPrivilegeUsers -Style $userStyle -PassThru -FreezeTopRow -AutoFilter | Out-Null
        $appStyle = @(
            New-ExcelStyle -FontColor White -BackgroundColor DarkBlue -Bold -Range "A1:D1" -Height 20 -FontSize 12 -VerticalAlignment Center
            New-ExcelStyle -FontColor Blue -Underline -Range "A2:A$maxRows"
        )
        $highprivilegeapps | Export-Excel -ExcelPackage $excel -WorksheetName HighPrivilegeApps -Style $appStyle -PassThru -FreezeTopRow -AutoFilter | Out-Null

        $consentSheet = $excel.Workbook.Worksheets["ConsentGrantData"]
        $consentSheet.Column(1).Width = 20 #PermissionType
        $consentSheet.Column(2).Hidden = $true #ConsentTypeFilter
        $consentSheet.Column(3).Hidden = $true #ClientObjectId
        $consentSheet.Column(4).Hidden = $true #AppId
        $consentSheet.Column(5).Width = 40 #ClientDisplayName
        $consentSheet.Column(6).Hidden = $true #ResourceObjectId
        $consentSheet.Column(7).Hidden = $true #ResourceObjectIdFilter
        $consentSheet.Column(8).Width = 40 #ResourceDisplayName
        $consentSheet.Column(9).Hidden = $true #ResourceDisplayNameFilter
        $consentSheet.Column(10).Width = 40 #Permission
        $consentSheet.Column(11).Hidden = $true #PermissionFilter
        $consentSheet.Column(12).Hidden = $true #PrincipalObjectId
        $consentSheet.Column(13).Width = 23 #PrincipalDisplayName
        $consentSheet.Column(14).Width = 17 #MicrosoftApp
        $consentSheet.Column(15).Hidden = $true #AppOwnerOrganizationId
        $consentSheet.Column(16).Width = 15 #Privilege
        $consentSheet.Column(17).Hidden = $true #PrivilegeFilter
        $consentSheet.Column(18).Hidden = $true #AssignedTo

        $consentSheet.Column(14).Style.HorizontalAlignment = "Center" #MicrosoftApp
        $consentSheet.Column(16).Style.HorizontalAlignment = "Center" #Privilege

        Add-ConditionalFormatting -Worksheet $consentSheet -Range "A1:Z$maxRows" -RuleType Equal -ConditionValue "High" -ForegroundColor White -BackgroundColor Red
        Add-ConditionalFormatting -Worksheet $consentSheet -Range "A1:Z$maxRows" -RuleType Equal -ConditionValue "Medium" -ForegroundColor Black -BackgroundColor Orange
        Add-ConditionalFormatting -Worksheet $consentSheet -Range "A1:Z$maxRows" -RuleType Equal -ConditionValue "Low" -ForegroundColor Black -BackgroundColor LightGreen
        Add-ConditionalFormatting -Worksheet $consentSheet -Range "A1:Z$maxRows" -RuleType Equal -ConditionValue "Unranked" -ForegroundColor Black -BackgroundColor LightGray

        $userSheet = $excel.Workbook.Worksheets["HighPrivilegeUsers"]
        Add-ConditionalFormatting -Worksheet $userSheet -Range "B1:B$maxRows" -RuleType Equal -ConditionValue "High" -ForegroundColor White -BackgroundColor Red
        Set-ExcelRange -Worksheet $userSheet -Range "A1:C$maxRows"
        $userSheet.Column(1).Width = 45 #PrincipalDisplayName
        $userSheet.Column(2).Width = 15 #Privilege
        $userSheet.Column(2).Style.HorizontalAlignment = "Center" #Privilege


        $appSheet = $excel.Workbook.Worksheets["HighPrivilegeApps"]
        Add-ConditionalFormatting -Worksheet $appSheet -Range "B1:B$maxRows" -RuleType Equal -ConditionValue "High" -ForegroundColor White -BackgroundColor Red
        Set-ExcelRange -Worksheet $appSheet -Range "A1:C$maxRows"
        $appSheet.Column(1).Width = 45 #ClientDisplayName
        $appSheet.Column(2).Width = 15 #Privilege
        $appSheet.Column(3).Width = 20 #AssignedTo
        $appSheet.Column(4).Width = 17 #MicrosoftApp

        $appSheet.Column(2).Style.HorizontalAlignment = "Center" #Privilege
        $appSheet.Column(3).Style.HorizontalAlignment = "Right" #AssignedTo
        $appSheet.Column(4).Style.HorizontalAlignment = "Center" #MicrosoftApp

        $appSheet.Cells["C1"].Style.HorizontalAlignment = "Center" #AssignedTo
        $appSheet.Cells["D1"].Style.HorizontalAlignment = "Center" #AssignedTo

        Export-Excel -ExcelPackage $excel -WorksheetName "ConsentGrantData" -Activate -HideSheet "Sheet1"

        Write-Verbose ("Excel workbook {0}" -f $ExcelWorkbookPath)
    }

    # Call main function
    Main
}

#endregion

#region Export-MsIdAzureMfaReport.ps1

<#
.SYNOPSIS
    Exports the list of users that have signed into the Azure portal, Azure CLI, or Azure PowerShell over the last 30 days by querying the sign-in logs.
    In [Microsoft Entra ID Free](https://learn.microsoft.com/entra/identity/monitoring-health/reference-reports-data-retention#activity-reports) tenants, sign-in log retention is limited to seven days.

    The report also includes each user's multi-factor authentication (MFA) registration status from Microsoft Entra.

    ```powershell
    Install-Module MsIdentityTools -Scope CurrentUser

    Connect-MgGraph -Scopes Directory.Read.All, AuditLog.Read.All, UserAuthenticationMethod.Read.All

    Export-MsIdAzureMfaReport .\report.xlsx
    ```

    ### Permissions and roles
    - Required Microsoft Entra role: **Global Reader**
    - Required permission scopes: **Directory.Read.All**, **AuditLog.Read.All**, **UserAuthenticationMethod.Read.All**

    ### Output
    ![Screenshot of a sample Azure MFA report](../assets/export-msidazuremfareport-sample.png)

    * This report will assist you in assessing the impact of the [Microsoft will require MFA for all Azure users](https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/microsoft-will-require-mfa-for-all-azure-users/ba-p/4140391) rollout on your tenant.
    ### MFA Status

    - **✅ MFA Capable + Signed in with MFA**: The user has MFA authentication methods registered and has successfully signed in at least once to Azure using MFA.
    - **✅ MFA Capable**: The user has MFA authentication methods registered but has always signed into Azure using single factor authentication.
    - **❌ Not MFA Capable**: The user has not yet registered a multi-factor authentication method and has not signed into Azure using MFA. Note: This status may not be accurate if your tenant uses identity federation or a third-party multi-factor authentication provider. See [MFA Status when using identity federation](#mfa-status-when-using-identity-federation).

.DESCRIPTION
    ### Consenting to permissions
        If this is the first time running `Connect-MgGraph` with the permission scopes listed above, the user consenting to the permissions will need to be in one of the following roles:
        - **Cloud Application Administrator**
        - **Application Administrator**
        - **Privileged Role Administrator**

        After the initial consent the `Export-MsIdAzureMfaReport` cmdlet can be run by any user with the Microsoft Entra **Global Reader** role.

    ### PowerShell 7.0
        This cmdlet requires [PowerShell 7.0](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) or later.

.EXAMPLE
    Connect-MgGraph -Scopes Directory.Read.All, AuditLog.Read.All, UserAuthenticationMethod.Read.All
    Export-MsIdAzureMfaReport .\report.xlsx

    Queries the last 30 days sign-in logs and creates a report of users accessing Azure and their MFA status in Excel format.

.EXAMPLE
    Export-MsIdAzureMfaReport .\report.xlsx -Days 3

    Queries sign-in logs for the past 3 days and creates a report of Azure users and their MFA status in Excel format.

.EXAMPLE
    Export-MsIdAzureMfaReport -PassThru | Export-Csv -Path .\report.csv

    Returns the results and exports them to a CSV file.

.EXAMPLE
    Export-MsIdAzureMfaReport .\report.xlsx -SignInsJsonPath ./signIns.json

    Generates the report from the sign-ins JSON file downloaded from the Entra portal. This is required for Entra ID Free tenants.

.NOTES

    ### Entra ID Free tenants

    If you are using an Entra ID Free tenant, additional steps are required to download the sign-in logs

    Follow these steps to download the sign-in logs.

    - Sign-in to the **[Entra Admin Portal](https://entra.microsoft.com)**
    - From the left navigation select: **Identity** → **Monitoring & health** → **Sign-in logs**.
    - Select the **Date** filter and set to **Last 7 days**
    - Select **Add filters** → **Application** and click **Apply**
    - Type in: **Azure** and click **Apply**
    - Select **Download** → **Download JSON**
    - Set the **File Name** of the first textbox to **signins** and click **Download**.
    - Once the file is downloaded, copy it to the folder where the export command will be run.

    Run the export with the **-SignInsJsonPath** option.
    ```powershell
    Export-MsIdAzureMfaReport ./report.xlsx -SignInsJsonPath ./signins.json
    ```

    ### Delay in reporting MFA Status and Authentication Methods

    The **MFA Status** does not immediately reflect changes made to the user's authentication methods. Expect a delay of up to 24 hours for the report to reflect the latest MFA status.

    To get the latest MFA status use the `-UseAuthenticationMethodEndPoint` switch. This option will get the latest user details but will take longer to export.

    ### MFA Status when using identity federation

    Tenants configured with identity federation may not have an accurate **MFA Status** in this report unless MFA is enforced for Azure Portal access.

    To resolve this:

    - Enforce MFA for these users using Conditional Access or Security Defaults.
      - [Conditional Access policy - Require MFA for Azure management](https://learn.microsoft.com/entra/identity/conditional-access/howto-conditional-access-policy-azure-management) for Entra ID premium tenants.
      - [Security Defaults](https://learn.microsoft.com/entra/fundamentals/security-defaults) for Entra ID free tenants.
    - Request users to sign in to the Azure portal.
    - Re-run this report to confirm their MFA status.
#>

function Export-MsIdAzureMfaReport {
    [CmdletBinding(HelpUri = 'https://azuread.github.io/MSIdentityTools/commands/Export-MsIdAzureMfaReport')]
    param (
        # Output file location for Excel Workbook. e.g. .\report.xlsx
        [string]
        [Parameter(Position = 1)]
        [string]
        $ExcelWorkbookPath,

        # Optional. Path to the sign-ins JSON file. If provided, the report will be generated from this file instead of querying the sign-ins.
        [string]
        $SignInsJsonPath,

        # Switch to include the results in the output
        [switch]
        $PassThru,

        # Optional. Number of days to query sign-in logs. Defaults to 30 days.
        [ValidateScript({
                $_ -ge 0 -and $_ -le 30
            },
            ErrorMessage = "Logs are only available for 30 days. Please enter a number between 0 and 30.")]
        [int]
        $Days,

        # Optional. Hashtable with a pre-defined list of User objects (Use Get-MsIdAzureUsers).
        [array]
        $Users,

        # If enabled, the user auth method will be used (slower) instead of the reporting API. This is the default for free tenants as the reporting API requires a premium license.
        [switch]
        $UseAuthenticationMethodEndPoint

        # # Used for dev. Hashtable with a pre-defined list of User objects with auth methods. Used for generating spreadhsheet.
        # [array]
        # $UsersMfa,
    )
    function Main() {

        if (-not (Test-MgModulePrerequisites @('AuditLog.Read.All', 'Directory.Read.All', 'UserAuthenticationMethod.Read.All'))) { return }

        $isExcel = ![string]::IsNullOrEmpty($ExcelWorkbookPath)
        if ($isExcel) {
            # Determine if the ImportExcel module is installed since the parameter was included
            if ($null -eq (Get-Module -Name ImportExcel -ListAvailable)) {
                Write-Error "The ImportExcel module is not installed. This is used to export the results to an Excel worksheet. Please install the ImportExcel Module before using this parameter or run without this parameter." -ErrorAction Stop
            }

            if ([IO.Path]::GetExtension($ExcelWorkbookPath) -notmatch ".xlsx") {
                Write-Error "The ExcelWorkbookPath '$ExcelWorkbookPath' is not a valid Excel file. Please provide a valid Excel file path. E.g. .\report.xlsx" -ErrorAction Stop
            }
        }

        # if ($UsersMfa) {
        # # We only need to generate the report.
        # $azureUsersMfa = $UsersMfa
        # }
        # else {
            if (![string]::IsNullOrEmpty($SignInsJsonPath)) {
                # Don't look up graph if we have the sign-ins json (usually free tenant download from portal)
                $Users = Get-MsIdAzureUsers -SignInsJsonPath $SignInsJsonPath
            }
            # Get the users and their MFA status
            elseif ($null -eq $Users) {
                # Get the users
                $Users = Get-MsIdAzureUsers -Days $Days
            }
            $azureUsersMfa = GetUserMfaInsight $Users # Get the MFA status
        # }

        if ($isExcel) {
            if ($null -eq $azureUsersMfa) {
                Write-Host 'Excel workbook not generated as there are no users to report on.' -ForegroundColor Yellow
            }
            else {
                GenerateExcelReport $azureUsersMfa $ExcelWorkbookPath
            }
        }

        if (-not ($isExcel) -or ($isExcel -and $PassThru)) {
            return $azureUsersMfa
        }
    }

    function GenerateExcelReport ($UsersMfa, $Path) {

        $maxRows = $UsersMfa.Count + 1

        $UsersMfa = $UsersMfa | Sort-Object -Property @{Expression = "MfaStatusIcon"; Descending = $true }, MfaStatus, UserDisplayName

        # Delete the existing output file if it already exists
        $OutputFileExists = Test-Path $Path
        if ($OutputFileExists -eq $true) {
            Get-ChildItem $Path | Remove-Item -Force
        }

        $headerBgColour = [System.Drawing.ColorTranslator]::FromHtml("#0077b6")
        $darkGrayColour = [System.Drawing.ColorTranslator]::FromHtml("#A9A9A9")
        $styles = @(
            New-ExcelStyle -Range "A1:J$maxRows" -Height 20 -FontSize 14
            New-ExcelStyle -Range "A1:J1" -FontColor White -BackgroundColor $headerBgColour -Bold -HorizontalAlignment Center
            New-ExcelStyle -Range "A2:A$maxRows" -FontColor Blue -Underline
            New-ExcelStyle -Range "D2:D$maxRows" -FontColor Blue -Underline
            New-ExcelStyle -Range "E2:G$maxRows" -FontColor Blue -HorizontalAlignment Center
            New-ExcelStyle -Range "C2:C$maxRows" -HorizontalAlignment Center
            New-ExcelStyle -Range "I2:I$maxRows" -FontColor $darkGrayColour -HorizontalAlignment Fill
        )

        $authMethodBlade = 'https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/UserAuthMethods/userId/%id%/hidePreviewBanner~/true'
        $userBlade = 'https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/%id%/hidePreviewBanner~/true'

        $report = $UsersMfa | Select-Object `
        @{name = 'Name'; expression = { GetLink $userBlade $_.UserId $_.UserDisplayName } }, UserPrincipalName, `
        @{name = ' '; expression = { $_.MfaStatusIcon } }, `
        @{name = 'MFA Status'; expression = {
                GetLink $authMethodBlade $_.UserId $_.MfaStatus
            }
        }, `
        @{name = 'Az Portal'; expression = { GetTickSymbol $_.AzureAppName "Azure Portal" } }, `
        @{name = 'Az CLI'; expression = { GetTickSymbol $_.AzureAppName "Azure CLI" } }, `
        @{name = 'Az PowerShell'; expression = { GetTickSymbol $_.AzureAppName "Azure PowerShell" } }, `
        @{name = 'Authentication Methods'; expression = { $_.AuthenticationMethods -join ', ' } }, UserId, `
        @{name = 'Notes'; expression = { if (![string]::IsNullOrEmpty($_.Notes)) { $_.Notes } } } `

        $excel = $report | Export-Excel -Path $Path -WorksheetName "MFA Report" `
            -FreezeTopRow `
            -Activate `
            -Style $styles `
            -HideSheet "None" `
            -PassThru `
            -IncludePivotChart -PivotTableName "MFA Readiness" -PivotRows "MFA Status" -PivotData @{'MFA Status' = 'count' } -PivotChartType PieExploded3D -ShowPercent

        $sheet = $excel.Workbook.Worksheets["MFA Report"]
        $sheet.Column(1).Width = 35 #DisplayName
        $sheet.Column(2).Width = 35 #UPN
        $sheet.Column(3).Width = 6 #MFA Icon
        $sheet.Column(4).Width = 34 #MFA Registered
        $sheet.Column(5).Width = 17 #Azure Portal
        $sheet.Column(6).Width = 17 #Azure CLI
        $sheet.Column(7).Width = 17 #Azure PowerShell
        $sheet.Column(8).Width = 40 #AuthenticationMethods
        $sheet.Column(9).Width = 15 #UserId
        $sheet.Column(10).Width = 30 #Notes

        Add-ConditionalFormatting -Worksheet $sheet -Range "C2:C$maxRows" -ConditionValue '=$C2="✅"' -RuleType Expression -ForegroundColor Green
        Add-ConditionalFormatting -Worksheet $sheet -Range "C2:C$maxRows" -ConditionValue '=$C2="❌"' -RuleType Expression -ForegroundColor Red

        Export-Excel -ExcelPackage $excel -WorksheetName "MFA Report" -Activate

        Write-Verbose ("Excel workbook {0}" -f $ExcelWorkbookPath)

    }

    function GetTickSymbol($source, $matchString) {
        if ($source -match $matchString) { return "🔵" }
        return ""
    }

    function GetLink($uriFormat, $id, $name) {
        $uri = $uriFormat -replace '%id%', $id
        $hyperlink = '=Hyperlink("%uri%", "%name%")'
        $hyperlink = $hyperlink -replace '%uri%', $uri
        $hyperlink = $hyperlink -replace '%name%', $name
        Write-Verbose $hyperlink
        return ( $hyperlink)
    }

    # Get the authentication method state for each user
    function GetUserMfaInsight($users) {

        if (-not $users) { return $null }
        if ($UseAuthenticationMethodEndPoint) { $isPremiumTenant = $false } # Force into free tenant mode
        else { $isPremiumTenant = GetIsPremiumTenant $users }

        #$users = $users | Select-Object -First 10 # For testing

        $totalCount = $users.Count
        $currentCount = 0
        foreach ($user in $users) {
            Write-Verbose $user.UserId
            Write-Verbose $user.UserPrincipalName

            $currentCount++
            AddMfaProperties $user
            UpdateProgress $currentCount $totalCount $user

            if ($user.AuthenticationRequirement -eq "multiFactorAuthentication") {
                $user.MfaStatus = "MFA Capable + Signed in with MFA"
                $user.MfaStatusIcon = "✅"
            }

            $graphUri = "$graphBaseUri/v1.0/users/$($user.UserId)/authentication/methods"
            if ($isPremiumTenant) {
                $graphUri = "$graphBaseUri/v1.0/reports/authenticationMethods/userRegistrationDetails/$($user.UserId)"
            }
            $resultsJson = Invoke-MgGraphRequest -Uri $graphUri -Method GET -SkipHttpErrorCheck
            $err = Get-ObjectPropertyValue $resultsJson -Property "error"

            if ($err) {
                if ($err.code -eq "Authentication_RequestFromUnsupportedUserRole") {
                    $message += $err.message + " The signed-in user needs to be assigned the Microsoft Entra Global Reader role."
                    Write-Error $message -ErrorAction Stop
                }

                $user.Notes = "Unable to retrieve MFA info for user. $($err.message) ($($err.code))"
                continue
            }

            if ($isPremiumTenant) {
                $methodsRegistered = Get-ObjectPropertyValue $resultsJson -Property 'methodsRegistered'
                $userAuthMethod = @()
                foreach ($method in $methodsRegistered) {
                    $methodInfo = $authMethods | Where-Object { $_.ReportType -eq $method }
                    if ($null -eq $methodInfo) { $userAuthMethod += $method }
                    else {
                        if ($methodInfo.IsMfa) { $userAuthMethod += $methodInfo.DisplayName }
                    }
                }
                $user.AuthenticationMethods = $userAuthMethod -join ', '
                $user.IsMfaRegistered = Get-ObjectPropertyValue $resultsJson -Property 'isMfaRegistered'
                $user.IsMfaCapable = Get-ObjectPropertyValue $resultsJson -Property 'isMfaCapable'
            }
            else {
                $graphMethods = Get-ObjectPropertyValue $resultsJson -Property "value"
                $userAuthMethods = @()
                $isMfaRegistered = $false
                $types = $graphMethods | Select-Object '@odata.type' -Unique
                foreach ($method in $types) {
                    $type = $method.'@odata.type'
                    Write-Verbose "Type: $type"
                    $userAuthMethod = GetAuthMethodInfo $type
                    if ($userAuthMethod.IsMfa) {
                        $isMfaRegistered = $true
                        $userAuthMethods += $userAuthMethod.DisplayName
                    }
                }
                $user.AuthenticationMethods = $userAuthMethods
                $user.IsMfaRegistered = $isMfaRegistered
                $user.IsMfaCapable = $isMfaRegistered
            }

            if ($user.AuthenticationRequirement -ne "multiFactorAuthentication") {
                if ($user.IsMfaCapable) {
                    $user.MfaStatus = "MFA Capable"
                    $user.MfaStatusIcon = "✅"
                }
                else {
                    $user.MfaStatus = "Not MFA Capable"
                    $user.MfaStatusIcon = "❌"
                }
            }
        }

        return $users
    }

    # Check if the tenant has permissions to call the user registration API.
    function GetIsPremiumTenant($users) {
        $isPremiumTenant = $true
        if ($users -and $users.Count -gt 0) {
            $user = $users[0]
            $graphUri = "$graphBaseUri/v1.0/reports/authenticationMethods/userRegistrationDetails/$($user.UserId)"
            $resultsJson = Invoke-MgGraphRequest -Uri $graphUri -Method GET -SkipHttpErrorCheck
            $err = Get-ObjectPropertyValue $resultsJson -Property "error"

            if ($err) {
                $isPremiumTenant = $err.code -ne "Authentication_RequestFromNonPremiumTenantOrB2CTenant"
            }
        }
        return $isPremiumTenant
    }
    function AddMfaProperties($user) {
        $user | Add-Member -MemberType NoteProperty -Name "Notes" -Value $null -ErrorAction SilentlyContinue
        $user | Add-Member -MemberType NoteProperty -Name "AuthenticationMethods" -Value $null -ErrorAction SilentlyContinue
        $user | Add-Member -MemberType NoteProperty -Name "IsMfaRegistered" -Value $null -ErrorAction SilentlyContinue
        $user | Add-Member -MemberType NoteProperty -Name "IsMfaCapable" -Value $null -ErrorAction SilentlyContinue
        $user | Add-Member -MemberType NoteProperty -Name "MfaStatus" -Value $null -ErrorAction SilentlyContinue
        $user | Add-Member -MemberType NoteProperty -Name "MfaStatusIcon" -Value $null -ErrorAction SilentlyContinue
    }

    function UpdateProgress($currentCount, $totalCount, $user) {
        $userStatusDisplay = $user.UserId

        if ([bool]$user.PSObject.Properties["UserPrincipalName"]) {
            $userStatusDisplay = $user.UserPrincipalName
        }

        $percent = [math]::Round(($currentCount / $totalCount) * 100)

        Write-Progress -Activity "Getting authentication method" -Status "[$currentCount of $totalCount] Checking $userStatusDisplay. $percent% complete" -PercentComplete $percent
    }

    function WriteExportProgress(
        # The current step of the overal generation
        [ValidateSet("ServicePrincipal", "AppPerm", "DownloadDelegatePerm", "ProcessDelegatePerm", "GenerateExcel", "Complete")]
        $MainStep,
        $Status = "Processing...",
        # The percentage of completion within the child step
        $ChildPercent,
        [switch]$ForceRefresh) {
        $percent = 0
        switch ($MainStep) {
            "ServicePrincipal" {
                $percent = GetNextPercent $ChildPercent 2 10
                $activity = "Downloading service principals"
            }
            "AppPerm" {
                $percent = GetNextPercent $ChildPercent 10 50
                $activity = "Downloading application permissions"
            }
            "DownloadDelegatePerm" {
                $percent = GetNextPercent $ChildPercent 50 75
                $activity = "Downloading delegate permissions"
            }
            "ProcessDelegatePerm" {
                $percent = GetNextPercent $ChildPercent 75 90
                $activity = "Processing delegate permissions"
            }
            "GenerateExcel" {
                $percent = GetNextPercent $ChildPercent 90 99
                $activity = "Processing risk information"
            }
            "Complete" {
                $percent = 100
                $activity = "Complete"
            }
        }

        if ($ForceRefresh.IsPresent) {
            Start-Sleep -Milliseconds 250
        }
        Write-Progress -Id 0 -Activity $activity -PercentComplete $percent -Status $Status
    }

    function GetAuthMethodInfo($type) {
        $methodInfo = $authMethods | Where-Object { $_.Type -eq $type }
        if ($null -eq $methodInfo) {
            # Default to the type and assume it is MFA
            $methodInfo = @{
                Type        = $type
                DisplayName = ($type -replace '#microsoft.graph.', '') -replace 'AuthenticationMethod', ''
                IsMfa       = $true
            }
        }
        return $methodInfo
    }

    $authMethods = @(
        @{
            ReportType  = 'passKeyDeviceBoundAuthenticator'
            Type        = $null
            DisplayName = 'Passkey (Microsoft Authenticator)'
            IsMfa       = $true
        },
        @{
            ReportType  = 'passKeyDeviceBound'
            Type        = '#microsoft.graph.fido2AuthenticationMethod'
            DisplayName = "Passkey (other device-bound)"
            IsMfa       = $true
        },
        @{
            ReportType  = 'email'
            Type        = '#microsoft.graph.emailAuthenticationMethod'
            DisplayName = 'Email'
            IsMfa       = $false
        },
        @{
            ReportType  = 'microsoftAuthenticatorPush'
            Type        = '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'
            DisplayName = 'Microsoft Authenticator'
            IsMfa       = $true
        },
        @{
            ReportType  = 'mobilePhone'
            Type        = '#microsoft.graph.phoneAuthenticationMethod'
            DisplayName = 'Phone'
            IsMfa       = $true
        },
        @{
            ReportType  = 'softwareOneTimePasscode'
            Type        = '#microsoft.graph.softwareOathAuthenticationMethod'
            DisplayName = 'Authenticator app (TOTP)'
            IsMfa       = $true
        },
        @{
            ReportType  = $null
            Type        = '#microsoft.graph.temporaryAccessPassAuthenticationMethod'
            DisplayName = 'Temporary Access Pass'
            IsMfa       = $false
        },
        @{
            ReportType  = 'windowsHelloForBusiness'
            Type        = '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod'
            DisplayName = 'Windows Hello for Business'
            IsMfa       = $true
        },
        @{
            ReportType  = $null
            Type        = '#microsoft.graph.passwordAuthenticationMethod'
            DisplayName = 'Password'
            IsMfa       = $false
        },
        @{
            ReportType  = $null
            Type        = '#microsoft.graph.platformCredentialAuthenticationMethod'
            DisplayName = 'Platform Credential for MacOS'
            IsMfa       = $true
        },
        @{
            ReportType  = 'microsoftAuthenticatorPasswordless'
            Type        = '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod'
            DisplayName = 'Microsoft Authenticator'
            IsMfa       = $true
        }
    )

    $graphBaseUri = Get-GraphBaseUri
    # Call main function
    Main
}

#endregion

#region Find-MsIdUnprotectedUsersWithAdminRoles.ps1

<#
.SYNOPSIS
    Find Users with Admin Roles that are not registered for MFA
.DESCRIPTION
    Find Users with Admin Roles that are not registered for MFA by evaluating their authentication methods registered for MFA and their sign-in activity.
.EXAMPLE
    PS > Find-MsIdUnprotectedUsersWithAdminRoles

    Enumerate users with role assignments
.EXAMPLE
    PS > Find-MsIdUnprotectedUsersWithAdminRoles -includeSignIns:$false

    Enumerate users with role assignments including their sign in activity
.NOTES
     - Eligible users for roles may not have active assignments showing in their directoryrolememberships, but they have the potential to elevate to assigned roles
     - Large amounts of role assignments may take time process.
     - Must be connected to MS Graph with appropriate scopes for reading user, group, application, role, an sign in information .
      -- Connect-MgGraph -scopes RoleManagement.Read.Directory,UserAuthenticationMethod.Read.All,AuditLog.Read.All,User.Read.All,Group.Read.All,Application.Read.All
#>

function Find-MsIdUnprotectedUsersWithAdminRoles {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Include Sign In log activity - Note this can cause the query to run slower in larger active environments
        [switch] $IncludeSignIns
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgUser', 'Get-MgUserAuthenticationMethod', 'Get-MgGroupMember', 'Get-MgRoleManagementDirectoryRoleDefinition', 'Get-MgRoleManagementDirectoryRoleAssignmentSchedule', 'Get-MgRoleManagementDirectoryRoleEligibilitySchedule', 'Get-MgAuditLogSignIn' -MinimumVersion 2.8.0 -RequireListPermissions -ErrorVariable CriticalError)) { return }


        if ($VerbosePreference -eq 'SilentlyContinue') {
            Write-Host "NOTE: This process may take awhile depending on the size of the environment. Please run with -Verbose switch for more details progress output."
        }

    }

    process {
        if ($CriticalError) { return }

        [array]$usersWithRoles = Get-UsersWithRoleAssignments

        $TotalUsersCount = $usersWithRoles.count
        Write-Verbose ("Checking {0} users with roles..." -f $TotalUsersCount)

        [array]$checkedUsers = @()
        $checkUsersCount = 0

        foreach ($user in $usersWithRoles) {

            $checkUsersCount++

            $userObject = $null
            try {
                $userObject = Get-MgUser -UserId $user.PrincipalId -Property signInActivity, UserPrincipalName, Id
            }
            catch {
                Write-Warning ("User object with UserId {0} with a role assignment was not found! Review assignment for orphaned user." -f $user.PrincipalId)
            }

            Write-Verbose ("User {0} of {1} - Evaluating {2} with role assignments...." -f $checkUsersCount, $TotalUsersCount, $userObject.Id)

            if ($Null -ne $userObject) {
                $UserAuthMethodStatus = Get-UserMfaRegisteredStatus -UserId $userObject.UserPrincipalName

                $checkedUser = [ordered] @{}
                $checkedUser.UserID = $userObject.Id
                $checkedUser.UserPrincipalName = $userObject.UserPrincipalName

                If ($null -eq $userObject.signInActivity.LastSignInDateTime) {
                    $checkedUser.LastSignInDateTime = "Unknown"
                    $checkedUser.LastSigninDaysAgo = "Unknown"
                }
                else {
                    $checkedUser.LastSignInDateTime = $userObject.signInActivity.LastSignInDateTime
                    $checkedUser.LastSigninDaysAgo = (New-TimeSpan -Start $checkedUser.LastSignInDateTime -End (Get-Date)).Days
                }
                $checkedUser.DirectoryRoleAssignments = $user.RoleName
                $checkedUser.DirectoryRoleAssignmentType = $user.AssignmentType
                $checkedUser.DirectoryRoleAssignmentCount = $user.RoleName.count
                $checkedUser.RoleAssignedBy = $user.RoleAssignedBy
                $checkedUser.IsMfaRegistered = $UserAuthMethodStatus.isMfaRegistered
                $checkedUser.Status = $UserAuthMethodStatus.Status

                if ($includeSignIns -eq $true) {
                    $signInInfo = Get-UserSignInSuccessHistoryAuth -userId $checkedUser.UserId

                    $checkedUser.SuccessSignIns = $signInInfo.SuccessSignIns
                    $checkedUser.MultiFactorSignIns = $signInInfo.MultiFactorSignIns
                    $checkedUser.SingleFactorSignIns = $signInInfo.SingleFactorSignIns
                    $checkedUser.RiskySignIns = $signInInfo.RiskySignIns
                }
                else {
                    $checkedUser.SuccessSignIns = "Skipped"
                    $checkedUser.MultiFactorSignIns = "Skipped"
                    $checkedUser.SingleFactorSignIns = "Skipped"
                    $checkedUser.RiskySignIns = "Skipped"
                }
                $checkedUsers += ([pscustomobject]$checkedUser)
            }
            else {

                $checkedUser = [ordered] @{}
                $checkedUser.UserID = $userObject.Id
                $checkedUser.Status = "Not Exists"

            }
        }

    }

    end {
        if ($CriticalError) { return }

        Write-Verbose ("{0} Users Evaluated!" -f $checkedUsers.count)
        Write-Verbose ("{0} Users with roles who are NOT registered for MFA!" -f ($checkedUsers | Where-Object -FilterScript { $_.isMfaRegistered -eq $false }).count)
        Write-Output $checkedUsers
    }
}

function Get-UserMfaRegisteredStatus ([string]$UserId) {

    $mfaMethods = @("#microsoft.graph.fido2AuthenticationMethod", "#microsoft.graph.softwareOathAuthenticationMethod", "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod", "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod", "#microsoft.graph.phoneAuthenticationMethod")

    $results = [pscustomobject]@{
        IsMfaRegistered = $null
        AuthMethodsRegistered = $null
        status = $null
    }

    try {
        $authMethods = (Get-MgUserAuthenticationMethod -UserId $UserId).AdditionalProperties."@odata.type"

        $isMfaRegistered = $false
        foreach ($mfa in $MfaMethods) { if ($authmethods -contains $mfa) { $isMfaRegistered = $true } }

        $results.IsMfaRegistered = $isMfaRegistered
        $results.AuthMethodsRegistered = $authMethods
        $results.status = "Checked"

    }
    catch {
        $results.status = "Unknown"
    }

    Write-Output $results

}

function Get-UserSignInSuccessHistoryAuth ([string]$userId) {

    $signinAuth = @{}
    $signinAuth.UserID = $userId
    $signinAuth.SuccessSignIns = 0
    $signinAuth.MultiFactorSignIns = 0
    $signinAuth.SingleFactorSignIns = 0
    $signInAuth.RiskySignIns = 0

    $filter = ("UserId eq '{0}' and status/errorCode eq 0" -f $userId)
    Write-Debug $filter
    [array]$signins = Get-MgAuditLogSignIn -Filter $filter -all:$True
    Write-Debug $signins.count

    if ($signins.count -gt 0) {

        $signinAuth.SuccessSignIns = $signins.count
        $groupedAuth = $signins | Group-Object -Property AuthenticationRequirement

        $MfaSignInsCount = 0
        $MfaSignInsCount = $groupedAuth | Where-Object -FilterScript { $_.Name -eq 'multiFactorAuthentication' } | Select-Object -ExpandProperty count
        if ($null -eq $MfaSignInsCount) {
            $MfaSignInsCount = 0
        }
        $signinAuth.MultiFactorSignIns = $MfaSignInsCount

        $singleFactorSignInsCount = 0
        $singleFactorSignInsCount = $groupedAuth | Where-Object -FilterScript { $_.Name -eq 'singleFactorAuthentication' } | Select-Object -ExpandProperty count


        if ($null -eq $singleFactorSignInsCount) {
            $singleFactorSignInsCount = 0
        }
        $signinAuth.SingleFactorSignIns = $singleFactorSignInsCount

        $signInAuth.RiskySignIns = ($signins | Where-Object -FilterScript { $_.RiskLevelDuringSignIn -ne 'none' } | Measure-Object | Select-Object -ExpandProperty Count)

    }

    Write-Output ([pscustomobject]$signinAuth)
}

function Get-UsersWithRoleAssignments() {
    [array]$uniquePrincipals = $null
    [array]$usersWithRoles = $Null
    [array]$groupsWithRoles = $null
    [array]$servicePrincipalsWithRoles = $null
    [array]$roleAssignments = @()
    [array]$activeRoleAssignments = $null
    [array]$eligibleRoleAssignments = $null
    [array]$AssignmentSchedule = @()

    Write-Verbose "Retrieving Active Role Assignments..."
    [array]$activeRoleAssignments = Get-MgRoleManagementDirectoryRoleAssignmentSchedule -All:$true -ExpandProperty Principal | Add-Member -MemberType NoteProperty -Name AssignmentScope -Value "Active" -Force -PassThru | Add-Member -MemberType ScriptProperty -Name PrincipalType -Value { $this.Principal.AdditionalProperties."@odata.type".split('.')[2] } -Force -PassThru
    Write-Verbose ("{0} Active Role Assignments..." -f $activeRoleAssignments.count)
    $AssignmentSchedule += $activeRoleAssignments

    Write-Verbose "Retrieving Eligible Role Assignments..."
    [array]$eligibleRoleAssignments = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -All:$true -ExpandProperty Principal | Add-Member -MemberType NoteProperty -Name AssignmentScope -Value "Eligible" -Force -PassThru | Add-Member -MemberType ScriptProperty -Name PrincipalType -Value { $this.Principal.AdditionalProperties."@odata.type".split('.')[2] } -Force -PassThru
    Write-Verbose ("{0} Eligible Role Assignments..." -f $eligibleRoleAssignments.count)
    $AssignmentSchedule += $eligibleRoleAssignments

    Write-Verbose ("{0} Total Role Assignments to all principals..." -f $AssignmentSchedule.count)
    [array]$uniquePrincipals = $AssignmentSchedule.PrincipalId | Get-Unique
    Write-Verbose ("{0} Total Role Assignments to unique principals..." -f $uniquePrincipals.count)

    foreach ($type in ($AssignmentSchedule | Group-Object PrincipalType)) {
        Write-Verbose ("{0} assignments to {1} type" -f $type.count, $type.name)
    }

    foreach ($assignment in ($AssignmentSchedule)) {

        if ($assignment.PrincipalType -eq 'user') {
            $roleAssignment = @{}
            $roleAssignment.PrincipalId = $assignment.PrincipalId
            $roleAssignment.PrincipalType = $assignment.PrincipalType
            $roleAssignment.AssignmentType = $assignment.AssignmentScope
            $roleAssignment.RoleDefinitionId = $assignment.RoleDefinitionId
            $roleAssignment.RoleAssignedBy = "user"
            $roleAssignment.RoleName = [array](Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $assignment.RoleDefinitionId | Select-Object -ExpandProperty displayName)
            $roleAssignments += ([pscustomobject]$roleAssignment)
        }

        if ($assignment.PrincipalType -eq 'group') {
            Write-Verbose ("Expanding Group Members for Role Assignable Group {0}" -f $assignment.PrincipalId)
            $groupMembers = Get-MgGroupMember -GroupId $assignment.PrincipalId | Select-Object -ExpandProperty Id

            [array]$RoleName = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $assignment.RoleDefinitionId | Select-Object -ExpandProperty displayName

            foreach ($member in $groupMembers) {
                Write-Verbose ("Adding Group Member {0} for Role Assignable Group {0}" -f $member, $assignment.PrincipalId)

                $roleAssignment = @{}
                $roleAssignment.PrincipalId = $member
                $roleAssignment.PrincipalType = "user"
                $roleAssignment.AssignmentType = $assignment.AssignmentScope
                $roleAssignment.RoleDefinitionId = $assignment.RoleDefinitionId
                $roleAssignment.RoleAssignedBy = "group"
                $roleAssignment.RoleName = $RoleName
                $roleAssignments += ([pscustomobject]$roleAssignment)

            }
        }

    }

    [array]$usersWithRoles = $roleAssignments | Where-Object -FilterScript { $_.PrincipalType -eq 'user' }
    Write-Verbose ("{0} Total Role Assignments to Users" -f $usersWithRoles.count)

    Write-Output $usersWithRoles
}

#endregion

#region Get-MsIdProvisioningLogStatistics.ps1

<#
.SYNOPSIS
    Get Statistics for Set of Azure AD Provisioning Logs

.EXAMPLE
    PS > Get-MgAuditLogProvisioning -Filter "jobId eq '<jobId>'" | Get-MsIdProvisioningLogStatistics -SummarizeByCycleId -WriteToConsole

    Get Statistics for Set of Azure AD Provisioning Logs

#>

function Get-MsIdProvisioningLogStatistics {
    [CmdletBinding()]
    param (
        # Provisioning Logs
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object[]] $ProvisioningLogs,
        # Summarize Logs by CycleId
        [Parameter(Mandatory = $false)]
        [switch] $SummarizeByCycleId,
        # Write Summary to Host Console in addition to Standard Output
        [Parameter(Mandatory = $false)]
        [switch] $WriteToConsole
    )

    begin {
        function New-CycleSummary ($CycleId) {
            return [pscustomobject][ordered]@{
                CycleId          = $CycleId
                StartDateTime    = $null
                EndDateTime      = $null
                Changes          = 0
                Users            = 0
                ActionStatistics = @(
                    New-ActionStatusStatistics 'Create'
                    New-ActionStatusStatistics 'Update'
                    New-ActionStatusStatistics 'Delete'
                    New-ActionStatusStatistics 'Disable'
                    New-ActionStatusStatistics 'StagedDelete'
                    New-ActionStatusStatistics 'Other'
                )
            }
        }

        function New-ActionStatusStatistics ($Action) {
            return [PSCustomObject][ordered]@{
                Action  = $Action
                Success = 0
                Failure = 0
                Skipped = 0
                Warning = 0
            }
        }

        $CycleSummary = New-CycleSummary
        $CycleSummary.CycleId = New-Object 'System.Collections.Generic.List[string]'
        $CycleTracker = @{
            ChangeIds = New-Object 'System.Collections.Generic.HashSet[string]'
            UserIds   = New-Object 'System.Collections.Generic.HashSet[string]'
        }

        $CycleSummaries = [ordered]@{}
        $CycleTrackers = @{}
    }

    process {
        foreach ($ProvisioningLog in $ProvisioningLogs) {
            if ($SummarizeByCycleId) {
                if (!$CycleSummaries.Contains($ProvisioningLog.CycleId)) {
                    ## New CycleSummary object for new CycleId
                    $CycleSummaries[$ProvisioningLog.CycleId] = $CycleSummary = New-CycleSummary $ProvisioningLog.CycleId
                    $CycleTrackers[$ProvisioningLog.CycleId] = $CycleTracker = @{
                        ChangeIds = New-Object 'System.Collections.Generic.HashSet[string]'
                        UserIds   = New-Object 'System.Collections.Generic.HashSet[string]'
                    }
                }
                else {
                    $CycleSummary = $CycleSummaries[$ProvisioningLog.CycleId]
                    $CycleTracker = $CycleTrackers[$ProvisioningLog.CycleId]
                }
            }
            else {
                ## Add CycleId to a single summary object
                if (!$CycleSummary.CycleId.Contains($ProvisioningLog.CycleId)) { $CycleSummary.CycleId.Add($ProvisioningLog.CycleId) }
            }

            ## Update log date range
            if ($null -eq $CycleSummary.StartDateTime -or $ProvisioningLog.ActivityDateTime -lt $CycleSummary.StartDateTime) {
                $CycleSummary.StartDateTime = $ProvisioningLog.ActivityDateTime
            }
            if ($null -eq $CycleSummary.EndDateTime -or $ProvisioningLog.ActivityDateTime -gt $CycleSummary.EndDateTime) {
                $CycleSummary.EndDateTime = $ProvisioningLog.ActivityDateTime
            }

            ## Update summary object with statistics
            if ($CycleTracker.ChangeIds.Add($ProvisioningLog.ChangeId)) { $CycleSummary.Changes++ }
            if ($CycleTracker.UserIds.Add($ProvisioningLog.SourceIdentity.Id)) { $CycleSummary.Users++ }

            $CycleSummary.ActionStatistics | Where-Object Action -EQ $ProvisioningLog.ProvisioningAction | ForEach-Object { $_.($ProvisioningLog.ProvisioningStatusInfo.Status)++ }
        }
    }

    end {
        if ($SummarizeByCycleID) {
            [array] $CycleSummaries = $CycleSummaries.Values
        }
        else {
            [array] $CycleSummaries = $CycleSummary
        }

        foreach ($CycleSummary in $CycleSummaries) {
            Write-Output $CycleSummary

            if ($WriteToConsole) {
                Write-Host ('')
                Write-Host ("CycleId: {0}" -f ($CycleSummary.CycleId -join ', '))
                Write-Host ("Timespan: {0} - {1} ({2})" -f $CycleSummary.StartDateTime, $CycleSummary.EndDateTime, ($CycleSummary.EndDateTime - $CycleSummary.StartDateTime))
                Write-Host ("Total Changes: {0}" -f $CycleSummary.Changes)
                Write-Host ("Total Users: {0}" -f $CycleSummary.Users)
                Write-Host ('')

                $TableRowPattern = '{0,-12} {1,7} {2,7} {3,7} {4,7} {5,7}'
                Write-Host ($TableRowPattern -f 'Action', 'Success', 'Failure', 'Skipped', 'Warning', 'Total')
                Write-Host ($TableRowPattern -f '------', '-------', '-------', '-------', '-------', '-----')
                foreach ($row in $CycleSummary.ActionStatistics) {
                    Write-Host ($TableRowPattern -f $row.Action, $row.Success, $row.Failure, $row.Skipped, $row.Warning, ($row.Success + $row.Failure + $row.Skipped + $row.Warning))
                }
                Write-Host ('')
            }
        }
    }
}

#endregion

#region Get-MsIdAdfsSamlToken.ps1

<#
.SYNOPSIS
    Initiates a SAML logon request to and AD FS server to generate log activity and returns the user token.
.DESCRIPTION
    This command will generate log activity on the ADFS server, by requesting a SAML token using Windows or forms authentication.
.EXAMPLE
    PS > Get-MsIdAdfsSamlToken urn:microsoft:adfs:claimsxray -HostName adfs.contoso.com

    Sign in to an application on an AD FS server using logged user credentials using the SAML protocol.

.EXAMPLE
    PS > $credential = Get-Credential
    PS > Get-MsIdAdfsSamlToken urn:microsoft:adfs:claimsxray -HostName adfs.contoso.com

    Sign in to an application on an AD FS server using credentials provided by the user using the SAML endpoint and forms based authentication.

.EXAMPLE
    PS > $SamlIdentifiers = Get-AdfsRelyingPartyTrust | where { $_.WSFedEndpoint -eq $null } | foreach { $_.Identifier.Item(0) }
    PS > $SamlIdentifiers | foreach { Get-MsIdAdfsSamlToken $_ -HostName adfs.contoso.com }
    
    Get all SAML relying party trusts from the AD FS server and sign in using the logged user credentials.

#>

function Get-MsIdAdfsSamlToken 
{
  [CmdletBinding()]
  [OutputType([string])]
  param(
    # Application identifier
    [Parameter(Mandatory=$true,
      Position=0,
      ValueFromPipeline=$true,
      ValueFromPipelineByPropertyName=$true)]
    [string]$Issuer,
    # Enter host name for the AD FS server
    [Parameter(Mandatory=$true)]
    [string]$HostName,
    # Provide the credential for the user to be signed in
    [Parameter(Mandatory=$false)]
    [pscredential]$Credential
  )

  if ($null -ne $Credential) 
  {
    Write-Warning "Using credentials sends password in clear text over the network!"
  }


  $login = $null
  $loginFail = ""

  $EncodedSamlRequest = New-MsIdSamlRequest -Issuer $Issuer -DeflateAndEncode

  [System.UriBuilder] $uriAdfs = 'https://{0}/adfs/ls' -f $HostName
  $uriAdfs.Query = ConvertTo-QueryString @{
      SAMLRequest = $EncodedSamlRequest
  }  

  if ($null -ne $Credential) {
    $user = $Credential.UserName
    $form = New-AdfsLoginFormFields -Credential $Credential
    try{
      $login = Invoke-WebRequest -Uri $uriAdfs.Uri -Method POST -Body $form -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }
  else {
    $userAgent = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT; Windows NT 10.0; en-US)'
    $user = "$($env:USERDOMAIN)\$($env:UserName)"
    try{
      $login = Invoke-WebRequest -Uri $uriAdfs.Uri -UserAgent $userAgent -UseDefaultCredentials -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }



  if ($null -eq $login) { Write-Error "HTTP request failed for issuer ""$($Issuer)"" and user: $($user). ERROR: $($loginFail)" }
  elseif ($login.StatusCode -ne 200) { Write-Error "HTTP request failed for issuer ""$($Issuer)"" and user: $($user). ERROR: HTTP status $($login.StatusCode)" }
  elseif ($login.InputFields.Count -le 0) {
    Write-Warning "Login failed for issuer ""$($Issuer)"" and user: $($user)"
  }
  elseif ($login.InputFields[0].outerHTML.Contains("SAMLResponse")) {
    Write-Host "Login sucessful for issuer ""$($Issuer)"" and user: $($user)"
    return $login.Content | Get-ParsedTokenFromResponse -Protocol SAML
  }
  else { Write-Warning "Login failed for issuer ""$($Issuer)"" and user: $($user)" }

  return
}

#endregion

#region Get-MsIdAdfsWsFedToken.ps1

<#
.SYNOPSIS
    Initiates a Ws-Fed logon request to and AD FS server to generate log activity and returns the user token.
.DESCRIPTION
    This command will generate log activity on the ADFS server, by requesting a Ws-Fed token using the windows or forms authentication.
.EXAMPLE
    PS > Get-MsIdAdfsWsFedToken urn:federation:MicrosoftOnline -HostName adfs.contoso.com

    Sign in to an application on an AD FS server using logged user credentials using the Ws-Fed protocol.

.EXAMPLE
    PS > $credential = Get-Credential
    PS > Get-MsIdAdfsWsFedToken urn:federation:MicrosoftOnline -HostName adfs.contoso.com

    Sign in to an application on an AD FS server using credentials provided by the user using the Ws-Fed endpoint and forms based authentication.

.EXAMPLE
    PS > $WsFedIdentifiers = Get-AdfsRelyingPartyTrust | where { $_.WSFedEndpoint -ne $null -and $_.Identifier -notcontains "urn:federation:MicrosoftOnline" } | foreach { $_.Identifier.Item(0) }
    PS > $WsFedIdentifiers | foreach { Get-MsIdAdfsWsFedToken $_ -HostName adfs.contoso.com }

    Get all Ws-Fed relying party trusts from the AD FS server excluding Azure AD and sign in using the logged user credentials.

#>

function Get-MsIdAdfsWsFedToken 
{
  [CmdletBinding()]
  [OutputType([string])]
  param(
    # Enter the application identifier
    [Parameter(Mandatory=$true,
      Position=0,
      ValueFromPipeline=$true,
      ValueFromPipelineByPropertyName=$true)]
    [string]$WtRealm,
    # Enter host name for the AD FS server
    [Parameter(Mandatory=$true)]
    [string]$HostName,
    # Provide the credential for the user to be signed in
    [Parameter(Mandatory=$false)]
    [pscredential]$Credential
  )

  $login = $null
  $loginFail = ""

  # Defaults to Ws-Fed request
  [System.UriBuilder] $uriAdfs = 'https://{0}/adfs/ls' -f $HostName
  $uriAdfs.Query = ConvertTo-QueryString @{
    'client-request-id' = New-Guid
    wa = 'wsignin1.0'
    wtrealm = $WtRealm
  }  

    
  if ($null -ne $Credential) {
    Write-Warning "Using credentials sends password in clear text over the network!"

    $user = $Credential.UserName
    $form = New-AdfsLoginFormFields -Credential $Credential
    try{
      $login = Invoke-WebRequest -Uri $uriAdfs.Uri -Method POST -Body $form -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }
  else {
    $userAgent = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT; Windows NT 10.0; en-US)'
    $user = "$($env:USERDOMAIN)\$($env:UserName)"
    try{
      $login = Invoke-WebRequest -Uri $uriAdfs.Uri -UserAgent $userAgent -UseDefaultCredentials -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }


  if ($null -eq $login) { Write-Error "HTTP request failed for WtRealm ""$($WtRealm)"" and user: $($user). ERROR: $($loginFail)" }
  elseif ($login.StatusCode -ne 200) { Write-Error "HTTP request failed for WtRealm ""$($WtRealm)"" and user: $($user). ERROR: HTTP status $($login.StatusCode)" }
  elseif ($login.InputFields.Count -le 0) {
    Write-Warning "Login failed for WtRealm ""$($WtRealm)"" and user: $($user)" 
  }
  elseif ($login.InputFields[0].outerHTML.Contains("wsignin1.0")) {
    Write-Host "Login sucessful for WtRealm ""$($WtRealm)"" and user: $($user)"
    return $login.Content | Get-ParsedTokenFromResponse -Protocol WsFed
  }
  else { Write-Warning "Login failed for WtRealm ""$($WtRealm)"" and user: $($user)" }

  return
}

#endregion

#region Get-MsIdAdfsWsTrustToken.ps1

<#
.SYNOPSIS
    Initiates a Ws-Trust logon request to and AD FS server to generate log activity and returns the user token.
.DESCRIPTION
    This command will generate log activity on the ADFS server, by requesting a Ws-Trust token using the windows transport or user name mixed endpoint.
.EXAMPLE
    PS > Get-MsIdAdfsWsTrustToken urn:federation:MicrosoftOnline -HostName adfs.contoso.com
    
    Sign in to an application on an AD FS server using logged user credentials using the WindowsTransport endpoint.

.EXAMPLE
    PS > $credential = Get-Credential
    PS > Get-MsIdAdfsWsTrustToken urn:federation:MicrosoftOnline -HostName adfs.contoso.com -Credential $credential

    Sign in to an application on an AD FS server using credentials provided by the user using the UserNameMixed endpoint.

.EXAMPLE
    PS > $identifiers = Get-AdfsRelyingPartyTrust | foreach { $_.Identifier.Item(0) }
    PS > $identifiers | foreach { Get-MsIdAdfsWsTrustToken $_ -HostName adfs.contoso.com }

    Get all relying party trusts from the AD FS server and sign in using the logged user credentials.

#>

function Get-MsIdAdfsWsTrustToken 
{
  [CmdletBinding()]
  [OutputType([string])]
  param(
    # Enter the application identifier
    [Parameter(Mandatory=$true,
      Position=0,
      ValueFromPipeline=$true,
      ValueFromPipelineByPropertyName=$true)]
    [string]$Identifier,
    # Enter host name for the AD FS server
    [Parameter(Mandatory=$true)]
    [string]$HostName,
    # Provide the credential for the user to be signed in
    [Parameter(Mandatory=$false)]
    [pscredential]$Credential
  )

  $login = $null
  $loginFail = ""

  if ($null -ne $Credential) {
    $user = $Credential.UserName

    [System.UriBuilder] $uriAdfs = 'https://{0}/adfs/services/trust/2005/usernamemixed' -f $HostName

    $wstrustRequest = New-MsIdWsTrustRequest $Identifier -Endpoint $uriAdfs.Uri -Credential $Credential
    try{
      $login = Invoke-WebRequest $uriAdfs.Uri -Method Post -Body $wstrustRequest -ContentType "application/soap+xml" -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }
  else {
    $user = "$($env:USERDOMAIN)\$($env:UserName)"

    [System.UriBuilder] $uriAdfs = 'https://{0}/adfs/services/trust/2005/windowstransport' -f $HostName

    $wstrustRequest = New-MsIdWsTrustRequest $Identifier -Endpoint $uriAdfs.Uri
    try{
      $login = Invoke-WebRequest $uriAdfs.Uri -Method Post -Body $wstrustRequest -ContentType "application/soap+xml" -UseDefaultCredentials -UseBasicParsing -ErrorAction SilentlyContinue
    }
    catch [System.Net.WebException]{
      $loginFail = $_
    }
  }



  if ($null -eq $login) { Write-Error "HTTP request failed for identifier ""$($identifier)"" and user: $($user). ERROR: $($loginFail)" }
  elseif ($login.StatusCode -ne 200) { Write-Error "HTTP request failed for identifier ""$($identifier)"" and user: $($user). ERROR: HTTP status $($login.StatusCode)" }
  elseif ($login.Headers["Content-Type"].Contains("application/soap+xml")) {
      Write-Host "Login sucessful for identifier ""$($Identifier)"" and user: $($user)"
      return $login.Content | ConvertFrom-SamlMessage
  }
  else { Write-Warning "Login failed for identifier ""$($Identifier)"" and user: $($user)" }

  return
}

#endregion

#region Get-MsIdApplicationIdByAppId.ps1

<#
.SYNOPSIS
    Lookup Application Registration by AppId

.EXAMPLE
    PS > Get-MsIdApplicationIdByAppId 10000000-0000-0000-0000-000000000001

    Return the application registration id matching appId, 10000000-0000-0000-0000-000000000001.

.INPUTS
    System.String

#>

function Get-MsIdApplicationIdByAppId {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # AppID of the Application Registration
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [string[]] $AppId
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgApplication' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        foreach ($_AppId in $AppId) {
            ## Filter application registration by appId and return id
            Get-MgApplication -Filter "appId eq '$_AppId'" -Select id | Select-Object -ExpandProperty id
        }
    }
}

#endregion

#region Get-MsIdAuthorityUri.ps1

<#
.SYNOPSIS
    Build Microsoft Identity Provider Authority URI

.EXAMPLE
    PS > Get-MsIdAuthorityUri

    Get common Microsoft authority URI endpoint.

.EXAMPLE
    PS > Get-MsIdAuthorityUri -TenantId contoso.com

    Get Microsoft IdP authority URI endpoint for a specific organizational tenant (Azure AD).

.EXAMPLE
    PS > Get-MsIdAuthorityUri -AzureAd

    Get Microsoft IdP authority URI endpoint for any organizational account (Azure AD).

.EXAMPLE
    PS > Get-MsIdAuthorityUri -Msa

    Get Microsoft IdP authority URI endpoint for any Microsoft consumer account (MSA).

.EXAMPLE
    PS > Get-MsIdAuthorityUri -AzureAdB2c -TenantName contoso -Policy B2C_1_SignUp

    Get Microsoft IdP authority URI endpoint for a specific organization B2C tenant (Azure AD B2C) using the B2C_1_SignUp policy.
    
#>

function Get-MsIdAuthorityUri {
    [CmdletBinding(DefaultParameterSetName = 'Common')]
    [OutputType([string])]
    param (
        # Use endpoint for organizational accounts (Azure AD).
        [Parameter(Mandatory = $true, ParameterSetName = 'AzureAd')]
        [switch] $AzureAd,
        # Use endpoint for organizational B2C accounts (Azure AD B2C).
        [Parameter(Mandatory = $true, ParameterSetName = 'AzureAdB2c')]
        [switch] $AzureAdB2c,
        # Use endpoint for Microsoft consumer accounts (MSA).
        [Parameter(Mandatory = $true, ParameterSetName = 'Msa')]
        [switch] $Msa,
        # Name of Azure AD tenant. For example: <TenantName>.onmicrosoft.com
        [Parameter(Mandatory = $false, ParameterSetName = 'Common')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AzureAd')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AzureAdB2c')]
        [string] $TenantName,
        # Azure AD tenant GUID or verified domain name. For example: contoso.onmicrosoft.com or contoso.com
        [Parameter(Mandatory = $false, ParameterSetName = 'Common')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AzureAd')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AzureAdB2c')]
        [string] $TenantId,
        # Name of B2C Policy defined in Azure AD B2C tenant.
        [Parameter(Mandatory = $true, ParameterSetName = 'AzureAdB2c')]
        [string] $Policy,

        # Type of app integration ('OAuth2','Saml','WsFed'). 'OAuth2' is default.
        [Parameter(Mandatory = $false)]
        [ValidateSet('OAuth2', 'Saml', 'WsFed')]
        [string] $AppType = 'OAuth2',
        # OAuth2 endpoint version ('v1.0','v2.0'). v2.0 is default.
        [Parameter(Mandatory = $false, ParameterSetName = 'Common')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AzureAd')]
        [ValidateSet('v1.0', 'v2.0')]
        [string] $OAuth2EndpointVersion = 'v2.0'
    )

    switch ($PSCmdlet.ParameterSetName) {
        "AzureAdB2c" {
            [uri] $BaseUri = "https://{0}.b2clogin.com/" -f $TenantName
        }
        default {
            [uri] $BaseUri = "https://login.microsoftonline.com/"
        }
    }

    switch ($PSCmdlet.ParameterSetName) {
        "AzureAd" {
            if (!$TenantId) {
                if ($TenantName) { $TenantId = "{0}.onmicrosoft.com" -f $TenantName }
                else { $TenantId = "organizations" }
            }
            break
        }
        "AzureAdB2c" {
            if (!$TenantId) { $TenantId = "{0}.onmicrosoft.com" -f $TenantName }
            break
        }
        "Msa" {
            if (!$TenantId) { $TenantId = "consumers" }
            break
        }
        default {
            if (!$TenantId) {
                if ($TenantName) { $TenantId = "{0}.onmicrosoft.com" -f $TenantName }
                else { $TenantId = "common" }
            }
        }
    }

    $uriMsftIdPAuthority = New-Object System.UriBuilder $BaseUri.AbsoluteUri
    $uriMsftIdPAuthority.Path = '/{0}' -f $TenantId
    if ($PSCmdlet.ParameterSetName -eq 'AzureAdB2c') { $uriMsftIdPAuthority.Path += '/{0}' -f $Policy }
    if ($AppType -eq 'OAuth2' -and $OAuth2EndpointVersion -ne 'v1.0') { $uriMsftIdPAuthority.Path += '/{0}' -f $OAuth2EndpointVersion }
    #if ($Policy) { $uriMsftIdPAuthority.Query = ConvertTo-QueryString @{ p = $Policy } }

    return $uriMsftIdPAuthority.Uri.AbsoluteUri
}

#endregion

#region Get-MsIdAzureIpRange.ps1

<#
.SYNOPSIS
    Get list of IP ranges for Azure

.EXAMPLE
    PS > Get-MsIdAzureIpRange -AllServiceTagsAndRegions

    Get list of IP ranges for Azure Public cloud catagorized by Service Tag and Region.

.EXAMPLE
    PS > Get-MsIdAzureIpRange -ServiceTag AzureActiveDirectory

    Get list of IP ranges for Azure Active Directory in Azure Public Cloud.

.EXAMPLE
    PS > Get-MsIdAzureIpRange -Region WestUS

    Get list of IP ranges for West US region of Azure Public Cloud.

.EXAMPLE
    PS > Get-MsIdAzureIpRange -Cloud China -Region ChinaEast -ServiceTag Storage

    Get list of IP ranges for Storage in ChinaEast region of Azure China Cloud.

.INPUTS
    System.String
    
#>

function Get-MsIdAzureIpRange {
    [CmdletBinding(DefaultParameterSetName = 'ById')]
    [OutputType([PSCustomObject], [string[]])]
    param(
        # Name of Azure Cloud. Valid values are: Public, Government, Germany, China
        [Parameter(Mandatory = $false, Position = 1)]
        [ValidateSet('Public', 'Government', 'Germany', 'China')]
        [string] $Cloud = 'Public',

        # Name of Region. Use AllServiceTagsAndRegions parameter to see valid regions.
        [Parameter(Mandatory = $false, Position = 2, ParameterSetName = 'ById')]
        [ValidateNotNullOrEmpty()]
        [ArgumentCompleter({
                param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters )
                [string] $Cloud = 'Public'  # Default Cloud parameter value
                if ($fakeBoundParameters.ContainsKey('Cloud')) { $Cloud = $fakeBoundParameters.Cloud }
                [string] $ServiceTag = ''  # Default ServiceTag parameter value
                if ($fakeBoundParameters.ContainsKey('ServiceTag')) { $ServiceTag = $fakeBoundParameters.ServiceTag }

                #$StartPosition = $host.UI.RawUI.CursorPosition
                #Write-Host '...' -NoNewline

                [array] $AllServiceTagsAndRegions = Get-MsIdAzureIpRange -Cloud $Cloud -AllServiceTagsAndRegions -Verbose:$false
                #$AllServiceTagsAndRegions.values.properties.region | Select-Object -Unique | Where-Object { $_ }

                $listRegions = New-Object System.Collections.Generic.List[string]
                foreach ($Item in $AllServiceTagsAndRegions.values.name) {
                    if ($Item -like "$ServiceTag*.$wordToComplete*") {
                        $Region = $Item.Split('.')[1]
                        if (!$listRegions.Contains($Region)) { $listRegions.Add($Region) }
                    }
                }

                if ($listRegions) {
                    $listRegions #| ForEach-Object {$_}
                }

                #$host.UI.RawUI.CursorPosition = $StartPosition
                #Write-Host (' ') -NoNewline
            })]
        [string] $Region,

        # Name of Service Tag. Use AllServiceTagsAndRegions parameter to see valid service tags.
        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'ById')]
        [ValidateNotNullOrEmpty()]
        [ArgumentCompleter({
                param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters )
                [string] $Cloud = 'Public'  # Default Cloud parameter value
                if ($fakeBoundParameters.ContainsKey('Cloud')) { $Cloud = $fakeBoundParameters.Cloud }
                [string] $Region = ''  # Default Region parameter value
                if ($fakeBoundParameters.ContainsKey('Region')) { $Region = $fakeBoundParameters.Region }

                #Write-Host '...' -NoNewline
                [array] $AllServiceTagsAndRegions = Get-MsIdAzureIpRange -Cloud $Cloud -AllServiceTagsAndRegions -Verbose:$false
                #$AllServiceTagsAndRegions.values.properties.region | Select-Object -Unique | Where-Object { $_ }

                $listServiceTags = New-Object System.Collections.Generic.List[string]
                foreach ($Item in $AllServiceTagsAndRegions.values.name) {
                    if ($Item -like "$wordToComplete*?$Region*") {
                        $ServiceTag = $Item.Split('.')[0]
                        if (!$listServiceTags.Contains($ServiceTag)) { $listServiceTags.Add($ServiceTag) }
                    }
                }

                if ($listServiceTags) {
                    $listServiceTags #| ForEach-Object {$_}
                }
            })]
        [string] $ServiceTag,

        # List all IP ranges catagorized by Service Tag and Region.
        [Parameter(Mandatory = $false, ParameterSetName = 'AllServiceTagsAndRegions')]
        [switch] $AllServiceTagsAndRegions,

        # Bypass cache and download data again.
        [Parameter(Mandatory = $false)]
        [switch] $ForceRefresh
    )

    ## Get data cache
    if (!(Get-Variable cacheAzureIPRangesAndServiceTags -ErrorAction SilentlyContinue)) { New-Variable -Name cacheAzureIPRangesAndServiceTags -Scope Script -Value (New-Object hashtable) }

    ## Download data and update cache
    if ($ForceRefresh -or !$cacheAzureIPRangesAndServiceTags.ContainsKey($Cloud)) {
        Write-Verbose ('Downloading data for Cloud [{0}].' -f $Cloud)
        [hashtable] $MdcIdCloudMapping = @{
            Public     = 56519
            Government = 57063
            Germany    = 57064
            China      = 57062
        }

        [uri] $MdcUri = 'https://www.microsoft.com/en-us/download/confirmation.aspx?id={0}' -f $MdcIdCloudMapping[$Cloud]
        [uri] $MdcDirectUri = $null  # Example: https://download.microsoft.com/download/7/1/D/71D86715-5596-4529-9B13-DA13A5DE5B63/ServiceTags_Public_20191111.json

        $MdcResponse = Invoke-WebRequest -UseBasicParsing -Uri $MdcUri
        if ($MdcResponse -match 'https://download\.microsoft\.com/download/.+?/ServiceTags_.+?_[0-9]{6,8}\.json') {
            $MdcDirectUri = $Matches[0]
        }

        if ($MdcDirectUri) {
            $cacheAzureIPRangesAndServiceTags[$Cloud] = Invoke-RestMethod -UseBasicParsing -Uri $MdcDirectUri -ErrorAction Stop
        }
    }
    else {
        Write-Verbose ('Using cached data for Cloud [{0}]. Use -ForceRefresh parameter to bypass cache.' -f $Cloud)
    }
    $AzureServiceTagsAndRegions = $cacheAzureIPRangesAndServiceTags[$Cloud]

    ## Return the data
    if ($AllServiceTagsAndRegions) {
        return $AzureServiceTagsAndRegions
    }
    else {
        [string] $Id = 'AzureCloud'
        if ($ServiceTag) {
            $Id = $ServiceTag
        }
        if ($Region) {
            $Id += '.{0}' -f $Region
        }

        $FilteredServiceTagsAndRegions = $AzureServiceTagsAndRegions.values | Where-Object id -EQ $Id
        if ($FilteredServiceTagsAndRegions) {
            return $FilteredServiceTagsAndRegions.properties.addressPrefixes
        }
    }
}

#endregion

#region Get-MsIdAzureUsers.ps1

<#
.SYNOPSIS
    Returns a list of users that have signed into the Azure portal, Azure CLI, or Azure PowerShell over the last 30 days by querying the sign-in logs.

    If your tenant is a [Microsoft Entra ID Free](https://learn.microsoft.com/entra/identity/monitoring-health/reference-reports-data-retention#activity-reports), the sign-in logs need to be downloaded from

    - Required permission scopes: **Directory.Read.All**, **AuditLog.Read.All**
    - Required Microsoft Entra role: **Global Reader**

.DESCRIPTION
    - Entra ID free tenants have access to sign-in logs for the last 7 days.
    - Entra ID premium tenants have access to sign-in logs for the last 30 days.

.EXAMPLE
    PS > Connect-MgGraph -Scopes Directory.Read.All, AuditLog.Read.All
    PS > Get-MsIdAzureUsers

    Queries all available logs and returns all the users that have signed into Azure.

.EXAMPLE
    PS > Get-MsIdAzureUsers -Days 3

    Queries the logs for the last three days and returns all the users that have signed into Azure during this period.

.EXAMPLE
    PS > Get-MsIdAzureUsers -SignInsJsonPath ./signIns.json

    Uses the sign-ins json file downloaded from the Microsoft Portal and returns all the users that have signed into Azure during this period.

#>


function Get-MsIdAzureUsers {
    [CmdletBinding(HelpUri = 'https://azuread.github.io/MSIdentityTools/commands/Get-MsIdAzureUsers')]
    param (
        # Optional. Path to the sign-ins JSON file. If provided, the report will be generated from this file instead of querying the sign-ins.
        [string]
        $SignInsJsonPath,

        # Number of days to query sign-in logs. Defaults to 30 days for premium tenants and 7 days for free tenants
        [ValidateScript({
                $_ -ge 0 -and $_ -le 30
            },
            ErrorMessage = "Logs are only available for the last 7 days for free tenants and 30 days for premium tenants. Please enter a number between 0 and 30."
        )]
        [int]
        $Days
    )

    $mfaEnforcedApps = @(
        @{
            AppId       = "c44b4083-3bb0-49c1-b47d-974e53cbdf3c"
            DisplayName = "Azure Portal"
        },
        @{
            AppId       = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
            DisplayName = "Microsoft Azure CLI"
        },
        @{
            AppId       = "1950a258-227b-4e31-a9cf-717495945fc2"
            DisplayName = "Microsoft Azure PowerShell"
        }
    )

    function Main() {

        if (!(Test-MgModulePrerequisites @('AuditLog.Read.All', 'Directory.Read.All'))) { return }

        if ($SignInsJsonPath) {
            $users = Get-JsonFileContent -SignInsJsonPath $SignInsJsonPath
        }
        else {
            $users = GetAzureUsers $Days
        }

        if ($users) {
            $users.Values
        }
        else {
            return $null
        }
    }

    function GetAzureUsers($pastDays) {

        # Get the date range to query by subtracting the number of days from today set to midnight
        $appFilter = GetAppFilter
        $statusFilter = "and status/errorcode eq 0"
        $dateFilter = GetDateFilter $pastDays

        # Create an array of filter and join with 'and'
        $filter = "$appFilter $statusFilter $dateFilter"
        Write-Verbose "Graph filter: $filter"
        $select = "userId,userPrincipalName,userDisplayName,appId,createdDateTime,authenticationRequirement"

        Write-Progress -Activity "Querying sign-in logs..."

        $earliestDate = GetEarliestDate $filter
        if ($null -eq $earliestDate) {
            Write-Host "No Azure sign-ins found." -ForegroundColor Green
            return
        }

        if ($Days) { $dayDiff = $Days }
        else { $dayDiff = (Get-Date).Subtract($earliestDate).Days }
        Write-Host "Getting sign-in logs for the last $dayDiff days (from $earliestDate to now)..." -ForegroundColor Green
        $graphUri = "$graphBaseUri/beta/auditLogs/signIns?`$select=$select&`$filter=$filter"

        Write-Verbose "Getting sign-in logs $graphUri"
        $resultsJson = Invoke-GraphRequest -Uri $graphUri -Method GET
        $nextLink = Get-ObjectPropertyValue $resultsJson -Property '@odata.nextLink'

        $latestDate = $resultsJson.value[0].createdDateTime
        # Create a key/value dictionary to store users by userId
        $azureUsers = @{}

        $count = 0
        do {
            foreach ($item in $resultsJson.value) {
                $count++
                # Check if user exists in the dictionary and create a new object if not
                [string]$userId = $item.userId
                $user = $azureUsers[$userId]
                if ($null -eq $user) {
                    $user = [pscustomobject]@{
                        UserId                    = $item.userId
                        UserPrincipalName         = $item.userPrincipalName
                        UserDisplayName           = $item.userDisplayName
                        AzureAppName              = ""
                        AzureAppId                = @($item.appId)
                        AuthenticationRequirement = $item.authenticationRequirement
                    }
                    $azureUsers[$userId] = $user
                }
                else {
                    # Add the app if it doesn't already exist
                    if ($user.AzureAppId -notcontains $item.appId) {
                        $user.AzureAppId += $item.appId
                    }
                    # Flag as MFA if user signed in at least once
                    if ($user.AuthenticationRequirement -ne "multiFactorAuthentication" `
                            -and $item.authenticationRequirement -eq "multiFactorAuthentication") {
                        $user.AuthenticationRequirement = $item.authenticationRequirement
                    }
                }
            }

            if ($null -ne $nextLink) {
                $latestProcessedDate = $resultsJson.value[$resultsJson.value.Count - 1].createdDateTime
                $percent = GetProgressPercent $earliestDate $latestDate $latestProcessedDate
                Write-Verbose $percent
                $formattedDate = GetDateDisplayFormat $latestProcessedDate
                $status = "Found $($azureUsers.Count) Azure users. Now processing $formattedDate ($([int]$percent)% completed)"
                Write-Progress -Activity "Checking sign-in logs" -Status $status -PercentComplete $percent
                $resultsJson = Invoke-GraphRequest -Uri $nextLink
            }
            $nextLink = Get-ObjectPropertyValue $resultsJson -Property '@odata.nextLink'
        } while ($null -ne $nextLink)

        # Update the Azure App name for each user
        foreach ($user in $azureUsers.Values) {
            $appNames = @()
            foreach ($appId in $user.AzureAppId) {
                $app = $mfaEnforcedApps | Where-Object { $_.AppId -eq $appId }
                if ($app) {
                    $appNames += $app.DisplayName
                }
            }
            $user.AzureAppName = $appNames -join ", "
        }
        return $azureUsers
    }

    function GetProgressPercent($earliestDate, $latestDate, $processedDate) {
        Write-Verbose "Earliest date: $earliestDate"
        Write-Verbose "Processed date: $processedDate"
        $totalSeconds = ($latestDate - $earliestDate).TotalSeconds
        $processedSeconds = ($latestDate - $processedDate).TotalSeconds
        $percent = ($processedSeconds / $totalSeconds) * 100
        return $percent
    }

    function GetEarliestDate($filter) {

        $graphUri = "$graphBaseUri/beta/auditLogs/signIns?`$select=createdDateTime&`$filter=$filter&`$top=1&`$orderby=createdDateTime asc"

        Write-Verbose "Getting earliest date in logs $graphUri"
        $resultsJson = Invoke-GraphRequest -Uri $graphUri -Method GET -SkipHttpErrorCheck

        $err = Get-ObjectPropertyValue $resultsJson -Property "error"
        if ($err) {
            if ($err.code -eq "Authentication_RequestFromUnsupportedUserRole") {
                Write-Host "The signed-in user needs to be assigned the Microsoft Entra Global Reader role." -ForegroundColor Green
            }
            elseif ($err.code -eq "Authentication_RequestFromNonPremiumTenantOrB2CTenant") {
                Write-Host "You are using an Entra ID Free tenant which requires additional steps to download the sign-in logs." -ForegroundColor Green
                Write-Host
                Write-Host "Follow these steps to download the sign-in logs." -ForegroundColor Green
                Write-Host "- Sign-in to https://entra.microsoft.com" -ForegroundColor Green
                Write-Host "- From the left navigation select: Identity → Monitoring & health → Sign-in logs." -ForegroundColor Green
                Write-Host "- Select the 'Date' filter and set to 'Last 7 days'" -ForegroundColor Green
                Write-Host "- Select 'Add filters' → 'Application' and click 'Apply'" -ForegroundColor Green
                Write-Host "- Type in 'Azure' and click 'Apply'" -ForegroundColor Green
                Write-Host "- Select 'Download' → 'Download JSON'" -ForegroundColor Green
                Write-Host "- Set the 'File Name' of the first textbox to 'signins' and click 'Download'." -ForegroundColor Green
                Write-Host "- Once the file is downloaded, copy it to the folder where the export command will be run." -ForegroundColor Green
                Write-Host
                Write-Host "Re-run this command with the -SignInsJsonPath parameter." -ForegroundColor Green
                Write-Host "E.g.> Export-MsIdAzureMfaReport ./report.xlsx -SignInsJsonPath ./signins.json" -ForegroundColor Yellow
            }
            Write-Error $err.message -ErrorAction Stop
        }

        $minDate = $null
        if ($resultsJson.value.Count -ne 0) {
            $minDate = $resultsJson.value[0].createdDateTime
        }

        return $minDate
    }

    function GetDateFilter($pastDays) {
        # Get the date range to query by subtracting the number of days from today set to midnight
        $dateFilter = $null
        if ($pastDays -and $pastDays -gt 0) {
            $dateStart = (Get-Date -Hour 0 -Minute 0 -Second 0).AddDays(-$pastDays)

            # convert the date to the correct format
            $tmzFormat = "yyyy-MM-ddTHH:mm:ssZ"
            $dateStartString = $dateStart.ToString($tmzFormat)

            $dateFilter = "and createdDateTime ge $dateStartString"
        }
        return $dateFilter
    }

    function GetAppFilter() {
        $allAppFilter = $mfaEnforcedApps.AppId -join "' or appid eq '"
        $allAppFilter = "(appid eq '$allAppFilter')"
        return $allAppFilter
    }

    function Get-JsonFileContent ($signInsJsonPath) {
        Write-Verbose "Reading sign-ins from $signInsJsonPath"
        $signIns = Get-Content $signInsJsonPath -Raw | ConvertFrom-Json

        $azureUsers = @{}
        $count = 0

        foreach ($item in $signIns) {
            $count++
            # Check if user exists in the dictionary and create a new object if not
            [string]$userId = $item.userId
            $user = $azureUsers[$userId]
            if ($null -eq $user) {
                $user = [pscustomobject]@{
                    UserId                    = $item.userId
                    UserPrincipalName         = $item.userPrincipalName
                    UserDisplayName           = $item.userDisplayName
                    AzureAppName              = ""
                    AzureAppId                = @($item.appId)
                    AuthenticationRequirement = $item.authenticationRequirement
                }
                $azureUsers[$userId] = $user
            }
            else {
                # Add the app if it doesn't already exist
                if ($user.AzureAppId -notcontains $item.appId) {
                    $user.AzureAppId += $item.appId
                }
                # Flag as MFA if user signed in at least once
                if ($user.AuthenticationRequirement -ne "multiFactorAuthentication" `
                        -and $item.authenticationRequirement -eq "multiFactorAuthentication") {
                    $user.AuthenticationRequirement = $item.authenticationRequirement
                }
            }
        }

        # Update the Azure App name for each user
        foreach ($user in $azureUsers.Values) {
            $appNames = @()
            foreach ($appId in $user.AzureAppId) {
                $app = $mfaEnforcedApps | Where-Object { $_.AppId -eq $appId }
                if ($app) {
                    $appNames += $app.DisplayName
                }
            }
            $user.AzureAppName = $appNames -join ", "
        }
        return $azureUsers
    }

    function WriteExportProgress(
        # The current step of the overal generation
        [ValidateSet("Logs")]
        $MainStep,
        $Status = "Processing...",
        # The percentage of completion within the child step
        $ChildPercent,
        [switch]$ForceRefresh) {
        $percent = 0
        switch ($MainStep) {
            "Logs" {
                $percent = GetNextPercent $ChildPercent 0 100
                $activity = "Checking sign-in logs"
            }
        }

        if ($ForceRefresh.IsPresent) {
            Start-Sleep -Milliseconds 250
        }
        Write-Progress -Id 0 -Activity $activity -PercentComplete $percent -Status $Status
    }
    function GetNextPercent($childPercent, $parentPercent, $nextPercent) {
        if ($childPercent -eq 0) { return $parentPercent }

        $gap = $nextPercent - $parentPercent
        return (($childPercent / 100) * $gap) + $parentPercent
    }

    function GetDateDisplayFormat($date) {
        return $date.ToString("dd MMM yyyy h:00 tt")
    }

    $graphBaseUri = Get-GraphBaseUri
    # Call main function
    Main
}

#endregion

#region Get-MsIdCrossTenantAccessActivity.ps1

<#
.SYNOPSIS
    Gets cross tenant user sign-in activity

.DESCRIPTION
    Gets user sign-in activity associated with external tenants. By default, shows both connections
    from local users access an external tenant (outbound), and external users accessing the local
    tenant (inbound).

    Has a parameter, -AccessDirection, to further refine results, using the following values:

      * Outboud - lists sign-in events of external tenant IDs accessed by local users
      * Inbound - list sign-in events of external tenant IDs of external users accessing local tenant

    Has a parameter, -ExternalTenantId, to target a single external tenant ID.

    Has a switch, -SummaryStats, to show summary statistics for each external tenant. This also works
    when targeting a single tenant. It is best to use this with Format-Table and Out-Gridview to ensure
    a table is produced.

    Has a switch, -ResolvelTenantId, to return additional details on the external tenant ID.

    -Verbose will give insight into the cmdlets activities.

    Requires AuditLog.Read.All scope (to access logs) and CrossTenantInfo.ReadBasic.All scope
    (for -ResolveTenantId), i.e. Connect-MgGraph -Scopes AuditLog.Read.All


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity

    Gets all available sign-in events for external users accessing resources in the local tenant and
    local users accessing resources in an external tenant.

    Lists by external tenant ID.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -ResolveTenantId -Verbose

    Gets all available sign-in events for external users accessing resources in the local tenant and
    local users accessing resources in an external tenant.

    Lists by external tenant ID. Attempts to resolve the external tenant ID GUID.

    Provides verbose output for insight into the cmdlet's execution.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -SummaryStats | Format-Table

    Provides a summary for sign-in information for the external tenant 3ce14667-9122-45f5-bcd4-f618957d9ba1, for both external
    users accessing resources in the local tenant and local users accessing resources in an external tenant.

    Use Format-Table to ensure a table is returned.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -ExternalTenantId 3ce14667-9122-45f5-bcd4-f618957d9ba1

    Gets all available sign-in events for local users accessing resources in the external tenant 3ce14667-9122-45f5-bcd4-f618957d9ba1,
    and external users from tenant 3ce14667-9122-45f5-bcd4-f618957d9ba1 accessing resources in the local tenant.

    Lists by targeted external tenant.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Outbound

    Gets all available sign-in events for local users accessing resources in an external tenant.

    Lists by unique external tenant.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Outbound -Verbose

    Gets all available sign-in events for local users accessing resources in an external tenant.

    Lists by unique external tenant.

    Provides verbose output for insight into the cmdlet's execution.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Outbound -SummaryStats -ResolveTenantId

    Provides a summary of sign-ins for local users accessing resources in an external tenant.

    Attempts to resolve the external tenant ID GUID.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Outbound -ExternalTenantId 3ce14667-9122-45f5-bcd4-f618957d9ba1

    Gets all available sign-in events for local users accessing resources in the external tenant 3ce14667-9122-45f5-bcd4-f618957d9ba1.

    Lists by unique external tenant.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Inbound

    Gets all available sign-in events for external users accessing resources in the local tenant.

    Lists by unique external tenant.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Inbound -Verbose

    Gets all available sign-in events for external users accessing resources in the local tenant.

    Lists by unique external tenant.

    Provides verbose output for insight into the cmdlet's execution.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Inbound -SummaryStats | Out-Gridview

    Provides a summary of sign-ins for external users accessing resources in the local tenant.

    Use Out-Gridview to display a table in the Out-Gridview window.


.EXAMPLE
    Get-MsIdCrossTenantAccessActivity -AccessDirection Inbound -ExternalTenantId 3ce14667-9122-45f5-bcd4-f618957d9ba1

    Gets all available sign-in events for external user from external tenant 3ce14667-9122-45f5-bcd4-f618957d9ba1 accessing
    resources in the local tenant.

    Lists by unique external tenant.


.NOTES
    THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.

    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.


#>

function Get-MsIdCrossTenantAccessActivity {

    [CmdletBinding()]
    param(

        #Return events based on external tenant access direction, either 'Inbound', 'Outbound', or 'Both'
        [Parameter(Position = 0)]
        [ValidateSet('Inbound', 'Outbound')]
        [string]$AccessDirection,

        #Return events for the supplied external tenant ID
        [Parameter(Position = 1)]
        [guid]$ExternalTenantId,

        #Show summary statistics by tenant
        [switch]$SummaryStats,

        #Atemmpt to resolve the external tenant ID
        [switch]$ResolveTenantId

    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgBetaAuditLogSignIn' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }

        #External Tenant ID check

        if ($ExternalTenantId) {

            Write-Verbose -Message "$(Get-Date -f T) - Checking supplied external tenant ID - $ExternalTenantId..."

            if ($ExternalTenantId -eq (Get-MgContext).TenantId) {

                Write-Error "$(Get-Date -f T) - Supplied external tenant ID ($ExternalTenantId) cannot match connected tenant ID ($((Get-MgContext).TenantId)))" -ErrorAction Stop

            }
            else {

                Write-Verbose -Message "$(Get-Date -f T) - Supplied external tenant ID OK"
            }

        }

    }

    process {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        #Get filtered sign-in logs and handle parameters

        if ($AccessDirection -eq "Outbound") {

            if ($ExternalTenantId) {

                Write-Verbose -Message "$(Get-Date -f T) - Access direction 'Outbound' selected"
                Write-Verbose -Message "$(Get-Date -f T) - Outbound: getting sign-ins for local users accessing external tenant ID - $ExternalTenantId"

                $SignIns = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and ResourceTenantId eq '{0}'" -f $ExternalTenantId) -All | Group-Object ResourceTenantID

            }
            else {

                Write-Verbose -Message "$(Get-Date -f T) - Access direction 'Outbound' selected"
                Write-Verbose -Message "$(Get-Date -f T) - Outbound: getting external tenant IDs accessed by local users"

                $SignIns = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and ResourceTenantId ne '{0}'" -f (Get-MgContext).TenantId) -All | Group-Object ResourceTenantID

            }

        }
        elseif ($AccessDirection -eq 'Inbound') {

            if ($ExternalTenantId) {

                Write-Verbose -Message "$(Get-Date -f T) - Access direction 'Inbound' selected"
                Write-Verbose -Message "$(Get-Date -f T) - Inbound: getting sign-ins for users accessing local tenant from external tenant ID - $ExternalTenantId"

                $SignIns = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and HomeTenantId eq '{0}' and TokenIssuerType eq 'AzureAD'" -f $ExternalTenantId) -All | Group-Object HomeTenantID

            }
            else {

                Write-Verbose -Message "$(Get-Date -f T) - Access direction 'Inbound' selected"
                Write-Verbose -Message "$(Get-Date -f T) - Inbound: getting external tenant IDs for external users accessing local tenant"

                $SignIns = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and HomeTenantId ne '{0}' and TokenIssuerType eq 'AzureAD'" -f (Get-MgContext).TenantId) -All | Group-Object HomeTenantID

            }

        }
        else {

            if ($ExternalTenantId) {

                Write-Verbose -Message "$(Get-Date -f T) - Default access direction 'Both'"
                Write-Verbose -Message "$(Get-Date -f T) - Outbound: getting sign-ins for local users accessing external tenant ID - $ExternalTenantId"

                $Outbound = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and ResourceTenantId eq '{0}'" -f $ExternalTenantId) -All | Group-Object ResourceTenantID


                Write-Verbose -Message "$(Get-Date -f T) - Inbound: getting sign-ins for users accessing local tenant from external tenant ID - $ExternalTenantId"

                $Inbound = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and HomeTenantId eq '{0}' and TokenIssuerType eq 'AzureAD'" -f $ExternalTenantId) -All | Group-Object HomeTenantID


            }
            else {

                Write-Verbose -Message "$(Get-Date -f T) - Default access direction 'Both'"
                Write-Verbose -Message "$(Get-Date -f T) - Outbound: getting external tenant IDs accessed by local users"

                $Outbound = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and ResourceTenantId ne '{0}'" -f (Get-MgContext).TenantId) -All | Group-Object ResourceTenantID


                Write-Verbose -Message "$(Get-Date -f T) - Inbound: getting external tenant IDs for external users accessing local tenant"

                $Inbound = Get-MgBetaAuditLogSignIn -Filter ("CrossTenantAccessType ne 'none' and HomeTenantId ne '{0}' and TokenIssuerType eq 'AzureAD'" -f (Get-MgContext).TenantId) -All | Group-Object HomeTenantID



            }

            #Combine outbound and inbound results

            [array]$SignIns = $Outbound
            $SignIns += $Inbound



        }


        #Analyse sign-in logs

        Write-Verbose -Message "$(Get-Date -f T) - Checking for sign-ins..."

        if ($SignIns) {

            Write-Verbose -Message "$(Get-Date -f T) - Sign-ins obtained"
            Write-Verbose -Message "$(Get-Date -f T) - Iterating Sign-ins..."

            foreach ($TenantID in $SignIns) {

                #Handle resolving tenant ID

                if ($ResolveTenantId) {

                    Write-Verbose -Message "$(Get-Date -f T) - Attempting to resolve external tenant - $($TenantId.Name)"

                    #Nullify $ResolvedTenant value

                    $ResolvedTenant = $null


                    #Attempt to resolve tenant ID

                    try { $ResolvedTenant = Resolve-MsIdTenant -TenantId $TenantId.Name -ErrorAction Stop }
                    catch { Write-Warning $_.Exception.Message; Write-Verbose -Message "$(Get-Date -f T) - Issue resolving external tenant - $($TenantId.Name)" }

                    if ($ResolvedTenant) {

                        if ($ResolvedTenant.Result -eq 'Resolved') {

                            $ExternalTenantName = $ResolvedTenant.DisplayName
                            $DefaultDomainName = $ResolvedTenant.DefaultDomainName


                        }
                        else {

                            $ExternalTenantName = $ResolvedTenant.Result
                            $DefaultDomainName = $ResolvedTenant.Result

                        }

                        if ($ResolvedTenant.oidcMetadataResult -eq 'Resolved') {

                            $oidcMetadataTenantRegionScope = $ResolvedTenant.oidcMetadataTenantRegionScope

                        }
                        else {

                            $oidcMetadataTenantRegionScope = 'NotFound'

                        }

                    }
                    else {

                        $ExternalTenantName = "NotFound"
                        $DefaultDomainName = "NotFound"
                        $oidcMetadataTenantRegionScope = 'NotFound'


                    }

                }

                #Handle access direction

                if (($AccessDirection -eq 'Inbound') -or ($AccessDirection -eq 'Outbound')) {

                    $Direction = $AccessDirection

                }
                else {

                    if ($TenantID.Name -eq $TenantID.Group[0].HomeTenantId) {

                        $Direction = "Inbound"

                    }
                    elseif ($TenantID.Name -eq $TenantID.Group[0].ResourceTenantId) {

                        $Direction = "Outbound"

                    }

                }

                #Provide summary

                if ($SummaryStats) {

                    Write-Verbose -Message "$(Get-Date -f T) - Creating summary stats for external tenant - $($TenantId.Name)"

                    #Handle resolving tenant ID

                    if ($ResolveTenantId) {

                        $Analysis = [pscustomobject]@{

                            ExternalTenantId          = $TenantId.Name
                            ExternalTenantName        = $ExternalTenantName
                            ExternalTenantRegionScope = $oidcMetadataTenantRegionScope
                            AccessDirection           = $Direction
                            SignIns                   = ($TenantId).count
                            SuccessSignIns            = ($TenantID.Group.Status | Where-Object { $_.ErrorCode -eq 0 } | Measure-Object).count
                            FailedSignIns             = ($TenantID.Group.Status | Where-Object { $_.ErrorCode -ne 0 } | Measure-Object).count
                            UniqueUsers               = ($TenantID.Group | Select-Object UserId -Unique | Measure-Object).count
                            UniqueResources           = ($TenantID.Group | Select-Object ResourceId -Unique | Measure-Object).count

                        }

                    }
                    else {

                        #Build custom output object

                        $Analysis = [pscustomobject]@{

                            ExternalTenantId = $TenantId.Name
                            AccessDirection  = $Direction
                            SignIns          = ($TenantId).count
                            SuccessSignIns   = ($TenantID.Group.Status | Where-Object { $_.ErrorCode -eq 0 } | Measure-Object).count
                            FailedSignIns    = ($TenantID.Group.Status | Where-Object { $_.ErrorCode -ne 0 } | Measure-Object).count
                            UniqueUsers      = ($TenantID.Group | Select-Object UserId -Unique | Measure-Object).count
                            UniqueResources  = ($TenantID.Group | Select-Object ResourceId -Unique | Measure-Object).count

                        }


                    }

                    Write-Verbose -Message "$(Get-Date -f T) - Adding stats for $($TenantId.Name) to total analysis object"

                    [array]$TotalAnalysis += $Analysis

                }
                else {

                    #Get individual events by external tenant

                    Write-Verbose -Message "$(Get-Date -f T) - Getting individual sign-in events for external tenant - $($TenantId.Name)"


                    foreach ($Event in $TenantID.group) {


                        if ($ResolveTenantId) {

                            $CustomEvent = [pscustomobject]@{

                                ExternalTenantId          = $TenantId.Name
                                ExternalTenantName        = $ExternalTenantName
                                ExternalDefaultDomain     = $DefaultDomainName
                                ExternalTenantRegionScope = $oidcMetadataTenantRegionScope
                                AccessDirection           = $Direction
                                UserDisplayName           = $Event.UserDisplayName
                                UserPrincipalName         = $Event.UserPrincipalName
                                UserId                    = $Event.UserId
                                UserType                  = $Event.UserType
                                CrossTenantAccessType     = $Event.CrossTenantAccessType
                                AppDisplayName            = $Event.AppDisplayName
                                AppId                     = $Event.AppId
                                ResourceDisplayName       = $Event.ResourceDisplayName
                                ResourceId                = $Event.ResourceId
                                SignInId                  = $Event.Id
                                CreatedDateTime           = $Event.CreatedDateTime
                                StatusCode                = $Event.Status.Errorcode
                                StatusReason              = $Event.Status.FailureReason


                            }

                            $CustomEvent

                        }
                        else {

                            $CustomEvent = [pscustomobject]@{

                                ExternalTenantId      = $TenantId.Name
                                AccessDirection       = $Direction
                                UserDisplayName       = $Event.UserDisplayName
                                UserPrincipalName     = $Event.UserPrincipalName
                                UserId                = $Event.UserId
                                UserType              = $Event.UserType
                                CrossTenantAccessType = $Event.CrossTenantAccessType
                                AppDisplayName        = $Event.AppDisplayName
                                AppId                 = $Event.AppId
                                ResourceDisplayName   = $Event.ResourceDisplayName
                                ResourceId            = $Event.ResourceId
                                SignInId              = $Event.Id
                                CreatedDateTime       = $Event.CreatedDateTime
                                StatusCode            = $Event.Status.Errorcode
                                StatusReason          = $Event.Status.FailureReason


                            }

                            $CustomEvent

                        }


                    }


                }

            }

        }
        else {

            Write-Warning "$(Get-Date -f T) - No sign-ins matching the selected criteria found."

        }

        #Display summary table

        if ($SummaryStats) {

            #Show array of summary objects for each external tenant

            Write-Verbose -Message "$(Get-Date -f T) - Displaying total analysis object"

            if (!$AccessDirection) {

                $TotalAnalysis | Sort-Object ExternalTenantId

            }
            else {

                $TotalAnalysis | Sort-Object SignIns -Descending

            }

        }


    }

    end {
        if ($CriticalError) { return }
    }
}

#endregion

#region Get-MsIdGroupWithExpiration.ps1

<#
.SYNOPSIS
    Return groups with an expiration date via lifecycle policy.

.EXAMPLE
    PS > Get-MsIdGroupWithExpiration | ft Id,DisplayName,ExpirationDateTime,RenewedDateTime

    Return all groups with an expiration date.

.EXAMPLE
    PS > Get-MsIdGroupWithExpiration -After (Get-Date).AddDays(-30) -Before (Get-Date).AddDays(30) | ft Id,DisplayName,ExpirationDateTime,RenewedDateTime

    Return all groups with an expiration date between 30 days before today and 30 days after today.

.EXAMPLE
    PS > Get-MsIdGroupWithExpiration -Days 30 | ft Id,DisplayName,ExpirationDateTime,RenewedDateTime

    Return all groups with an expiration date between now and 30 days from now.

.INPUTS
    None

#>

function Get-MsIdGroupWithExpiration {
    [CmdletBinding(DefaultParameterSetName = 'DateTimeSpan')]
    param (
        # Numbers of days
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'Days')]
        [int] $Days,
        # Start of DateTime range
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'DateTimeSpan')]
        [datetime] $After = [datetime]::MinValue,
        # End of DateTime range
        [Parameter(Mandatory = $false, Position = 1, ParameterSetName = 'DateTimeSpan')]
        [datetime] $Before = [datetime]::MaxValue
    )

    ## Initialize Critical Dependencies
    if (!(Test-MgCommandPrerequisites 'Get-MgGroup' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }

    if ($PSCmdlet.ParameterSetName -eq 'Days') {
        [datetime] $After = Get-Date
        [datetime] $Before = $After.AddDays($Days)
    }

    ## Filter for groups with an expiration date
    Get-MgGroup -Filter "expirationDateTime ge $($After.ToUniversalTime().ToString('o')) and expirationDateTime le $($Before.ToUniversalTime().ToString('o'))" -All -CountVariable MgCount -ConsistencyLevel eventual | Sort-Object expirationDateTime
}

#endregion

#region Get-MsIdMsftIdentityAssociation.ps1

<#
.SYNOPSIS
    Parse Microsoft Identity Association Configuration for a Public Domain (such as published apps)

.EXAMPLE
    PS > Get-MsIdMsftIdentityAssociation https://contoso.com/
    
    Get Microsoft Identity Association Configuration for contoso domain.

.INPUTS
    System.Uri

#>

function Get-MsIdMsftIdentityAssociation {
    [CmdletBinding()]
    [OutputType([PsCustomObject[]])]
    param (
        # Publisher Domain. For example: https://contoso.com/
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [uri] $Publisher
    )
    ## Build common OpenId provider configuration URI
    $uriMsftIdentityAssociation = New-Object System.UriBuilder $Publisher.AbsoluteUri
    if (!$uriMsftIdentityAssociation.Path.EndsWith('/.well-known/microsoft-identity-association.json')) { $uriMsftIdentityAssociation.Path += '/.well-known/microsoft-identity-association.json' }

    ## Download and parse configuration
    $MsftIdentityAssociation = Invoke-RestMethod -UseBasicParsing -Uri $uriMsftIdentityAssociation.Uri.AbsoluteUri  # Should return ContentType 'application/json'
    return $MsftIdentityAssociation
}

#endregion

#region Get-MsIdO365Endpoints.ps1

<#
.SYNOPSIS
    Get list of URLs and IP ranges for O365
    
.DESCRIPTION
    http://aka.ms/ipurlws

.EXAMPLE
    PS > Get-MsIdO365Endpoints

    Get list of URLs and IP ranges for O365 Worldwide cloud.

.EXAMPLE
    PS > Get-MsIdO365Endpoints -Cloud China -ServiceAreas Exchange,SharePoint

    Get list of URLs and IP ranges for Exchange and SharePoint in O365 China Cloud.

.EXAMPLE
    PS > Get-MsIdO365Endpoints -Cloud Worldwide -ServiceAreas Common | Where-Object id -In 54,56,59,96

    Get list of URLs and IP ranges related to Azure Active Directory.

.INPUTS
    System.String

#>

function Get-MsIdO365Endpoints {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        # Name of O365 Cloud. Valid values are: 'Worldwide','USGovGCCHigh','USGovDoD','Germany','China'
        [Parameter(Mandatory = $false, Position = 1)]
        [ValidateSet('Worldwide', 'USGovGCCHigh', 'USGovDoD', 'Germany', 'China')]
        [string] $Cloud = 'Worldwide',
        # Office 365 tenant name.
        [Parameter(Mandatory = $false)]
        [string] $TenantName,
        # Exclude IPv6 addresses from the output
        [Parameter(Mandatory = $false)]
        [switch] $NoIPv6,
        # Name of Service Area.
        [Parameter(Mandatory = $false)]
        [ValidateSet('Common', 'Exchange', 'SharePoint', 'Skype')]
        [string[]] $ServiceAreas,
        # Client Request Id.
        [Parameter(Mandatory = $false)]
        [guid] $ClientRequestId = (New-Guid)
    )

    [hashtable] $EndpointsParameters = @{
        clientrequestid = $ClientRequestId
    }
    if ($TenantName) { $EndpointsParameters.Add('TenantName', $TenantName) }
    if ($NoIPv6) { $EndpointsParameters.Add('NoIPv6', $NoIPv6) }
    if ($ServiceAreas) { $EndpointsParameters.Add('ServiceAreas', ($ServiceAreas -join ',')) }

    [System.UriBuilder] $O365EndpointsUri = 'https://endpoints.office.com/endpoints/{0}' -f $Cloud
    $O365EndpointsUri.Query = ConvertTo-QueryString $EndpointsParameters

    $O365Endpoints = Invoke-RestMethod -UseBasicParsing -Uri $O365EndpointsUri.Uri -ErrorAction Stop
    return $O365Endpoints
}

#endregion

#region Get-MsIdOpenIdProviderConfiguration.ps1

<#
.SYNOPSIS
    Parse OpenId Provider Configuration and Keys
    
.EXAMPLE
    PS > Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com | Get-MsIdOpenIdProviderConfiguration

    Get OpenId Provider Configuration for a specific Microsoft organizational tenant (Azure AD).

.EXAMPLE
    PS > Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com | Get-MsIdOpenIdProviderConfiguration -Keys

    Get public keys for OpenId Provider for a specific Microsoft organizational tenant (Azure AD).

.EXAMPLE
    PS > Get-MsIdAuthorityUri -Msa | Get-MsIdOpenIdProviderConfiguration

    Get OpenId Provider Configuration for Microsoft consumer accounts (MSA).

.INPUTS
    System.Uri

#>

function Get-MsIdOpenIdProviderConfiguration {
    [CmdletBinding()]
    [OutputType([PsCustomObject[]])]
    param (
        # Identity Provider Authority URI
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [uri] $Issuer,
        # Return configuration keys
        [Parameter(Mandatory = $false)]
        [switch] $Keys
    )

    process {
        Get-OpenIdProviderConfiguration @PSBoundParameters
    }
}

#endregion

#region Get-MsIdSamlFederationMetadata.ps1

<#
.SYNOPSIS
    Parse Federation Metadata
    
.EXAMPLE
    PS > Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com -AppType 'Saml' | Get-MsIdSamlFederationMetadata

    Get SAML or WS-Fed Federation Metadata for a specific Microsoft tenant.

.EXAMPLE
    PS > Get-MsIdAuthorityUri -TenantId tenant.onmicrosoft.com -AppType 'Saml' | Get-MsIdSamlFederationMetadata -AppId 00000000-0000-0000-0000-000000000000

    Get SAML or WS-Fed Federation Metadata for a specific application within a specific Microsoft tenant.

.EXAMPLE
    PS > Get-MsIdSamlFederationMetadata 'https://adfs.contoso.com'

    Get SAML or WS-Fed Federation Metadata for an ADFS farm.

.INPUTS
    System.Uri

#>

function Get-MsIdSamlFederationMetadata {
    [CmdletBinding()]
    [Alias('Get-MsIdWsFedFederationMetadata')]
    [OutputType([xml], [System.Xml.XmlElement[]])]
    param (
        # Identity Provider Authority URI
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [uri] $Issuer,
        # Azure AD Application Id
        [Parameter(Mandatory = $false, Position = 2)]
        [guid] $AppId
    )

    process {
        Get-SamlFederationMetadata @PSBoundParameters
    }
}

#endregion

#region Get-MsIdServicePrincipalIdByAppId.ps1

<#
.SYNOPSIS
    Lookup Service Principal by AppId

.EXAMPLE
    PS > Get-MsIdServicePrincipalIdByAppId 10000000-0000-0000-0000-000000000001

    Return the service principal id matching appId, 10000000-0000-0000-0000-000000000001.

.INPUTS
    System.String

#>

function Get-MsIdServicePrincipalIdByAppId {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # AppID of the Service Principal
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [string[]] $AppId
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgServicePrincipal' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        foreach ($_AppId in $AppId) {
            ## Filter service principals by appId and return id
            Get-MgServicePrincipal -Filter "appId eq '$_AppId'" -Select id | Select-Object -ExpandProperty id
        }
    }
}

#endregion

#region Get-MsIdUnmanagedExternalUser.ps1

<#
.SYNOPSIS
    Returns a list of all the external users in the tenant that are unmanaged (viral users).

.EXAMPLE
    PS > Get-MsIdUnmanagedExternalUser

    Gets a list of all the unmanaged/viral external users.

.EXAMPLE
    PS > Get-MsIdUnmanagedExternalUser -Type ExternalAzureADViral

    Gets a list of all the unmanaged/viral external users. This is the same as running Get-MsIdUnmanagedExternalUser without any parameters.

.EXAMPLE
    PS > Get-MsIdUnmanagedExternalUser -Type MicrosoftAccount

    Gets a list of all the external users with a personal Microsoft Account.

.EXAMPLE
    PS > Get-MsIdUnmanagedExternalUser -Type All

    Gets a list of all the external users that are from an unmanaged/viral tenant or have a personal Microsoft Account.

#>

function Get-MsIdUnmanagedExternalUser {
    [CmdletBinding()]

    param (
        # The type of unmanaged user to return
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)]
        [ValidateSet("ExternalAzureADViral", "MicrosoftAccount", "All")]
        [string] $Type = "ExternalAzureADViral"
    )

    ## Initialize Critical Dependencies
    $CriticalError = $null
    if (!(Test-MgCommandPrerequisites 'Get-MgUser' -MinimumVersion 2.8.0 -RequireListPermissions -ErrorVariable CriticalError)) { return }

    $graphBaseUri = "https://graph.microsoft.com/beta"
    $pageCount = 999
    $guestUserUri = $graphBaseUri + "/users?`$filter=userType eq 'Guest'&`$select=id,userPrincipalName,mail,displayName,identities&`$count=true&`$top=$pageCount"

    $results = Invoke-MgGraphRequest -Uri $guestUserUri -Headers @{ ConsistencyLevel = 'eventual' }
    $count = Get-ObjectPropertyValue $results '@odata.count'
    $currentPage = 0
    $hasMoreData = $true
    $userIndex = 1

    #Declare $user as GraphUser object

    if ($count -eq 0) {
        Write-Host "No guest users in this tenant."
    }
    elseif ($count -gt 0) {
        while ($hasMoreData) {
            $percentCompleted = $currentPage * $pageCount / $count * 100
            $currentPage += 1
            Write-Progress -Activity "Checking Guest Users" -PercentComplete $percentCompleted

            foreach ($userObject in (Get-ObjectPropertyValue $results 'value')) {
                $user = $userObject

                Write-Verbose "$userIndex / $count"
                $userIndex += 1

                $isAzureAdUser = $false
                $isMsaUser = $false
                $mail = Get-ObjectPropertyValue $user 'mail'

                foreach ($identity in (Get-ObjectPropertyValue $user 'identities')) {
                    $issuer = Get-ObjectPropertyValue $identity 'issuer'
                    Write-Verbose "$($mail) Issuer = $($issuer) [$($user.userPrincipalName)]"
                    switch ($issuer) {
                        'ExternalAzureAD' { $isAzureAdUser = $true }
                        'MicrosoftAccount' { $isMsaUser = $true }
                    }
                }

                $isViralUser = $false
                if($Type -eq 'ExternalAzureADViral' -or $Type -eq 'All')
                {
                    if ($isAzureAdUser) {

                        Write-Verbose "Checking if user $($mail) is viral user. [$($user.userPrincipalName)]"

                        if (![string]::IsNullOrEmpty($mail)) {
                            $isViralUser = Get-MsIdIsViralUser -Mail $mail
                        }
                        else {
                            Write-Verbose "Skipping viral check. $($user.userPrincipalName) does not have a mail address."
                        }
                    }
                    else {
                        Write-Verbose "Skipping viral check. $($mail) <> ExternalAzureAD managed user"
                    }
                }

                if(($Type -eq 'ExternalAzureADViral' -or $Type -eq 'All') -and $isViralUser)
                {
                    Write-Verbose "$($mail) = viral user [$($user.userPrincipalName)]"
                    Write-Output $user
                }
                if(($Type -eq 'MicrosoftAccount' -or $Type -eq 'All') -and $isMsaUser)
                {
                    Write-Verbose "$($mail) = Microsoft Account [$($user.userPrincipalName)]"
                    Write-Output $user
                }

            }

            $nextLink = Get-ObjectPropertyValue $results '@odata.nextLink'
            if ($nextLink) {
                $results = Invoke-MgGraphRequest -Uri $nextLink -Headers @{ ConsistencyLevel = 'eventual' }
            }
            else {
                $hasMoreData = $false
            }

        }
        Write-Progress -Activity "Checking Guest Users" -Completed
    }
}

#endregion

#region Invoke-MsIdAzureAdSamlRequest.ps1

<#
.SYNOPSIS
    Invoke Saml Request on Azure AD.
   
.EXAMPLE
    PS > $samlRequest = New-MsIdSamlRequest -Issuer 'urn:microsoft:adfs:claimsxray'
    PS > Invoke-MsIdAzureAdSamlRequest $samlRequest.OuterXml

    Create new Saml Request for Claims X-Ray and Invoke on Azure AD.

.INPUTS
    System.String

#>

function Invoke-MsIdAzureAdSamlRequest {
    [CmdletBinding()]
    [OutputType()]
    param (
        # SAML Request
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object[]] $SamlRequest,
        # Azure AD Tenant Id
        [Parameter(Mandatory = $false)]
        [string] $TenantId = 'common'
    )

    process {
        foreach ($_SamlRequest in $SamlRequest) {
            if ($_SamlRequest -is [string]) {
                $xmlSamlRequest = ConvertFrom-SamlMessage $_SamlRequest
            }
            else {
                $xmlSamlRequest = $_SamlRequest
            }
            $EncodedSamlRequest = $xmlSamlRequest.OuterXml | Compress-Data | ConvertTo-Base64String

            [System.UriBuilder] $uriAzureAD = 'https://login.microsoftonline.com/{0}/saml2' -f $TenantId
            $uriAzureAD.Query = ConvertTo-QueryString @{
                SAMLRequest = $EncodedSamlRequest
            }

            Write-Verbose ('Invoking Azure AD SAML2 Endpoint [{0}]' -f $uriAzureAD.Uri.AbsoluteUri)
            Start-Process $uriAzureAD.Uri.AbsoluteUri
        }
    }
}

#endregion

#region New-MsIdClientSecret.ps1

<#
.SYNOPSIS
    Generate Random Client Secret for application registration or service principal in Azure AD.
    
.EXAMPLE
    PS > New-MsIdClientSecret

    Generates a new client secret 32 characters long.
    
.EXAMPLE
    PS > New-MsIdClientSecret -Length 64 -Base64Encode

    Generates a new client secret 64 bytes long and then base64 encodes it.

#>

function New-MsIdClientSecret {
    [CmdletBinding()]
    [OutputType([securestring])]
    param (
        # Specifies the number of random characters or bytes to generate.
        [Parameter(Mandatory = $false)]
        [int] $Length = 32,
        # Generate a binary key and encode it to base64.
        [Parameter(Mandatory = $false)]
        [switch] $Base64Encode
    )

    if ($Base64Encode) {
        [securestring] $Secret = ConvertTo-SecureString (ConvertTo-Base64String ([byte[]](Get-Random -InputObject ((([byte]::MinValue)..([byte]::MaxValue)) * $Length) -Count $Length))) -AsPlainText -Force
    }
    else {
        [char[]] $Numbers = (48..57)
        [char[]] $UpperCaseLetters = (65..90)
        [char[]] $LowerCaseLetters = (97..122)
        [char[]] $Symbols = '*+-./:=?@[]_'
        [securestring] $Secret = ConvertTo-SecureString ((Get-Random -InputObject (($UpperCaseLetters + $LowerCaseLetters + $Numbers + $Symbols) * $Length) -Count $Length) -join '') -AsPlainText -Force
    }
    return $Secret
}

#endregion

#region New-MsIdSamlRequest.ps1

<#
.SYNOPSIS
   Create New Saml Request.
   
.EXAMPLE
    PS > New-MsIdSamlRequest -Issuer 'urn:microsoft:adfs:claimsxray'

    Create New Saml Request for Claims X-Ray.

.INPUTS
    System.String

.OUTPUTS
    SamlMessage : System.Xml.XmlDocument, System.String

#>

function New-MsIdSamlRequest {
    [CmdletBinding()]
    #[OutputType([xml], [string])]
    param (
        # Azure AD uses this attribute to populate the InResponseTo attribute of the returned response.
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string] $Issuer,
        # If provided, this parameter must match the RedirectUri of the cloud service in Azure AD.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [string] $AssertionConsumerServiceURL,
        # If this is true, Azure AD will attempt to authenticate the user silently using the session cookie.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [switch] $IsPassive,
        # If true, it means that the user will be forced to re-authenticate, even if they have a valid session with Azure AD.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [switch] $ForceAuthn,
        # Tailors the name identifier in the subjects of assertions.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ArgumentCompleter({
                param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters )
                'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
                'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
                'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
                'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified'
            })]
        [string] $NameIDPolicyFormat,
        # Specifies the authentication context requirements of authentication statements returned in response to a request or query.
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ArgumentCompleter({
                param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters )
                'urn:oasis:names:tc:SAML:2.0:ac:classes:Password'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:SecureRemotePassword'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:X509'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient'
                'urn:oasis:names:tc:SAML:2.0:ac:classes:Unspecified'
                'urn:oasis:names:tc:SAML:1.0:am:password'
                'urn:oasis:names:tc:SAML:1.0:am:X509-PKI'
                'urn:federation:authentication:windows'
                'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/password'
                'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/secureremotepassword'
                'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/windows'
                'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/kerberos'
                'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/tlsclient'
                'urn:ietf:rfc:1510'
                'urn:ietf:rfc:2246'
                'urn:ietf:rfc:2945'
            })]
        [string[]] $RequestedAuthnContext,
        # Specifies the comparison method used to evaluate the requested context classes or statements, one of "exact", "minimum", "maximum", or "better".
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('exact', 'minimum', 'maximum', 'better')]
        [string] $RequestedAuthnContextComparison,
        # Deflate and Base64 Encode the Saml Request
        [Parameter(Mandatory = $false)]
        [switch] $DeflateAndEncode,
        # Url Encode the Deflated and Base64 Encoded Saml Request
        [Parameter(Mandatory = $false)]
        [switch] $UrlEncode
    )

    begin {
        $pathSamlRequest = Join-Path $PSScriptRoot 'internal\SamlRequestTemplate.xml'
    }

    process {
        $xmlSamlRequest = New-Object SamlMessage
        $xmlSamlRequest.Load($pathSamlRequest)
        $xmlSamlRequest.AuthnRequest.ID = 'id{0}' -f (New-Guid).ToString("N")
        $xmlSamlRequest.AuthnRequest.IssueInstant = (Get-Date).ToUniversalTime().ToString('o')
        $xmlSamlRequest.AuthnRequest.Issuer.'#text' = $Issuer
        if ($AssertionConsumerServiceURL) { $xmlSamlRequest.AuthnRequest.SetAttribute('AssertionConsumerServiceURL', $AssertionConsumerServiceURL) }
        if ($PSBoundParameters.ContainsKey('IsPassive')) { $xmlSamlRequest.AuthnRequest.SetAttribute('IsPassive', $IsPassive.ToString().ToLowerInvariant()) }
        if ($PSBoundParameters.ContainsKey('ForceAuthn')) { $xmlSamlRequest.AuthnRequest.SetAttribute('ForceAuthn', $ForceAuthn.ToString().ToLowerInvariant()) }
        if ($NameIDPolicyFormat) { (Resolve-XmlElement $xmlSamlRequest.DocumentElement -Prefix samlp -LocalName NameIDPolicy -NamespaceURI $xmlSamlRequest.DocumentElement.NamespaceURI -CreateMissing).SetAttribute('Format', $NameIDPolicyFormat) }
        if ($RequestedAuthnContext) {
            $AuthnContextClassRefTemplate = $xmlSamlRequest.AuthnRequest.RequestedAuthnContext.ChildNodes[0]
            foreach ($AuthnContext in $RequestedAuthnContext) {
                $AuthnContextClassRef = $AuthnContextClassRefTemplate.Clone()
                $AuthnContextClassRef.'#text' = $AuthnContext
                [void]$xmlSamlRequest.AuthnRequest.RequestedAuthnContext.AppendChild($AuthnContextClassRef)
            }
            [void]$xmlSamlRequest.AuthnRequest.RequestedAuthnContext.RemoveChild($AuthnContextClassRefTemplate)
            if ($RequestedAuthnContextComparison) { $xmlSamlRequest.AuthnRequest.RequestedAuthnContext.SetAttribute('Comparison', $RequestedAuthnContextComparison) }
        }

        if ($DeflateAndEncode) {
            $EncodedSamlRequest = $xmlSamlRequest.OuterXml | Compress-Data | ConvertTo-Base64String
            if ($UrlEncode) { Write-Output ([System.Net.WebUtility]::UrlEncode($EncodedSamlRequest)) }
            else { Write-Output $EncodedSamlRequest }
        }
        else {
            Write-Output $xmlSamlRequest
        }
    }
}

#endregion

#region New-MsIdTemporaryUserPassword.ps1

<#
.SYNOPSIS
    Generate Random password for user in Azure AD.
    
.EXAMPLE
    PS > New-MsIdTemporaryUserPassword

    Generates a new password.

#>

function New-MsIdTemporaryUserPassword {
    [CmdletBinding()]
    [OutputType([securestring])]
    param ()

    [char[]] $Consonants = 'bcdfghjklmnpqrstvwxyz'
    [char[]] $Vowels = 'aou'
    [securestring] $Password = ConvertTo-SecureString ('{0}{1}{2}{3}{4}' -f (Get-Random -InputObject $Consonants).ToString().ToUpper(), (Get-Random -InputObject $Vowels), (Get-Random -InputObject $Consonants), (Get-Random -InputObject $Vowels), (Get-Random -Minimum 1000 -Maximum 9999)) -AsPlainText -Force
    return $Password
}

#endregion

#region New-MsIdWsTrustRequest.ps1

<#
.SYNOPSIS
    Create a WS-Trust request.
.EXAMPLE
    PS > New-MsIdWsTrustRequest urn:federation:MicrosoftOnline -Endpoint https://adfs.contoso.com/adfs/services/trust/2005/windowstransport

    Create a Ws-Trust request for the application urn:federation:MicrosoftOnline.

#>

function New-MsIdWsTrustRequest {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Application identifier
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string] $Identifier,
        # Host name for the AD FS server
        [Parameter(Mandatory=$true)]
        [string]$Endpoint,
        # Credential for the user to be signed in
        [Parameter(Mandatory=$false)]
        [pscredential]$Credential
    )

    if ($Credential -ne $null)
    {
        Write-Warning "Using credentials sends password in clear text over the network!"
      
        $username = $Credential.UserName
        $password = ConvertFrom-SecureStringAsPlainText $Credential.Password -Force
        $request = [String]::Format(
            '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"><s:Header><a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action><a:ReplyTo><a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo><a:To s:mustUnderstand="1">{0}</a:To><o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><o:UsernameToken u:Id="uuid-52bba51d-e0c7-4bb1-8c99-6f97220eceba-5"><o:Username>{1}</o:Username><o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{2}</o:Password></o:UsernameToken></o:Security></s:Header><s:Body><t:RequestSecurityToken xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust"><wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"><a:EndpointReference><a:Address>{3}</a:Address></a:EndpointReference></wsp:AppliesTo><t:KeySize>0</t:KeySize><t:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</t:KeyType><t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType><t:TokenType>http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0</t:TokenType></t:RequestSecurityToken></s:Body></s:Envelope>', `
                $Endpoint,
                $username,
                $password,
                $Identifier)
    }
    else
    {
        $request = [String]::Format(
            '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"><s:Header><a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action><a:ReplyTo><a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo><a:To s:mustUnderstand="1">{0}</a:To></s:Header><s:Body><t:RequestSecurityToken xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust"><wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"><a:EndpointReference><a:Address>{1}</a:Address></a:EndpointReference></wsp:AppliesTo><t:KeySize>0</t:KeySize><t:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</t:KeyType><t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType><t:TokenType>http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0</t:TokenType></t:RequestSecurityToken></s:Body></s:Envelope>', `
                $Endpoint,
                $Identifier)
    }

    return $request
}
#endregion

#region Remove-MsidUserAuthenticationMethod.ps1

<#
.SYNOPSIS
    Deletes all the authentication methods registered against a user.

    It is recommended to use Temporary Access Pass (TAP) to allow a users to sign in temporarily without MFA instead of deleting all methods.

.DESCRIPTION
    This cmdlet aims to replicate the [Require re-register MFA](https://learn.microsoft.com/entra/identity/authentication/howto-mfa-userdevicesettings#manage-user-authentication-options) option in the admin portal.

    Deleting all methods will force the user to re-register MFA next time they sign in.

.EXAMPLE
    Connect-MgGraph -Scopes UserAuthenticationMethod.ReadWrite.All

    Remove-MsidUserAuthenticationMethod -UserId john@contoso.com

#>

function Remove-MsidUserAuthenticationMethod {
    [CmdletBinding(HelpUri = 'https://azuread.github.io/MSIdentityTools/commands/Remove-MsidUserAuthenticationMethod')]
    param (
        # The user UPN or ID to delete the authentication methods for.
        [string]
        [Parameter(Position = 1)]
        [string]
        $UserId
    )

    if (-not (Test-MgModulePrerequisites @('UserAuthenticationMethod.ReadWrite'))) { return }


    function DeleteAuthMethod($uid, $method) {
        switch ($method.AdditionalProperties['@odata.type']) {
            '#microsoft.graph.emailAuthenticationMethod' {
                Write-Host 'Removing emailAuthenticationMethod'
                Remove-MgUserAuthenticationEmailMethod -UserId $uid -EmailAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.fido2AuthenticationMethod' {
                Write-Host 'Removing fido2AuthenticationMethod'
                Remove-MgUserAuthenticationFido2Method -UserId $uid -Fido2AuthenticationMethodId $method.Id
            }
            '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' {
                Write-Host 'Removing microsoftAuthenticatorAuthenticationMethod'
                Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId $uid -MicrosoftAuthenticatorAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.phoneAuthenticationMethod' {
                Write-Host 'Removing phoneAuthenticationMethod'
                Remove-MgUserAuthenticationPhoneMethod -UserId $uid -PhoneAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.softwareOathAuthenticationMethod' {
                Write-Host 'Removing softwareOathAuthenticationMethod'
                Remove-MgUserAuthenticationSoftwareOathMethod -UserId $uid -SoftwareOathAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.temporaryAccessPassAuthenticationMethod' {
                Write-Host 'Removing temporaryAccessPassAuthenticationMethod'
                Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId $uid -TemporaryAccessPassAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' {
                Write-Host 'Removing windowsHelloForBusinessAuthenticationMethod'
                Remove-MgUserAuthenticationWindowsHelloForBusinessMethod -UserId $uid -WindowsHelloForBusinessAuthenticationMethodId $method.Id
            }
            '#microsoft.graph.passwordAuthenticationMethod' {
                # Password cannot be removed currently
            }
            Default {
                Write-Host 'This script does not handle removing this auth method type: ' + $method.AdditionalProperties['@odata.type']
            }
        }
        return $? # Return true if no error and false if there is an error
    }

    $methods = Get-MgUserAuthenticationMethod -UserId $userId
    # -1 to account for passwordAuthenticationMethod
    $methods = @($methods) # Convert to array
    Write-Host "Found $($methods.Length - 1) auth method(s) for $userId"

    $defaultMethod = $null
    foreach ($authMethod in $methods) {
        $deleted = DeleteAuthMethod -uid $userId -method $authMethod
        if (!$deleted) {
            # We need to use the error to identify and delete the default method.
            $defaultMethod = $authMethod
        }
    }

    # Graph API does not support reading default method of a user.
    # Plus default method can only be deleted when it is the only (last) auth method for a user.
    # We need to use the error to identify and delete the default method.
    if ($null -ne $defaultMethod) {
        Write-Host "Removing default auth method"
        $result = DeleteAuthMethod -uid $userId -method $defaultMethod
    }

    Write-Host "Re-checking auth methods..."
    $methods = Get-MgUserAuthenticationMethod -UserId $userId
    $methods = @($methods) # Convert to array
    # -1 to account for passwordAuthenticationMethod
    Write-Host "Found $($methods.Length - 1) auth method(s) for $userId"
}

#endregion

#region Reset-MsIdExternalUser.ps1

<#
.SYNOPSIS
    Resets the redemption state of an external user.

.EXAMPLE
    PS > Reset-MsIdExternalUser -UserId 1468b68b-8536-4bc5-ab1f-6014175b836d

    Resets the invitation state of an external user.

.EXAMPLE
    PS > Reset-MsIdExternalUser -UserId 1468b68b-8536-4bc5-ab1f-6014175b836d -SendInvitationMessage

    Resets the invitation state of an external user and sends them the invitation redemption mail.

.EXAMPLE
    PS > $user = Get-MgUser -Filter "startsWith(mail, 'john.doe@fabrikam.net')"
    PS > Reset-MsIdExternalUser -UserId $user.Id

    Resets the invitation state of an external user with the email address john.doe@fabrikam.net.

.EXAMPLE
    PS > $users = Get-MgUser -Filter "endsWith(mail, '@fabrikam.net')"
    PS > $users | Reset-MsIdExternalUser -UserId $user.Id -SendInvitationMessage

    Resets the invitation state of all external users from fabrikam.net and sends them an invitation mail.

.EXAMPLE
    PS > Get-MsIdUnmanagedExternalUser | Reset-MsIdExternalUser

    Resets the invitation state of all unmanaged external users in the tenant.

#>

function Reset-MsIdExternalUser {
    [CmdletBinding(DefaultParameterSetName = 'ObjectId')]
    param (
        # ObjectId of external user
        [Parameter(Mandatory = $true, ParameterSetName = 'ObjectId', Position = 0, ValueFromPipeline = $true)]
        [string] $UserId,

        # User object of external user
        [Parameter(Mandatory = $true, ParameterSetName = 'GraphUser', Position = 0, ValueFromPipeline = $true)]
        [psobject] $User,

        # The url to redirect the user to after they redeem the link
        # Defaults to My Apps page of the inviter's home tenant. https://myapps.microsoft.com?tenantId={tenantId}
        [Parameter(Mandatory = $false, ParameterSetName = 'ObjectId', Position = 1, ValueFromPipeline = $false)]
        [Parameter(Mandatory = $false, ParameterSetName = 'GraphUser', Position = 1, ValueFromPipeline = $false)]
        [string] $InviteRedirectUrl,

        # Sends an email notification to the user with the guest invitation redemption link
        [Parameter(Mandatory = $false, ParameterSetName = 'ObjectId', Position = 2, ValueFromPipeline = $false)]
        [Parameter(Mandatory = $false, ParameterSetName = 'GraphUser', Position = 2, ValueFromPipeline = $false)]
        [switch] $SendInvitationMessage
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgUser', 'New-MgInvitation' -MinimumVersion 2.8.0 -RequireListPermissions -ErrorVariable CriticalError)) { return }

        if (!$InviteRedirectUrl) {
            $tenantId = (Get-MgContext).TenantId
            $InviteRedirectUrl = "https://myapps.microsoft.com?tenantId=$tenantId"
        }
        $doSendInvitationMessage = $SendInvitationMessage.IsPresent
    }

    process {
        function Send-Invitation {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
                [psobject]$GraphUser
            )

            # check that object has requried properties
            if ($GraphUser.psobject.Properties.Name -inotcontains "id") {
                Write-Error "No provided user id"
            }
            if ($GraphUser.psobject.Properties.Name -inotcontains "mail") {
                Write-Error "No provided user mail"
            }
            # check that values are not empty
            if ([string]::IsNullOrWhiteSpace($GraphUser.Id)) {
                Write-Error "Provided user id is empty"
            }
            if ([string]::IsNullOrWhiteSpace($GraphUser.Mail)) {
                Write-Error "Provided user mail is empty"
            }
            # send the invitation
            New-MgInvitation `
                -InvitedUserEmailAddress $GraphUser.Mail `
                -InviteRedirectUrl $InviteRedirectUrl `
                -ResetRedemption `
                -SendInvitationMessage:$doSendInvitationMessage `
                -InvitedUser @{ "id" = $GraphUser.Id }
        }

        # don't process further if there is a critical error
        if ($CriticalError) {
            return
        }

        switch ($PSCmdlet.ParameterSetName) {
            "ObjectId" {
                $graphUser = Get-MgUser -UserId $UserId
                if ($graphUser) {
                    Send-Invitation $graphUser
                }
                else {
                    Write-Error "User not found."
                }
                break
            }
            "GraphUser" {
                Send-Invitation $User
                break
            }
        }
    }

    end {
        if ($CriticalError) { return }
    }
}

#endregion

#region Resolve-MsIdAzureIpAddress.ps1

<#
.SYNOPSIS
    Lookup Azure IP address for Azure Cloud, Region, and Service Tag.
    
.EXAMPLE
    PS > $IpAddress = Resolve-DnsName login.microsoftonline.com | Where-Object QueryType -eq A | Select-Object -First 1 -ExpandProperty IPAddress
    PS > Resolve-MsIdAzureIpAddress $IpAddress

    Lookup Azure IP address for Azure Cloud, Region, and Service Tag.

.EXAMPLE
    PS > Resolve-MsIdAzureIpAddress graph.microsoft.com

    Lookup Azure IP address for Azure Cloud, Region, and Service Tag.

.INPUTS
    System.String
    System.Net.IPAddress

#>

function Resolve-MsIdAzureIpAddress {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        # DNS Name or IP Address
        [Parameter(Mandatory = $true, ParameterSetName = 'InputObject', ValueFromPipeline = $true, Position = 0)]
        [object[]] $InputObjects,
        # IP Address of Azure Service
        [Parameter(Mandatory = $true, ParameterSetName = 'IpAddress', Position = 1)]
        [ipaddress[]] $IpAddresses,
        # Name of Azure Cloud. Valid values are: Public, Government, Germany, China
        [Parameter(Mandatory = $false)]
        [ValidateSet('Public', 'Government', 'Germany', 'China')]
        [string[]] $Clouds = @('Public', 'Government', 'Germany', 'China'),
        # Bypass cache and download data again.
        [Parameter(Mandatory = $false)]
        [switch] $ForceRefresh
    )

    begin {
        #[string[]] $Clouds = 'Public', 'Government', 'Germany', 'China'
        [hashtable] $ServiceTagAndRegions = @{}
        $PreviousProgressPreference = $ProgressPreference
        $ProgressPreference = 'SilentlyContinue'
        Write-Verbose 'Getting Azure IP Ranges and Service Tag Data...'
        foreach ($Cloud in $Clouds) {
            $ServiceTagAndRegions.Add($Cloud, (Get-MsIdAzureIpRange -Cloud $Cloud -AllServiceTagsAndRegions -ForceRefresh:$ForceRefresh -Verbose:$false))
        }
        $ProgressPreference = $PreviousProgressPreference
        Write-Verbose 'Resolving IP Address to Azure Service Tags...'
    }

    process {
        ## Parse InputObject
        if ($PSCmdlet.ParameterSetName -eq 'InputObject') {
            $listIpAddresses = New-Object System.Collections.Generic.List[ipaddress]
            foreach ($InputObject in $InputObjects) {
                if ($InputObject -is [ipaddress] -or $InputObject -is [int] -or $InputObject -is [UInt32]) {
                    $listIpAddresses.Add($InputObject)
                }
                elseif ($InputObject -is [string]) {
                    if ($InputObject -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' -or $InputObject -match '^(?:(?::{1,2})?[0-9a-fA-F]{1,4}(?::{1,2})?){1,8}$') {
                        try {
                            [ipaddress] $IpAddress = $InputObject
                            $listIpAddresses.Add($IpAddress)
                        }
                        catch { throw }
                    }
                    else {
                        $DnsNames = Resolve-DnsName $InputObject -Type A -ErrorAction Stop | Where-Object QueryType -EQ A
                        foreach ($DnsName in $DnsNames) {
                            $listIpAddresses.Add($DnsName.IPaddress)
                        }
                    }
                }
                else {
                    $Exception = New-Object ArgumentException -ArgumentList ('Cannot parse input of type {0} to IP address or DNS name.' -f $InputObject.GetType())
                    Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ResolveAzureIpAddressFailureTypeNotSupported' -TargetObject $InputObject
                }
            }
            [ipaddress[]] $IpAddresses = $listIpAddresses.ToArray()
        }

        ## Lookup IP Address
        foreach ($IpAddress in $IpAddresses) {
            $listResults = New-Object System.Collections.Generic.List[pscustomobject]
            foreach ($Cloud in $ServiceTagAndRegions.Keys) {
                foreach ($ServiceTagAndRegion in $ServiceTagAndRegions[$Cloud].values) {
                    if (Test-IpAddressInSubnet $IpAddress -Subnets $ServiceTagAndRegion.properties.addressPrefixes) {
                        $ServiceTagAndRegion | Add-Member -Name cloud -MemberType NoteProperty -Value $Cloud -Force
                        $ServiceTagAndRegion | Add-Member -Name ipAddress -MemberType NoteProperty -Value $IpAddress -Force
                        $listResults.Add(($ServiceTagAndRegion | Select-Object ipAddress, cloud, id, properties))
                    }
                }
            }
            if ($listResults.Count -gt 1) { Write-Output $listResults.ToArray() -NoEnumerate }
            elseif ($listResults.Count -eq 1) { Write-Output $listResults.ToArray() }
        }
    }
}

#endregion

#region Revoke-MsIdServicePrincipalConsent.ps1

<#
.SYNOPSIS
    Revoke Existing Consent to an Azure AD Service Principal.

.DESCRIPTION
    This command requires the MS Graph SDK PowerShell Module to have a minimum of the following consented scopes:
    Application.Read.All
    DelegatedPermissionGrant.ReadWrite.All or AppRoleAssignment.ReadWrite.All

.EXAMPLE
    PS > Revoke-MsIdServicePrincipalConsent '10000000-0000-0000-0000-000000000001' -All

    Revoke all consent for servicePrincipal '10000000-0000-0000-0000-000000000001'.

.EXAMPLE
    PS > Get-MgServicePrincipal -ServicePrincipalId '10000000-0000-0000-0000-000000000001' | Revoke-MsIdServicePrincipalConsent -Scope User.Read.All -All

    Revoke all consent of 'User.Read.All' scope for piped in servicePrincipal '10000000-0000-0000-0000-000000000001'.

.EXAMPLE
    PS > Revoke-MsIdServicePrincipalConsent '10000000-0000-0000-0000-000000000001' -UserId '20000000-0000-0000-0000-000000000002'

    Revoke existing consent for servicePrincipal '10000000-0000-0000-0000-000000000001' by user '20000000-0000-0000-0000-000000000002'.

.EXAMPLE
    PS > Revoke-MsIdServicePrincipalConsent '10000000-0000-0000-0000-000000000001' -Scope User.Read.All -UserConsent -AdminConsentDelegated

    Revoke 'User.Read.All' scope from all user consent and tenant-wide admin consent of delegated permissions for servicePrincipal '10000000-0000-0000-0000-000000000001'.

.EXAMPLE
    PS > Revoke-MsIdServicePrincipalConsent '10000000-0000-0000-0000-000000000001' -Scope 'User.Read.All','User.ReadWrite.All' -AdminConsentApplication

    Revoke 'User.Read.All' scope from tenant-wide admin consent of application permissions for servicePrincipal '10000000-0000-0000-0000-000000000001'.

.INPUTS
    System.String

#>

function Revoke-MsIdServicePrincipalConsent {
    [CmdletBinding(DefaultParameterSetName = 'Granular')]
    [Alias('Revoke-MsIdApplicationConsent')]
    [OutputType()]
    param (
        # AppId or ObjectId of service principal
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 1)]
        [Alias('Id')]
        [string[]] $ClientId,
        # Limit which scopes are cleared to specified list
        [Parameter(Mandatory = $false)]
        [string[]] $Scope,
        # Revoke all existing consent for service principal
        [Parameter(Mandatory = $true, ParameterSetName = 'All')]
        [switch] $All,
        # Revoke user consent for service principal
        [Parameter(Mandatory = $false, ParameterSetName = 'Granular')]
        [switch] $UserConsent,
        # Revoke user consent for service principal for specified users
        [Parameter(Mandatory = $false, ParameterSetName = 'Granular')]
        [Alias('PrincipalId')]
        [string[]] $UserId,
        # Revoke tenant-wide admin consent of user delegated permissions for service principal
        [Parameter(Mandatory = $false, ParameterSetName = 'Granular')]
        [switch] $AdminConsentDelegated,
        # Revoke tenant-wide admin consent of application permissions for service principal
        [Parameter(Mandatory = $false, ParameterSetName = 'Granular')]
        [switch] $AdminConsentApplication
    )

    begin {
        ## Parameter Set Check
        if (!$All -and !($UserConsent -or $UserId -or $AdminConsentDelegated -or $AdminConsentApplication)) {
            Write-Warning "Your current parameter set will not clear any consent. Add switch for types of consent to clear or add 'All' to clear all consent types."
        }

        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgServicePrincipal' -MinimumVersion 2.8.0 -ErrorVariable CriticalError) -and $CriticalError[-1].CategoryInfo.Reason -Contains 'AuthenticationException') { return }

        if ($All -or $UserConsent -or $UserId -or $AdminConsentDelegated) {
            if (!(Test-MgCommandPrerequisites 'Get-MgServicePrincipalOauth2PermissionGrant', 'Update-MgOauth2PermissionGrant', 'Remove-MgOauth2PermissionGrant' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
        }
        elseif ($All -or $AdminConsentApplication) {
            if (!(Test-MgCommandPrerequisites 'Remove-MgServicePrincipalAppRoleAssignment' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
        }
    }

    process {
        if ($CriticalError) { return }

        foreach ($_ClientId in $ClientId) {

            ## Check for service principal by appId
            $servicePrincipalId = Get-MgServicePrincipal -Filter "appId eq '$_ClientId'" -Select id | Select-Object -ExpandProperty id
            ## If nothing is returned, use provided ClientId as servicePrincipalId
            if (!$servicePrincipalId) { $servicePrincipalId = $_ClientId }

            ## Get Service Principal details
            $servicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $servicePrincipalId -Select id -Expand appRoleAssignments
            if ($servicePrincipal) {

                if ($All -or $AdminConsentApplication) {

                    ## Revoke Application Permissions with Tenant-Wide Admin Consent
                    foreach ($appRoleAssignment in $servicePrincipal.AppRoleAssignments) {
                        $spResource = Get-MgServicePrincipal -ServicePrincipalId $appRoleAssignment.ResourceId -Select id, appRoles
                        $ScopeValue = $spResource.AppRoles | Where-Object Id -EQ $appRoleAssignment.AppRoleId | Select-Object -ExpandProperty Value
                        if (!$Scope -or $ScopeValue -in $Scope) {
                            Remove-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $appRoleAssignment.PrincipalId -AppRoleAssignmentId $appRoleAssignment.Id
                        }
                    }

                }

                if ($All -or $UserConsent -or $UserId -or $AdminConsentDelegated) {

                    ## Get all oauth2PermissionGrants and loop through each one
                    $oauth2PermissionGrants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $servicePrincipalId
                    foreach ($oauth2PermissionGrant in $oauth2PermissionGrants) {

                        if ($Scope) {
                            [System.Collections.Generic.List[string]] $UpdatedScopes = $oauth2PermissionGrant.Scope -split ' '
                            foreach ($_Scope in $Scope) { [void]$UpdatedScopes.Remove($_Scope) }
                        }

                        if ($Scope -and $UpdatedScopes) {
                            ## Update scopes for requested entries
                            if ($oauth2PermissionGrant.ConsentType -eq 'Principal' -and ($All -or ($UserConsent -and !$UserId) -or ($oauth2PermissionGrant.PrincipalId -in $UserId))) {
                                Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $oauth2PermissionGrant.Id -Scope ($UpdatedScopes -join ' ')
                            }
                            elseif ($oauth2PermissionGrant.ConsentType -eq 'AllPrincipals' -and ($All -or $AdminConsentDelegated)) {
                                Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $oauth2PermissionGrant.Id -Scope ($UpdatedScopes -join ' ')
                            }
                        }
                        else {
                            ## Revoke all scopes for requested entries
                            if ($oauth2PermissionGrant.ConsentType -eq 'Principal' -and ($All -or ($UserConsent -and !$UserId) -or ($oauth2PermissionGrant.PrincipalId -in $UserId))) {
                                Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $oauth2PermissionGrant.Id
                            }
                            elseif ($oauth2PermissionGrant.ConsentType -eq 'AllPrincipals' -and ($All -or $AdminConsentDelegated)) {
                                Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $oauth2PermissionGrant.Id
                            }
                        }
                    }

                }

            }
        }
    }
}

#endregion

#region Show-MsIdJwtToken.ps1

<#
.SYNOPSIS
   Show Json Web Token (JWT) decoded in Web Browser using diagnostic web app.
   
.EXAMPLE
    PS > $MsalToken.IdToken | Show-MsIdJwtToken

    Show OAuth IdToken JWT decoded in Web Browser.

.INPUTS
    System.String

#>

function Show-MsIdJwtToken {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [Alias('Show-Jwt')]
    param (
        # JSON Web Token (JWT)
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $Tokens,
        # OAuth2 Redirect Uri of test application to send Json Web Token
        [Parameter(Mandatory = $false)]
        [uri] $OAuth2RedirectUri = 'https://jwt.ms/',
        # Suppress Prompts
        [Parameter(Mandatory = $false)]
        [switch] $Force
    )

    begin {
        if ($Force -and -not (Get-Variable Confirm -ValueOnly -ErrorAction Ignore)) { $ConfirmPreference = 'None' }
    }

    process {
        foreach ($Token in $Tokens) {

            if ($OAuth2RedirectUri.AbsoluteUri -ne 'https://jwt.ms/') {
                Write-Warning ('The token is being sent to the following web service [{0}]. This command is intended for troubleshooting and should only be used if you trust the service endpoint receiving the token.' -f $OAuth2RedirectUri.AbsoluteUri)
                if (!$PSCmdlet.ShouldProcess($OAuth2RedirectUri.AbsoluteUri, "Send token")) { continue }
            }

            $OAuth2RedirectUriWithToken = New-Object System.UriBuilder $OAuth2RedirectUri -Property @{ Fragment = "id_token=$Token" }
            Start-Process $OAuth2RedirectUriWithToken.Uri.AbsoluteUri
        }
    }
}

#endregion

#region Show-MsIdSamlToken.ps1

<#
.SYNOPSIS
   Show Saml Security Token decoded in Web Browser using diagnostic web app.
   
.EXAMPLE
    PS > Show-MsIdSamlToken 'Base64String'

    Show Saml Security Token decoded in Web Browser.

.INPUTS
    System.String

#>

function Show-MsIdSamlToken {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [Alias('Show-SamlResponse')]
    param (
        # SAML Security Token
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $Tokens,
        # URL Endpoint to send SAML Security Token
        [Parameter(Mandatory = $false)]
        [string] $SamlEndpoint = 'https://adfshelp.microsoft.com/ClaimsXray/TokenResponse',
        # Suppress Prompts
        [Parameter(Mandatory = $false)]
        [switch] $Force
    )

    begin {
        if ($Force -and -not (Get-Variable Confirm -ValueOnly -ErrorAction Ignore)) { $ConfirmPreference = 'None' }

        function GetAvailableLocalTcpPort {
            $TcpListner = New-Object System.Net.Sockets.TcpListener -ArgumentList ([ipaddress]::Loopback, 0)
            try {
                $TcpListner.Start();
                return $TcpListner.LocalEndpoint.Port
            }
            finally { $TcpListner.Stop() }
        }

        function RespondToLocalHttpRequest {
            [CmdletBinding()]
            param (
                # HttpListener Object
                [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
                [System.Net.HttpListener] $HttpListener,
                # HTTP Message Body
                [Parameter(Mandatory = $true)]
                [byte[]] $MessageBody
            )

            ## Wait for HTTP Request
            $HttpListenerContext = $HttpListener.GetContext()

            ## Response to HTTP Request
            Write-Verbose ('{0} => {1}' -f $HttpListenerContext.Request.UserHostAddress, $HttpListenerContext.Request.Url)
            #$MessageBody = [System.Text.Encoding]::UTF8.GetBytes($Html)
            $HttpListenerContext.Response.ContentLength64 = $MessageBody.Length
            $HttpListenerContext.Response.OutputStream.Write($MessageBody, 0, $MessageBody.Length)
            $HttpListenerContext.Response.OutputStream.Close()
        }

        ## Get HTML Content
        $pathHtml = Join-Path $PSScriptRoot 'internal\SamlRedirect.html'
        if ($PSVersionTable.PSVersion -ge [version]'6.0') {
            $bytesHtml = Get-Content $pathHtml -Raw -AsByteStream
        }
        else {
            $bytesHtml = Get-Content $pathHtml -Raw -Encoding Byte
        }

        ## Generate local HTTP URL and Listener
        [System.UriBuilder] $uriSamlRedirect = New-Object System.UriBuilder -Property @{
            Scheme = 'http'
            Host   = 'localhost'
            Port   = GetAvailableLocalTcpPort
        }
        $HttpListener = New-Object System.Net.HttpListener
        $HttpListener.Prefixes.Add($uriSamlRedirect.Uri.AbsoluteUri)
    }

    process {
        foreach ($Token in $Tokens) {

            if ($SamlEndpoint -ne 'https://adfshelp.microsoft.com/ClaimsXray/TokenResponse') {
                Write-Warning ('The token is being sent to the following web service [{0}]. This command is intended for troubleshooting and should only be used if you trust the service endpoint receiving the token.' -f $SamlEndpoint)
                if (!$PSCmdlet.ShouldProcess($SamlEndpoint, "Send token")) { continue }
            }

            $uriSamlRedirect.Fragment = ConvertTo-QueryString @{
                SAMLResponse = $Token
                ReplyURL     = $SamlEndpoint
            }

            try {
                $HttpListener.Start()
                Start-Process $uriSamlRedirect.Uri.AbsoluteUri
                $HttpListener | RespondToLocalHttpRequest -MessageBody $bytesHtml
            }
            finally { $HttpListener.Stop() }
        }
    }

    end {
        $HttpListener.Dispose()
    }
}

#endregion

#region Test-MsIdAzureAdDeviceRegConnectivity.ps1

<#
.SYNOPSIS
    Test connectivity on Windows OS for Azure AD Device Registration
    
.EXAMPLE
    PS > Test-MsIdAzureAdDeviceRegConnectivity

    Test required hostnames

.EXAMPLE
    PS > Test-MsIdAzureAdDeviceRegConnectivity -AdfsHostname 'adfs.contoso.com'

    Test required hostnames and ADFS server

.INPUTS
    System.String

.LINK
    https://docs.microsoft.com/en-us/samples/azure-samples/testdeviceregconnectivity/testdeviceregconnectivity/

#>

function Test-MsIdAzureAdDeviceRegConnectivity {
    [CmdletBinding()]
    param (
        # ADFS Server
        [Parameter(Mandatory = $false)]
        [string] $AdfsHostname
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if ($PSEdition -ne 'Desktop' -or !(Test-PsElevation)) {
            Write-Error 'This command uses a Scheduled Job to run under the system context of a Windows OS which requires Windows PowerShell 5.1 and an elevated session using Run as Administrator.' -ErrorVariable CriticalError
            return
        }
    }

    process {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        Invoke-CommandAsSystem {
            param ([string]$AdfsHostname)
            [System.Security.Principal.WindowsIdentity]::GetCurrent().Name

            [System.Collections.Generic.List[string]] $listHostname = @(
                'login.microsoftonline.com'
                'device.login.microsoftonline.com'
                'enterpriseregistration.windows.net'
                'autologon.microsoftazuread-sso.com'
            )
            if ($AdfsHostname) { $listHostname.Add($AdfsHostname) }

            $listHostname | Test-NetConnection -Port 443 | Format-Table ComputerName, RemotePort, RemoteAddress, TcpTestSucceeded
        } -ArgumentList $AdfsHostname -ErrorAction Stop
    }
}

#endregion

#region Test-MsIdCBATrustStoreConfiguration.ps1

<#
.SYNOPSIS
    Test & report for common mis-configuration issues with the Entra ID Certificate Trust Store

.DESCRIPTION
    The following is a list of checks performed by this cmdlet.

    * CertificateRevocationListUrl Format Validation Test: Checks for a correctly formatted CRL Distribution Point (CDP) URL
    * Certificate Time Validity Test: Checks that the CA certificate being evaluated is time valid
    * CRL Download and Latency Test: Checks to make sure the Certificate Revocation List (CRL) can be downloaded from the configured CRL and that the download completes in less then 12 seconds
    * CRL Size Test: Checks that the CRL is less then 44MB
    * Certificate Trust Chain Test: Checks that any certificate that is not marked as a root has its issuer also present in the certificate store.
    * CRL Authority Test: Checks that the CRL downloaded from the configured CA lists the CA certificate being evaluated as the its authority.
    * CRL Time Validity Test: Checks that the CRL being evaluated is time valid
    * Additional CRL Information: This include properties of the tested CRL including thisUpdate(Issued), nextPublish, nextUpdate(Expiry) and amount of time remaining

    This Powershell cmdlet require Windows command line utility Certutil. This cmdlet can only be run from Windows device.

    Since the CRL Distribution Point (CDP) needs to be accessible to Entra ID. It is best to run this script from outside
    a corporate network on an internet connected Windows device.

.INPUTS
    None

.EXAMPLE
    Test-MsIdCBATrustStoreConfiguration
.LINK
    https://aka.ms/aadcba

#>

function Test-MsIdCBATrustStoreConfiguration {

    begin {
            ## Due to Certutil Dependency will only run on Windows.
            Try
            {
            certutil /? | Out-Null
            }
            Catch
            {
            Write-Host Certutil not found. This cmdlet can only run on Windows -ForegroundColor Red
            Break
            }
            try {
            if (-not(get-module -Name Microsoft.Graph.Identity.DirectoryManagement)) {
                    import-module Microsoft.Graph.Identity.DirectoryManagement
                }
                if (-not(get-module -Name Microsoft.Graph.Identity.SignIns)) {
                    import-module Microsoft.Graph.Identity.SignIns
                }
            }
            catch {
                Write-Host Microsoft Graph SDK not found. Install the Microwsoft Graph SDK -ForegroundColor Red
                Break
            }
            try {
                $context = Get-MgContext
                if ($null -eq $context) {
                                         Write-Host "Unable to connect to MSGraph. Run Connect-MgGraph prior to this Powershell Cmdlet" -ForegroundColor Red
                                         Break
                                        }
                }
            catch {
                Write-Host "Unable to determine MgContext (Get-MgContext). Resolve issues with Get-MgContext and try again" -ForegroundColor Red
                Break
            }
        }

    process {
# Get Org Info
$OrgInfo = Get-MgOrganization

# Get the list of trusted certificate authorities
$trustedCAs = (Get-MgOrganizationCertificateBasedAuthConfiguration -OrganizationId $OrgInfo.Id).CertificateAuthorities

# Check for a single CA
If($trustedCAs.count -eq 0)
{
    Write-Host "No Certificate Authorities are present in $($OrgInfo.DisplayName - $($OrgInfo.Id))" -ForegroundColor Red
    Break
}

# Loop through each trusted CA
$CompletedResult = @()
foreach ($ca in $trustedCAs) {
    $crlDLTime = $null
    $crldump = $Null
    $crlAKI = $Null
    $crlTU = $Null
    $crlNU = $null

    Write-Host "Processing $($ca.Issuer)"
    ### High Level Check for correctly formatted CDP URL

    If($ca.CertificateRevocationListUrl)
    {
     Write-Host " CertificateRevocationListUrl Format Validation Test"
     $pattern = '^http:\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])\/[^\/]+\.[^\/]+$'
     $crlURLCheckPass = $false
     if ($ca.CertificateRevocationListUrl -match $pattern) {
        Write-Host " Passed" -ForegroundColor Green
        $crlURLCheckPass = $true
     } elseif ($ca.CertificateRevocationListUrl -match '^https:\/\/') {
        Write-Host " HTTPS is not allowed" -ForegroundColor Red
    } else {
        Write-Host " Invalid CDP URL" -ForegroundColor Red
    }
    If(!$crlURLCheckPass)
    {
     ## THis needs to be corrected before other checks
     Write-Host " This CA CDP needs to be corrected. Additional checks for this CA are not processed" -ForegroundColor Red
     Continue
    }
    }

    $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ca.Certificate)

    $objresult = New-Object System.Object
    $objresult | Add-Member -type NoteProperty -name NotAfter -value $cert.NotAfter
    $objresult | Add-Member -type NoteProperty -name NotBefore -value $cert.NotBefore
    $objresult | Add-Member -type NoteProperty -name Subject -value $cert.Subject
    $objresult | Add-Member -type NoteProperty -name Issuer -value $cert.Issuer
    $objresult | Add-Member -type NoteProperty -name Thumbprint -value $cert.Thumbprint

    ForEach($Extension in $Cert.Extensions) {
        Switch($Extension.Oid.FriendlyName) {
            "Authority Key Identifier" {$objresult | Add-Member -type NoteProperty -name Authority-Key-Identifier -value ($Extension.Format($false)).trimstart("KeyID=")}
            "Subject Key Identifier"   {$objresult | Add-Member -type NoteProperty -name Subject-Key-Identifier -value $Extension.Format($false)}
        } ##Switch
    }## ForEach Extension

    $FullCert = $objresult
    $CompletedResult += $objresult

    # Check the Time validity of the certificate
    $now = Get-Date
    Write-Host " Certificate Time Validity Test"
    if ($now -lt $FullCert.NotBefore -or $now -gt $FullCert.NotAfter) {
        Write-Host " Certificate for $($cert.Subject) is not yet valid or expired" -ForegroundColor Red
        continue
    } Else {
        Write-Host " Passed" -ForegroundColor Green
    }

    # Download the CRL
    $TempDir = [System.IO.Path]::GetTempPath()

    If($ca.CertificateRevocationListUrl) {
       Try {
            $crlDLTime = Measure-Command {Invoke-WebRequest -Uri $ca.CertificateRevocationListUrl -OutFile ($TempDir + "crl.crl")}
        } Catch {}

        # Check if the CRL was downloaded successfully
        Write-Host " CRL Download & Latency Test"
        if ($null -eq $crlDLTime) {
            Write-Host " Failed to download CRL for $($cert.Subject)" -ForegroundColor Red
            continue
        } Else {
            if($crlDLTime.TotalSeconds -gt 12) {
                Write-Host " Slow CRL Download (>12 Seconds) for $($cert.Subject)" -ForegroundColor Red
            } Else {
                Write-Host " CRL Download successful for $($cert.Subject)" -ForegroundColor Green
            }
        }
    } Else {
        Write-Host $cert.Subject is not configured with a CRL - Entra ID will not perform CRL check for this CA -ForegroundColor Yellow
        Continue
    }

    ## Check CRL Size
    Write-Host " CRL Size Test"
    $File = Get-ChildItem ($TempDir + "crl.crl")
    $FileMB = [math]::Round($File.Length/1MB,0)
    if($FileMB -gt 44) {
        Write-Host " CRL is Large - $($FileMB) MB- Users may see intermittent Sign-in errors due to sizes above 45" MB -ForegroundColor Red
    } Else {
        If($FileMB -lt 1)
        {
         Write-Host " Passed - CRL is < 1MB" -ForegroundColor Green
        }
        Else
        {
         Write-Host " Passed - CRL is $($FileMB) MB" -ForegroundColor Green
        }
    }

# Validate CA Cert AKI--> SKI Mapping Logic
Write-Host " Certificate Trust Chain Test"
If(($FullCert | Get-Member).name -contains 'Authority-Key-Identifier')
 {
  If([string]::IsNullOrEmpty($FullCert.'Authority-Key-Identifier')) ##Check for Empty AKI
  {
    If($ca.IsRootAuthority)
        {
         Write-Host " CA is configured as a Root Authority --> No Parent Issuer expected in store(AKI Present and Empty)"
        }
   Else {
        Write-Host " CA is not configured as a Root CA and certificate contains empty Authority Key Identifier(AKI) --> This is unexpected" -ForegroundColor Red
        }
  } ## Close Present but Empty AKI
Else ## Non-Empty AKI
  {
    Write-Host " Expected Issuer Subject Key Identifier (SKI) : $($FullCert.'Authority-Key-Identifier')"
    If(!$ca.IsRootAuthority)
        {
         If($FullCert.'Authority-Key-Identifier' -eq $FullCert.'Subject-Key-Identifier')
            {
             Write-Host " CA Authority Key Identifier (AKI) and Subject Key Identifier(SKI) are the same and Cert is not marked as isRootAuthority --> This is unexpected"
            }
         Else
            { ## Non-Empty AKI Non-Root
             If($trustedCAs.IssuerSKI -notcontains $FullCert.'Authority-Key-Identifier')
                {
                 Write-Host " Certificate issuer $($FullCert.'Authority-Key-Identifier') is not present in the tenant certificate store" -ForegroundColor Red
                }
            Else
                {
                 Write-Host " Passed" -ForegroundColor Green
                }
            }
        } ##Close Non Empty NonRoot
    Else
        {
         #Non Empty Root
         If($FullCert.'Authority-Key-Identifier' -eq $FullCert.'Subject-Key-Identifier')
               {
                 Write-Host " Passed" -ForegroundColor Green
               }
        elseif([string]::IsNullOrEmpty($FullCert.'Authority-Key-Identifier'))
            ##Check for Empty AKI
               {
                Write-Host " Passed Certificate issuer is marked as Root and contains empty AKI" -ForegroundColor Green
               }
           Else{
                Write-Host " Certificate issuer is marked as Root but contains AKI that does not match SKI --> This is unexpected"
               }
    }
  }##Close Non-Empty AKI
 }## Close with AKI
 else ## Handle No AKI in Cert at all
     {
        If($ca.IsRootAuthority)
        {
            Write-Host " Passed" -ForegroundColor Green
        }
         Else
        {
            Write-Host " CA Certificate is not marked as Root and doesnot contain AKI --> This is unexpected"
        }
     }## Close No AKI

    # Dump the CRL file using certutil
    Write-Host " "Running Certutil commands and parsing output *** Can be Slow for Big CRL *** -ForegroundColor White
    $crldump = certutil -dump ($TempDir + "crl.crl")
    # Check for a Next Publish Date in CRLDump and grab before truncating the output for faster processing
        $i = 0
        $crlNP = $Null
    ForEach($Line in $crldump) {
        If ($Line -match "Next CRL Publish") {
            $crlNP = ($crldump[$i+1]).TrimStart(' ') | get-date
            break
        }
        $i++
    }

    ## Shorted CRLDump output for faster parsing
    ## Removed due to inconsistent certutil output. AKI is sometimes after the CRL Entries
    #$i = 0
    #ForEach($Line in $crldump) {
    # If ($Line -match "CRL Entries:") {
    # $crldump = $crldump[0..$i]
    # break
    # }
    # $i++
    #}
    #Clear crl values
    $crlAKI = $null
    $crlAKI = $null
    $crlTU = $null
    $crlTU = $null
    $crlNU = $null
    $crlNU = $null
    $MatchingCRL = $false

    $crlAKI = $crldump -match 'KeyID='
    $crlAKI = $crlAKI -replace ' KeyID=',''
    $crlTU = $crldump -match ' ThisUpdate: '
    $crlTU = $crlTU -replace ' ThisUpdate: ','' | get-Date
    $crlNU = $crldump -match ' NextUpdate: '
    $crlNU = $crlNU -replace ' NextUpdate: ','' | get-Date

    # Verify CRL/CERT AKI Match
    Write-Host " CRL AKI matches CA SKI Test"
    If($crlAKI -ne $FullCert.'Subject-Key-Identifier') {
        If($null -eq $crlAKI)
        {
            Write-Host " Unable to determine CRL AKI from Certutul output" -ForegroundColor Red
        }
        else 
        {
              
        # Downloaded CRL AKI does not match expected SKI of CA Certificate
        Write-Host " CRL Authority Key Identifier(AKI) Mismatch" -ForegroundColor Red
        Write-Host " CRL AKI : " $crlAKI -ForegroundColor Red
        Write-Host " Expected AKI : " $FullCert.'Subject-Key-Identifier' -ForegroundColor Red
        }

        ## See if the CRL downloaded AKI matches other CA in Store
        If($trustedCAs.IssuerSKI -contains $crlAKI) {
            $MatchedCA = @()
            $MatchedCA = $trustedCAs | Where-Object {$crlAKI -eq $_.IssuerSki}
            If($MatchedCA) {
                            Write-Host " Downloaded CRL AKI matches another CA certificate in the trusted store : $($MatchedCA.Issuer) & SKI of $($MatchedCA.IssuerSki)" -ForegroundColor Red
                           }
        }
    } Else {
        $MatchingCRL = $true
        Write-Host " " Cert SKI matches CRL AKI -ForegroundColor Green
    }
    If($MatchingCRL)
    {
    # Check CRL Time Validity
    Write-Host " CRL Time Validity Test"
    if ($now -lt $crlTU -or $now -gt $crlNU) {
        Write-Host " CRL for $($cert.Subject) downloaded from $($ca.CertificateRevocationListUrl) is not yet valid or expired" -ForegroundColor Red
    } Else {
        Write-Host " Passed" -ForegroundColor Green
    }

    # Display CRL Lifetime Information
    Write-Host " Additional CRL Information"
    Write-Host " " CRL was Issued on $crlTU
    If($crlNP)
    {
     Write-Host " " CRL nextPublish is $crlNP
    }
    Else
    {
     Write-Host " " CRL does not contain nextPublish date
    }
    Write-Host " " CRL expires on $crlNU
    $TimeLeft = New-TimeSpan -Start $now -End $crlNU
    Write-Host " " CRL is valid for $TimeLeft.Days Days $TimeLeft.Hours Hours
    }
    # TODO Verify the CRL signature
}##ForEach CA
}##Close Process
}## Close Function
#Test-MsIdCBATrustStoreConfiguration

#endregion

#region Resolve-MsIdTenant.ps1

<#
.SYNOPSIS
    Resolve TenantId or DomainName to an Azure AD Tenant
    
.DESCRIPTION
    Resolves TenantID or DomainName values to an Azure AD tenant to retrieve metadata about the tenant when resolved

.EXAMPLE
    Resolve-MsIdTenant -Tenant example.com

    Resolve tenant example.com

.EXAMPLE
    Resolve-MsIdTenant -TenantId c19543f3-d36c-435c-ad33-18f11b8c1a15

    Resolve tenant guid c19543f3-d36c-435c-ad33-18f11b8c1a15

.EXAMPLE
    Resolve-MsIdTenant -Tenant "example.com","c19543f3-d36c-435c-ad33-18f11b8c1a15"

    Resolve tenant domain, example.com, and tenant guid, c19543f3-d36c-435c-ad33-18f11b8c1a15.

.EXAMPLE
    $DomainList = get-content .\DomainList.txt
    Resolve-MsIdTenant -Tenant $DomainList

    Resolve tenants in DomainList.txt

.NOTES
    - Azure AD OIDC Metadata endpoint - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#fetch-the-openid-connect-metadata-document
    - A Result of NotFound does not mean that the tenant does not exist at all, but it might be in a different cloud environment. Additional queries to other environments may result in it being found.
    - Requires CrossTenantInfo.ReadBasic.All scope to read MS Graph API info, i.e. Connect-MgGraph -Scopes CrossTenantInfo.ReadBasic.All
    - THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.
    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.

#>

function Resolve-MsIdTenant {
    [CmdletBinding(DefaultParameterSetName = 'Parameter Set 1',
        SupportsShouldProcess = $false,
        PositionalBinding = $false,
        HelpUri = 'http://www.microsoft.com/',
        ConfirmImpact = 'Medium')]
    [Alias()]
    [OutputType([String])]
    Param (
        # The TenantId in GUID Format or TenantDomainName in DNS Name format to attempt to resolve to Azure AD tenant
        [Parameter(Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false,
            ParameterSetName = 'Parameter Set 1')]
        [ValidateNotNull()]
        [ValidateNotNullOrEmpty()]
        [Alias("TenantId")]
        [Alias("DomainName")]
        [string[]]
        $TenantValue,
        # Environment to Resolve Azure AD Tenant In (Global, USGov, China, USGovDoD, Germany)
        [Parameter(Mandatory = $false,
            Position = 1,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false,
            ParameterSetName = 'Parameter Set 1')]
        [ValidateSet("Global", "USGov", "China", "USGovDoD", "Germany")]
        [string]
        $Environment = "Global",
        # Include resolving the value to an Azure AD tenant by the OIDC Metadata endpoint
        [switch]
        $SkipOidcMetadataEndPoint



    )

    begin {

        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgModulePrerequisites -ErrorVariable CriticalError)) { return }
        try { Test-MgModulePrerequisites 'CrossTenantInformation.ReadBasic.All' -ErrorAction Stop | Out-Null }
        catch { Write-Warning $_.Exception.Message }

        $GraphEndPoint = (Get-MgEnvironment -Name $Environment).GraphEndpoint
        $AzureADEndpoint = (Get-MgEnvironment -Name $Environment).AzureADEndpoint

        Write-Verbose ("$(Get-Date -f T) - Using $Environment login endpoint of $AzureADEndpoint")
        Write-Verbose ("$(Get-Date -f T) - Using $Environment Graph endpoint of $GraphEndPoint")
    }

    process {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        $i = 0
        foreach ($value in $TenantValue) {

            $i++
            Write-Verbose ("$(Get-Date -f T) - Checking Value {0} of {1} - Value: {2}" -f $i, ($($TenantValue | Measure-Object).count), $value)

            $ResolveUri = $null
            $ResolvedTenant = [ordered]@{}
            $ResolvedTenant.Environment = $Environment
            $ResolvedTenant.ValueToResolve = $value

            if (Test-IsGuid -StringGuid $value) {
                Write-Verbose ("$(Get-Date -f T) - Attempting to resolve AzureAD Tenant by TenantID {0}" -f $value)
                $ResolveUri = ("{0}/beta/tenantRelationships/findTenantInformationByTenantId(tenantId='{1}')" -f $GraphEndPoint, $Value)
                $ResolvedTenant.ValueFormat = "TenantId"
            }
            else {

                if (Test-IsDnsDomainName -StringDomainName $value) {
                    Write-Verbose ("$(Get-Date -f T) - Attempting to resolve AzureAD Tenant by DomainName {0}" -f $value)
                    $ResolveUri = ("{0}/beta/tenantRelationships/findTenantInformationByDomainName(domainName='{1}')" -f $GraphEndPoint, $Value)
                    $ResolvedTenant.ValueFormat = "DomainName"

                }
            }

            if ($null -ne $ResolveUri) {
                try {

                    Write-Verbose ("$(Get-Date -f T) - Resolving Tenant Information using MS Graph API")
                    $Resolve = Invoke-MgGraphRequest -Method Get -Uri $ResolveUri -ErrorAction Stop | Select-Object tenantId, displayName, defaultDomainName, federationBrandName

                    $ResolvedTenant.Result = "Resolved"
                    $ResolvedTenant.ResultMessage = "Resolved Tenant"
                    $ResolvedTenant.TenantId = $Resolve.TenantId
                    $ResolvedTenant.DisplayName = $Resolve.DisplayName
                    $ResolvedTenant.DefaultDomainName = $Resolve.defaultDomainName
                    $ResolvedTenant.FederationBrandName = $Resolve.federationBrandName
                }
                catch {

                    if ($_.Exception.Message -eq 'Response status code does not indicate success: NotFound (Not Found).') {
                        $ResolvedTenant.Result = "NotFound"
                        $ResolvedTenant.ResultMessage = "NotFound (Not Found)"
                    }
                    else {

                        $ResolvedTenant.Result = "Error"
                        $ResolvedTenant.ResultMessage = $_.Exception.Message

                    }

                    $ResolvedTenant.TenantId = $null
                    $ResolvedTenant.DisplayName = $null
                    $ResolvedTenant.DefaultDomainName = $null
                    $ResolvedTenant.FederationBrandName = $null

                }
            }
            else {

                $ResolvedTenant.ValueFormat = "Unknown"
                Write-Warning ("$(Get-Date -f T) - {0} value to resolve was not in GUID or DNS Name format, and will be skipped!" -f $value)
                $ResolvedTenant.Status = "Skipped"
            }


            if ($true -ne $SkipOidcMetadataEndPoint) {
                $oidcMetadataUri = ("{0}/{1}/v2.0/.well-known/openid-configuration" -f $AzureADEndpoint, $value)

                try {

                    $oidcMetadata = Invoke-RestMethod -Method Get -Uri $oidcMetadataUri -ErrorAction Stop
                    $resolvedTenant.OidcMetadataResult = "Resolved"
                    $resolvedTenant.OidcMetadataTenantId = $oidcMetadata.issuer.split("/")[3]
                    $resolvedTenant.OidcMetadataTenantRegionScope = $oidcMetadata.tenant_region_scope

                }
                catch {

                    $resolvedTenant.OidcMetadataResult = "NotFound"
                    $resolvedTenant.OidcMetadataTenantId = $null
                    $resolvedTenant.OidcMetadataTenantRegionScope = $null

                }
            }
            else {
                $resolvedTenant.OidcMetadataResult = "Skipped"
                $resolvedTenant.OidcMetadataTenantId = $null
                $resolvedTenant.OidcMetadataTenantRegionScope = $null
            }
            Write-Output ([pscustomobject]$ResolvedTenant)
        }

    }

    end {
    }
}


function Test-IsGuid {
    [OutputType([bool])]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]$StringGuid
    )

    $ObjectGuid = [System.Guid]::empty
    return [System.Guid]::TryParse($StringGuid, [System.Management.Automation.PSReference]$ObjectGuid) # Returns True if successfully parsed
}

function Test-IsDnsDomainName {
    [OutputType([bool])]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]$StringDomainName
    )
    $isDnsDomainName = $false
    $DnsHostNameRegex = "\A([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}\Z"
    Write-Verbose ("$(Get-Date -f T) - Checking if DomainName {0} is a valid Dns formatted Uri" -f $StringDomainName)
    if ($StringDomainName -match $DnsHostNameRegex) {
        If ("Dns" -eq [System.Uri]::CheckHostName($StringDomainName)) {
            $isDnsDomainName = $true
        }
    }

    return $isDnsDomainName
}

#endregion

#region Set-MsIdWindowsTlsSettings.ps1

<#
.SYNOPSIS
    Set TLS settings on Windows OS to use more secure TLS protocols.

.EXAMPLE
    PS > Set-MsIdWindowsTlsSettings -DotNetFwUseSystemDefault -DotNetFwUseStrongCrypto -IEDisableLegacySecurityProtocols

    Sets recommended TLS settings for .NET Framework applications and Internet Explorer (Internet Options) which should default to TLS 1.2+ on Windows 8/2012 and later.

.EXAMPLE
    PS > Set-MsIdWindowsTlsSettings -DisableClientLegacyTlsVersions

    Disables TLS 1.1 and earlier for the entire operating system.

#>

function Set-MsIdWindowsTlsSettings {
    [CmdletBinding()]
    param (
        # System-wide .NET Framework setting to allow the operating system to choose the protocol.
        [Parameter(Mandatory = $false)]
        [switch] $DotNetFwUseSystemDefault,
        # System-wide .NET Framework setting to use more secure network protocols (TLS 1.2, TLS 1.1, and TLS 1.0) and blocks protocols that are not secure.
        [Parameter(Mandatory = $false)]
        [switch] $DotNetFwUseStrongCrypto,
        # Internet Explorer (Internet Options) setting to disable use of TLS 1.1 and earlier.
        [Parameter(Mandatory = $false)]
        [switch] $IEDisableLegacySecurityProtocols,
        # System-wide Windows Secure Channel setting to disable all use of TLS 1.1 and earlier.
        [Parameter(Mandatory = $false)]
        [switch] $DisableClientLegacyTlsVersions
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-PsElevation)) {
            Write-Error 'This command sets machine-level registery settings which requires an elevated PowerShell session using Run as Administrator.' -ErrorVariable CriticalError
            return
        }
    }

    process {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        ## System-wide .NET Framework Settings
        # https://docs.microsoft.com/en-us/dotnet/framework/network-programming/tls#configuring-security-via-the-windows-registry
        if ($PSBoundParameters.ContainsKey('DotNetFwUseSystemDefault')) {
            if ($DotNetFwUseSystemDefault) {
                Write-Host @"
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727\SystemDefaultTlsVersions = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727\SystemDefaultTlsVersions = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319\SystemDefaultTlsVersions = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319\SystemDefaultTlsVersions = 1
"@

                ## .NET Framework 3.5
                New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -Name 'SystemDefaultTlsVersions' -Type Dword -Value $DotNetFwUseSystemDefault.ToBool()

                New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -Name 'SystemDefaultTlsVersions' -Type Dword -Value $DotNetFwUseSystemDefault.ToBool()

                ## .NET Framework 4 and above
                New-Item 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -Type Dword -Value $DotNetFwUseSystemDefault.ToBool()

                New-Item 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -Type Dword -Value $DotNetFwUseSystemDefault.ToBool()

            }
            else {
                Write-Host @"
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727\SystemDefaultTlsVersions
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727\SystemDefaultTlsVersions
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319\SystemDefaultTlsVersions
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319\SystemDefaultTlsVersions
"@

                ## .NET Framework 3.5
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -Name 'SystemDefaultTlsVersions' -ErrorAction Ignore
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -Name 'SystemDefaultTlsVersions' -ErrorAction Ignore

                ## .NET Framework 4 and above
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -ErrorAction Ignore
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -ErrorAction Ignore
            }
        }

        if ($PSBoundParameters.ContainsKey('DotNetFwUseStrongCrypto')) {
            if ($DotNetFwUseStrongCrypto) {
                Write-Host @"
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727\SchUseStrongCrypto = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727\SchUseStrongCrypto = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319\SchUseStrongCrypto = 1
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319\SchUseStrongCrypto = 1
"@

                ## .NET Framework 3.5
                New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -Name 'SchUseStrongCrypto' -Type Dword -Value $DotNetFwUseStrongCrypto.ToBool()

                New-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -Name 'SchUseStrongCrypto' -Type Dword -Value $DotNetFwUseStrongCrypto.ToBool()

                ## .NET Framework 4 and above
                New-Item 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Type Dword -Value $DotNetFwUseStrongCrypto.ToBool()

                New-Item 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -ErrorAction Ignore | Out-Null
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Type Dword -Value $DotNetFwUseStrongCrypto.ToBool()
            }
            else {
                Write-Host @"
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727\SchUseStrongCrypto
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727\SchUseStrongCrypto
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319\SchUseStrongCrypto
Removing Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319\SchUseStrongCrypto
"@

                ## .NET Framework 3.5
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v2.0.50727' -Name 'SchUseStrongCrypto' -ErrorAction Ignore
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v2.0.50727' -Name 'SchUseStrongCrypto' -ErrorAction Ignore

                ## .NET Framework 4 and above
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -ErrorAction Ignore
                Remove-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -ErrorAction Ignore
            }
        }

        ## Internet Explorer (Internet Options) Settings
        if ($PSBoundParameters.ContainsKey('IEDisableLegacySecurityProtocols')) {
            Write-Host @"
Setting Registery Value: HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\SecurityProtocols
Setting Registery Value: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\SecurityProtocols
The latter is only relevant when loopback processing of group policy is enabled.
"@

            New-Item 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction Ignore | Out-Null
            if ($IEDisableLegacySecurityProtocols) {
                ## Current User Internet Options
                $SecurityProtocols = Get-ItemPropertyValue 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -ErrorAction Ignore
                $SecurityProtocols = $SecurityProtocols -band -bnot 32 -band -bnot 128 -band -bnot 512  # Disable SSL 3.0, TLS 1.0, and TLS 1.1
                $SecurityProtocols = $SecurityProtocols -bor 2048 -bor 8192  # Enable TLS 1.2 and TLS 1.3
                Set-ItemProperty 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -Type Dword -Value $SecurityProtocols

                ## System-wide Internet Options (Only relevant when loopback processing of group policy is enabled)
                # https://docs.microsoft.com/en-us/troubleshoot/windows-server/group-policy/loopback-processing-of-group-policy
                try { $SecurityProtocols = Get-ItemPropertyValue 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -ErrorAction Ignore } catch {}
                $SecurityProtocols = $SecurityProtocols -band -bnot 32 -band -bnot 128 -band -bnot 512  # Disable SSL 3.0, TLS 1.0, and TLS 1.1
                $SecurityProtocols = $SecurityProtocols -bor 2048 -bor 8192  # Enable TLS 1.2 and TLS 1.3
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -Type Dword -Value $SecurityProtocols
            }
            else {
                ## Current User Internet Options
                $SecurityProtocols = Get-ItemPropertyValue 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -ErrorAction Ignore
                $SecurityProtocols = $SecurityProtocols -bor 128 -bor 512  # Re-Enable TLS 1.0 and TLS 1.1
                Set-ItemProperty 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -Type Dword -Value $SecurityProtocols

                ## System-wide Internet Options (Only relevant when loopback processing of group policy is enabled)
                # https://docs.microsoft.com/en-us/troubleshoot/windows-server/group-policy/loopback-processing-of-group-policy
                try { $SecurityProtocols = Get-ItemPropertyValue 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -ErrorAction Ignore } catch {}
                $SecurityProtocols = $SecurityProtocols -bor 128 -bor 512  # Re-Enable TLS 1.0 and TLS 1.1
                Set-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings' -Name 'SecureProtocols' -Type Dword -Value $SecurityProtocols
            }
        }

        ## System-wide Windows Settings
        # https://docs.microsoft.com/en-US/troubleshoot/windows-server/windows-security/restrict-cryptographic-algorithms-protocols-schannel
        if ($PSBoundParameters.ContainsKey('DisableClientLegacyTlsVersions')) {
            [string[]] $LegacyTls = 'SSL 2.0', 'SSL 3.0', 'TLS 1.0', 'TLS 1.1'
            if ($DisableClientLegacyTlsVersions) {
                New-Item "Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols" -ErrorAction Ignore | Out-Null
                foreach ($Protocol in $LegacyTls) {
                    Write-Host @"
Setting Registery Value: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Client\Enabled = 0
"@

                    New-Item "Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol" -ErrorAction Ignore | Out-Null
                    New-Item "Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Client" -ErrorAction Ignore | Out-Null
                    Set-ItemProperty "Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Client" -Name 'Enabled' -Type Dword -Value (!$DisableClientLegacyTlsVersions.ToBool())
                }
            }
            else {
                foreach ($Protocol in $LegacyTls) {
                    Write-Host @"
Removing Registery Value: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Client\Enabled
"@

                    Remove-ItemProperty "Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Client" -Name 'Enabled' -ErrorAction Ignore
                }
            }
        }
    }

    end {
        ## Return Immediately On Critical Error
        if ($CriticalError) { return }

        Write-Warning "These setting updates only effect new process so you will need to restart your apps for these settings to take effect."
    }
}

#endregion

#region Get-MsIdSigningKeyThumbprint.ps1

<#
.SYNOPSIS
    Get signing keys used by Azure AD.
    
.EXAMPLE
    PS > Get-MsIdSigningKeyThumbprint

    Get common Azure AD signing key thumbprints.

.EXAMPLE
    PS > Get-MsIdSigningKeyThumbprint -Tenant <tenandId>

    Get Azure AD signing key thumbprints for the given tenant.

.EXAMPLE
    PS > Get-MsIdSigningKeyThumbprint -Tenant <tenandId> -Latest

    Get the latest Azure AD signing key thumbprint for the given tenant.

.EXAMPLE
    PS > Get-MsIdSigningKeyThumbprint -DownloadPath C:\temp

    Export the certificates to a folder destination.

#>


function Get-MsIdSigningKeyThumbprint{
    Param(
        # Tenant ID
        $Tenant = "common",

        # Cloud environment
        $Environment="prod",

        # Return the latest certificate
        [switch]$Latest,

        # Location to save certificate
        [string]$DownloadPath
    )

    process {

        $authority = "https://login.microsoftonline.com/"
        if($Environment.ToLower() -eq "china"){ $authority = "https://login.chinacloudapi.cn/" }

        $keysUrl = "$authority$Tenant/discovery/keys";
        $keysJson = ConvertFrom-Json (Invoke-WebRequest $keysUrl).Content

        $certs = @()
        foreach ($key in $keysJson.keys) {
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($key.x5c)
            $cert = new-object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$bytes)

            $certs += new-object PSObject -Property @{ 'Kid'=$key.kid; 'Thumbprint'=$cert.Thumbprint; 'NotAfter'=$cert.NotAfter; 'NotBefore'=$cert.NotBefore; 'Cert'=$cert }
        }

        if ($Latest) {
            $certs = $certs | sort -Descending {$_.NotBefore} | Select -First 1
        }

        if ($DownloadPath) {
            foreach ($cert in $certs) {
                $path = Join-Path $DownloadPath ($cert.Thumbprint.ToLower() + ".cer")
                [System.IO.File]::WriteAllBytes($path, $cert.Cert.Export("Cert"));
                Write-Host "Certificate successfully exported to $path"
            }
        }else{
            Write-Output $certs.Thumbprint 
        }
    }
}
#endregion

#region Update-MsIdApplicationSigningKeyThumbprint.ps1

<#
.SYNOPSIS
    Update a Service Princpal's preferredTokenSigningKeyThumbprint to the specified certificate thumbprint

.DESCRIPTION
    Update a Service Princpal's preferredTokenSigningKeyThumbprint to the specified certificate thumbprint
    For more information on Microsoft Identity platorm signing key rollover see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-signing-key-rollover

.EXAMPLE
    PS > Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -KeyThumbprint <Thumbprint>

    Update Application's preferred signing key to the specified thumbprint

.EXAMPLE
    PS > Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -Default

    Update Application's preferred signing key to default value null

.EXAMPLE
    PS > Get-MsIdSigningKeyThumbprint -Latest | Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId>

    Get the latest signing key thumbprint and set it as the perferred signing key on the application

#>

function Update-MsIdApplicationSigningKeyThumbprint {
    [CmdletBinding()]
    param (
        # Tenant ID
        $Tenant = "common",

        # Application ID
        [parameter(mandatory = $true)]
        [string]$ApplicationId,

        # Thumbprint of certificate
        [parameter(ValueFromPipeline = $true)]
        [string]$KeyThumbprint,

        # Return preferredTokenSigningKeyThumbprint to default value
        [parameter(parametersetname = "Default")]
        [switch]$Default
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgServicePrincipal', 'Update-MgServicePrincipal' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        if ($Default) {
            Write-Verbose "Default flag set. preferredTokenSigningKeyThumbprint will be set to null "
            $KeyThumbprint = $null
        }

        if ($null -ne $KeyThumbprint) {
            $KeyThumbprint = $KeyThumbprint.Replace(" ", "").ToLower()
        }

        $body = @{preferredTokenSigningKeyThumbprint = $KeyThumbprint } | ConvertTo-Json
        $body = $body.replace('""','null')

        Write-Verbose "Retrieving Service Principal"
        $sp = Get-MgServicePrincipal -Filter "appId eq '$ApplicationId'"

        if ($null -ne $sp) {
            Write-Verbose "Service Principal found: $($sp.DisplayName)"
            Write-Verbose "Updating Service Principal preferredTokenSigningKeyThumbprint"
            Invoke-MgGraphRequest -Method "PATCH" -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($sp.id)" -Body $body
        }
        else {
            Write-Error "Service principal was not found - Please check the Client (Application) ID"
        }
    }
}

#endregion

#region Get-MsIdIsViralUser.ps1

<#
.SYNOPSIS
    Returns true if the user's mail domain is a viral (unmanaged) Azure AD tenant.

.DESCRIPTION
    To learn more about viral tenants see [Take over an unmanaged directory as administrator in Azure Active Directory](https://docs.microsoft.com/azure/active-directory/enterprise-users/domains-admin-takeover)

.EXAMPLE
    PS > Get-MsIdIsViralUser -Mail john@yopmail.net

    Check if the mail address is from a viral tenant.

#>

function Get-MsIdIsViralUser {
    [CmdletBinding()]

    param (
        # The email address of the external user.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)]
        [string] $Mail
    )

    $userRealm = Get-MsftUserRealm $Mail

    $isExternalAzureADViral = (Get-ObjectPropertyValue $userRealm 'IsViral') -eq "True"

    return $isExternalAzureADViral
}

#endregion

#region Get-MsIdHasMicrosoftAccount.ps1

<#
.SYNOPSIS
    Returns true if the user's mail is a Microsoft Account
    
.EXAMPLE
    PS > Get-MsIdHasMicrosoftAccount -Mail john@yopmail.net

    Check if the mail address has a Microsoft account

#>

function Get-MsIdHasMicrosoftAccount {
    [CmdletBinding()]

    param (
        # The email address of the external user.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)]
        [string] $Mail
    )

    $userRealm = Get-MsftUserRealm $Mail -CheckForMicrosoftAccount
    
    $isMSA = (Get-ObjectPropertyValue $userRealm 'MicrosoftAccount') -eq "0"

    return $isMSA
}

#endregion

#region Get-MsIdGroupWritebackConfiguration.ps1

<#
.SYNOPSIS
    Gets the group writeback configuration for the group ID

.EXAMPLE
    PS > Get-MsIdGroupWritebackConfiguration -GroupId <GroupId>

    Get Group Writeback for Group ID

.EXAMPLE
    PS > Get-MsIdGroupWritebackConfiguration -Group <Group>

    Get Group Writeback for Group

.EXAMPLE
    PS > Get-mggroup -filter "groupTypes/any(c:c eq 'Unified')"|Get-MsIdGroupWritebackConfiguration -verbose

    Get the WritebackConfiguration for all M365 Groups in the tenant

.NOTES
    THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.
    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.
#>

function Get-MsIdGroupWritebackConfiguration {
    [CmdletBinding(DefaultParameterSetName = 'ObjectId')]
    param (
        # Group Object ID
        [Parameter(Mandatory = $true, ParameterSetName = 'ObjectId', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript( {
                try {
                    [System.Guid]::Parse($_) | Out-Null
                    $true
                }
                catch {
                    throw "$_ is not a valid ObjectID format. Valid value is a GUID format only."
                }
            })]
        [string[]] $GroupId,

        # Group Object
        [Parameter(Mandatory = $true, ParameterSetName = 'GraphGroup', Position = 1, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Object[]] $Group
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgGroup' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        if ($null -ne $Group) {
            $GroupId = $group.id
        }

        foreach ($gid in $GroupId) {
            Write-Verbose ("Retrieving Group Writeback Settings for Group ID {0}" -f $gid)
            $checkedGroup = [ordered]@{}
            $mgGroup = $null
            $cloudGroupType = $null


            Write-Verbose ("Retrieving mgGroup for Group ID {0}" -f $gid)
            $mgGroup = Get-MgGroup -GroupId $gid
            Write-Debug ($mgGroup | Select-Object -Property Id, DisplayName, GroupTypes, SecurityEnabled, OnPremisesSyncEnabled -Expand WritebackConfiguration | Select-Object -Property Id, DisplayName, GroupTypes, SecurityEnabled, OnPremisesSyncEnabled, IsEnabled, OnPremisesGroupType | Out-String)

            $checkedGroup.id = $mgGroup.Id
            $checkedGroup.DisplayName = $mgGroup.DisplayName
            $checkedGroup.SourceOfAuthority = if ($mgGroup.OnPremisesSyncEnabled -eq $true) { "On-Premises" }else { "Cloud" }


            if ($mgGroup.GroupTypes -contains 'Unified') {
                $cloudGroupType = "M365"
            }
            else {

                if ($mgGroup.SecurityEnabled -eq $true) {
                    $cloudGroupType = "Security"

                    if ($null -notlike $mgGroup.ProxyAddresses) {
                        $cloudGroupType = "Mail-Enabled Security"
                    }
                }
                else {
                    $cloudGroupType = "Distribution"
                }
            }


            $checkedGroup.Type = $cloudGroupType

            if ($checkedGroup.SourceOfAuthority -eq 'On-Premises') {
                $checkedGroup.WriteBackEnabled = "N/A"
                $checkedGroup.WriteBackOnPremGroupType = "N/A"
                $checkedGroup.EffectiveWriteBack = "On-Premises is Source Of Authority for Group"
            }
            else {

                switch ($checkedGroup.Type) {
                    "Distribution" {
                        $checkedGroup.WriteBackEnabled = "N/A"
                        $checkedGroup.WriteBackOnPremGroupType = "N/A"
                        $checkedGroup.EffectiveWriteBack = "Cloud Distribution Groups are not supported for group writeback to on-premises. Use M365 groups instead."

                    }

                    "Mail-Enabled Security" {
                        $checkedGroup.WriteBackEnabled = "N/A"
                        $checkedGroup.WriteBackOnPremGroupType = "N/A"
                        $checkedGroup.EffectiveWriteBack = "Cloud mail-enabled security groups are not supported for group writeback to on-premises. Use M365 groups instead."

                    }
                    Default {

                        $writebackEnabled = $null

                        switch ($mgGroup.writebackConfiguration.isEnabled) {
                            $true { $writebackEnabled = "TRUE" }
                            $false { $writebackEnabled = "FALSE" }
                            $null { $writebackEnabled = "NOTSET" }
                        }


                        if ($null -ne ($mgGroup.writebackConfiguration.onPremisesGroupType)) {
                            $WriteBackOnPremGroupType = $mgGroup.writebackConfiguration.onPremisesGroupType
                        }
                        else {
                            if ($checkedGroup.Type -eq 'M365') {
                                $WriteBackOnPremGroupType = "universalDistributionGroup (M365 DEFAULT)"
                            }
                            else {
                                $WriteBackOnPremGroupType = "universalSecurityGroup (Security DEFAULT)"
                            }
                        }

                        $checkedGroup.WriteBackEnabled = $writebackEnabled
                        $checkedGroup.WriteBackOnPremGroupType = $WriteBackOnPremGroupType

                        if ($checkedGroup.Type -eq 'M365') {
                            if ($checkedGroup.WriteBackEnabled -ne $false) {
                                $checkedGroup.EffectiveWriteBack = ("Cloud M365 group will be written back onprem as {0} grouptype" -f $WriteBackOnPremGroupType)
                            }
                            else {
                                $checkedGroup.EffectiveWriteBack = "Cloud M365 group will NOT be written back on-premises"
                            }
                        }

                        if ($checkedGroup.Type -eq 'Security') {
                            if ($checkedGroup.WriteBackEnabled -eq $true) {
                                $checkedGroup.EffectiveWriteBack = ("Cloud security group will be written back onprem as {0} grouptype" -f $WriteBackOnPremGroupType)
                            }
                            else {
                                $checkedGroup.EffectiveWriteBack = "Cloud security will NOT be written back on-premises"
                            }
                        }
                    }
                }
            }
            Write-Output ([pscustomobject]$checkedGroup)

        }
    }

    end {
        if ($CriticalError) { return }
    }
}

#endregion

#region Update-MsIdGroupWritebackConfiguration.ps1

<#
.SYNOPSIS
    Update an Azure AD cloud group settings to writeback as an AD on-premises group

.EXAMPLE
    PS > Update-MsIdGroupWritebackConfiguration -GroupId <GroupId> -WriteBackEnabled $false

    Disable Group Writeback for Group ID

.EXAMPLE
    PS > Update-MsIdGroupWritebackConfiguration -GroupId <GroupId> -WriteBackEnabled $true -WriteBackOnPremGroupType universalDistributionGroup

    Enable Group Writeback for Group ID as universalDistributionGroup on-premises
.EXAMPLE
    PS > Update-MsIdGroupWritebackConfiguration -GroupId <GroupId> -WriteBackEnabled $false

    Disable Group Writeback for Group ID
.EXAMPLE
    PS > Get-mggroup -filter "groupTypes/any(c:c eq 'Unified')"|Update-MsIdGroupWritebackConfiguration -WriteBackEnabled $false -verbose

    For all M365 Groups in the tenant, set the WritebackEnabled to false to prevent them from being written back on-premises
.NOTES
    - Updating Role Assignable Groups or Privileged Access Groups require PrivilegedAccess.ReadWrite.AzureADGroup permission scope

    THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.
    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.

#>

function Update-MsIdGroupWritebackConfiguration {
    [CmdletBinding(DefaultParameterSetName = 'ObjectId')]
    param (
        # Group Object ID
        [Parameter(Mandatory = $true, ParameterSetName = 'ObjectId', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript( {
                try {
                    [System.Guid]::Parse($_) | Out-Null
                    $true
                }
                catch {
                    throw "$_ is not a valid ObjectID format. Valid value is a GUID format only."
                }
            })]
        [string[]] $GroupId,

        # Group Object
        [Parameter(Mandatory = $true, ParameterSetName = 'GraphGroup', Position = 1, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Object[]] $Group,

        # WritebackEnabled true or false
        [Parameter(Mandatory = $true, Position = 2, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
        [bool] $WriteBackEnabled,

        # On-Premises Group Type cloud group is written back as
        [Parameter(Mandatory = $false, Position = 3, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
        [ValidateSet("universalDistributionGroup", "universalSecurityGroup", "universalMailEnabledSecurityGroup")]
        [string] $WriteBackOnPremGroupType
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgGroup', 'Update-MgGroup' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        if ($null -ne $Group) {
            $GroupId = $group.id
        }

        foreach ($gid in $GroupId) {
            $currentIsEnabled = $null
            $currentOnPremGroupType = $null
            $mgGroup = $null
            $groupSourceOfAuthority = $null
            $wbc = @{}
            $skipUpdate = $false

            Write-Verbose ("Retrieving mgGroup for Group ID {0}" -f $gid)
            $mgGroup = Get-MgGroup -GroupId $gid
            Write-Debug ($mgGroup | Select-Object -Property Id, DisplayName, GroupTypes, SecurityEnabled, OnPremisesSyncEnabled -Expand WritebackConfiguration | Select-Object -Property Id, DisplayName, GroupTypes, SecurityEnabled, OnPremisesSyncEnabled, IsEnabled, OnPremisesGroupType | Out-String)


            $currentOnPremGroupType = $mgGroup.WritebackConfiguration.OnPremisesGroupType

            $currentIsEnabled = $mgGroup.WritebackConfiguration.IsEnabled
            $cloudGroupType = $null

            if ($mggroup.GroupTypes -contains 'Unified') {
                $cloudGroupType = "M365"
            }
            else {

                if ($mgGroup.SecurityEnabled -eq $true) {
                    $cloudGroupType = "Security"

                    if ($null -notlike $mgGroup.ProxyAddresses) {
                        $cloudGroupType = "Mail-Enabled Security"
                    }
                }
                else {
                    $cloudGroupType = "Distribution"
                }
            }

            $groupSourceOfAuthority = if ($mgGroup.OnPremisesSyncEnabled -eq $true) { "On-Premises" }else { "Cloud" }


            if ($groupSourceOfAuthority -eq 'On-Premises') {
                $skipUpdate = $true
                Write-Verbose ("Group {0} is an on-premises SOA group and will not be updated." -f $gid)
            }
            else {

                switch ($cloudGroupType) {
                    "Distribution" {
                        $skipUpdate = $true
                        Write-Error ("Group {0} is a cloud distribution group and will NOT be updated. Cloud Distribution Groups are not supported for group writeback to on-premises. Use M365 groups instead." -f $gid)

                    }
                    "Mail-Enabled Security" {
                        $skipUpdate = $true
                        Write-Error ("Group {0} is a mail-enabled security group and will NOT be updated. Cloud mail-enabled security groups are not supported for group writeback to on-premises. Use M365 groups instead." -f $gid)

                    }
                    "Security" {


                        Write-Verbose ("Group {0} is a Security Group with current IsEnabled of {1} and onPremisesGroupType of {2}." -f $gid, $currentIsEnabled, $currentOnPremGroupType)

                        if ($currentIsEnabled -eq $WriteBackEnabled) {
                            $skipUpdate = $true
                            Write-Verbose "WriteBackEnabled $WriteBackEnabled already set for Security Group!"
                        }
                        else {

                            $wbc.isEnabled = $WriteBackEnabled

                            if ($null -eq $currentOnPremGroupType -or $currentOnPremGroupType -ne 'universalSecurityGroup') {
                                if ($null -ne $WriteBackOnPremGroupType -and $WriteBackOnPremGroupType -ne 'universalSecurityGroup') {
                                    $skipUpdate = $true
                                    Write-Error ("{0} is not a cloud security group and can only be written back as a univeralSecurityGroup type which is not currently set for this group!" -f $gid)

                                }
                                else {


                                    if ($null -eq $currentOnPremGroupType -ne $WriteBackOnPremGroupType) {
                                        $wbc.onPremisesGroupType = $WriteBackOnPremGroupType
                                    }
                                }
                            }

                        }
                    }

                    "M365" {

                        Write-Verbose ("Group {0} is an M365 Group with current IsEnabled of {1} and onPremisesGroupType of {2}." -f $gid, $currentIsEnabled, $currentOnPremGroupType)
                        if ($currentIsEnabled -eq $WriteBackEnabled) {
                            $skipUpdate = $true
                            Write-Verbose "WriteBackEnabled $WriteBackEnabled already set for M365 Group!"
                        }
                        else {

                            $wbc.isEnabled = $WriteBackEnabled

                            if ($currentOnPremGroupType -ne $WriteBackOnPremGroupType) {


                                $wbc.onPremisesGroupType = $WriteBackOnPremGroupType

                            }

                        }

                    }

                }

                if ($wbc.Count -eq 0) {
                    $skipUpdate = $true
                }

                if ($skipUpdate -ne $true) {
                    Write-Debug ($wbc | Out-String)
                    Write-Verbose ("Updating Group {0} with Group Writeback settings of Writebackenabled={1} and onPremisesGroupType={2}" -f $gid, $WriteBackEnabled, $WriteBackOnPremGroupType )

                    if ($null -like $wbc.onPremisesGroupType) {

                        # Workaround for null properties issue filed on GitHub - https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/833
                        $root = @{}
                        $root.writebackConfiguration = $wbc
                        $jbody = ($root | ConvertTo-Json -Depth 10 -Compress).Replace('""', 'null')

                        Update-MgGroup -GroupId $gid -BodyParameter $jbody
                    }
                    else {
                        Update-MgGroup -GroupId $gid -writeBackConfiguration $wbc -ErrorAction Stop
                    }

                    Write-Verbose ("Group Updated!")
                }
                else {

                    Write-Verbose ("No effective updates to group applied!")
                }

            }
        }
    }

    end {
        if ($CriticalError) { return }
    }
}

#endregion

#region Get-MsIdUnredeemedInvitedUser.ps1

<#
.SYNOPSIS
    Retrieve Users who have not had interactive sign ins since XX days ago

.EXAMPLE
    PS > Get-MsIdUnredeemedInvitedUser -InvitedBeforeDaysAgo 30

    Retrieve Users who have been invited but have not redeemed greater than XX days ago

.INPUTS
    System.Int
.NOTES
    - Updating Role Assignable Groups or Privileged Access Groups require PrivilegedAccess.ReadWrite.AzureADGroup permission scope
    THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.
    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.

#>

function Get-MsIdUnredeemedInvitedUser {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # External User Invited XX Days Ago
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [int] $InvitedBeforeDaysAgo = 30
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgUser' -MinimumVersion 2.8.0 -RequireListPermissions -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        $queryUsers = $null

        $queryDate = Get-Date (Get-Date).AddDays($(0 - $InvitedBeforeDaysAgo)) -UFormat %Y-%m-%dT00:00:00Z

        $UnredeemedFilter = "(externalUserState eq 'PendingAcceptance')"
        #$UnredeemedFilter = "(externalUserState eq 'PendingAcceptance') and createdDateTime -lt '$queryDate'"
        #To Add: Detection for invited users without externalUserState values

        $queryFilter = $UnredeemedFilter

        Write-Debug ("Retrieving Invited Users who are not redeemed with filter {0}" -f $queryFilter)
        $queryUsers = Get-MgUser -Filter $queryFilter -All:$true -OrderBy 'createdDateTime' -Property ExternalUserState, ExternalUserStateChangeDateTime, UserPrincipalName, Id, DisplayName, mail, userType, AccountEnabled, CreatedDateTime -ConsistencyLevel eventual -CountVariable UnredeemedUsersCount

        Write-Verbose ("{0} Unredeemed Invite Users Found!" -f $UnredeemedUsersCount)
        foreach ($userObject in $queryUsers) {
            Write-Verbose ("User {0}" -f $userObject.id)
            $checkedUser = [ordered] @{}
            $checkedUser.UserID = $userObject.Id
            $checkedUser.DisplayName = $userObject.DisplayName
            $checkedUser.Mail = $userObject.Mail

            $checkedUser.AccountEnabled = $userObject.AccountEnabled
            $checkedUser.ExternalUserState = $userObject.ExternalUserState
            If ($null -eq $userObject.ExternalUserStateChangeDateTime) {
                $checkedUser.ExternalUserStateChangeDateTime = "Unknown"
                $checkedUser.InvitedDaysAgo = "Unknown"

            }
            else {
                $checkedUser.ExternalUserStateChangeDateTime = $userObject.ExternalUserStateChangeDateTime
                $checkedUser.InvitedDaysAgo = (New-TimeSpan -Start $userObject.ExternalUserStateChangeDateTime -End (Get-Date)).Days

            }



            $checkedUser.UserPrincipalName = $userObject.UserPrincipalName
            $checkedUser.UserType = $userObject.UserType
            $checkedUser.Identities = $userObject.Identities
            If ($null -eq $userObject.CreatedDateTime) {
                $checkedUser.CreatedDateTime = "Unknown"
                $checkedUser.CreatedDaysAgo = "Unknown"
            }
            else {
                $checkedUser.CreatedDateTime = $userObject.CreatedDateTime
                $checkedUser.CreatedDaysAgo = (New-TimeSpan -Start $userObject.CreatedDateTime -End (Get-Date)).Days
            }

            if ($checkedUser.ExternalUserStateChangeDateTime -eq 'Unknown' -or $checkedUser.InvitedDaysAgo -ge $InvitedBeforeDaysAgo) {
                Write-Output ([pscustomobject]$checkedUser)
            }
        }

    }
}

#endregion

#region Import-MsIdAdfsSampleApp.ps1

<#
.SYNOPSIS
        Imports a list availabe sample AD FS relyng party trust applications available in this module, the list is created by the Get-MsIdAdfsSampleApps cmdlet. These applications do NOT use real endpoints and are meant to be used as test applications.
.EXAMPLE
    PS >Get-MsIdAdfsSampleApp | Import-MsIdAdfsSampleApp

    Import the full list of sample AD FS apps to the local AD FS server.

.EXAMPLE
    PS >Get-MsIdAdfsSampleApp | Import-MsIdAdfsSampleApp -NamePreffix 'MsId '

    Import the full list of sample AD FS apps to the local AD FS server, adding the MsId prefix to the app name.

.EXAMPLE
    PS >Get-MsIdAdfsSampleApp SampleAppName | Import-MsIdAdfsSampleApp

    Import only the SampleAppName sample AD FS app to the local AD FS server (replace SampleAppName by one of the available apps).
#>

function Import-MsIdAdfsSampleApp {
    [CmdletBinding()]
    param(
      # Application identifier
      [Parameter(Mandatory=$true,
        Position=0,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
      [object[]]$Application,
      # Name prefix for the AD FS relying party
      [Parameter(Mandatory=$false)]
      [string]$NamePreffix = "",
      # Apply sample app default parameters to existing apps
      [Parameter(Mandatory=$false)]
      [switch]$Force = $false
    )

    $samplePolicy = "MsId Block Off Corp and VPN"

    if (Import-AdfsModule) {
        Try {
            foreach($RelyingParty in $Application) {
                Write-Verbose "Processing app '$($RelyingParty.Name)' with the supplied prefix '$($NamePreffix)'"

                $rpName = $NamePreffix + $RelyingParty.Name
                $targetIdentifier = $RelyingParty.Identifier
            
                $adfsApp = Get-ADFSRelyingPartyTrust -Name $rpName
                if ($null -eq $adfsApp) {
                    Write-Verbose "Creating application '$($rpName)'"
                    $null = Add-ADFSRelyingPartyTrust -Identifier $targetIdentifier -Name $rpName
                }            
                else {
                    if (-not $Force) {
                        throw "The application '" + $rpName + "' already exists, use -Force to ovewrite it."
                    }
                    Write-Verbose "Updating application '$($rpName)'"
                }

                Set-ADFSRelyingPartyTrust -TargetName $rpName -AutoUpdateEnabled $RelyingParty.AutoUpdateEnabled
                Set-ADFSRelyingPartyTrust -TargetName $rpName -DelegationAuthorizationRules $RelyingParty.DelegationAuthorizationRules
                Set-ADFSRelyingPartyTrust -TargetName $rpName -IssuanceAuthorizationRules $RelyingParty.IssuanceAuthorizationRules
                Set-ADFSRelyingPartyTrust -TargetName $rpName -WSFedEndpoint $RelyingParty.WSFedEndpoint
                Set-ADFSRelyingPartyTrust -TargetName $rpName -IssuanceTransformRules $RelyingParty.IssuanceTransformRules
                Set-ADFSRelyingPartyTrust -TargetName $rpName -ClaimAccepted $RelyingParty.ClaimsAccepted
                Set-ADFSRelyingPartyTrust -TargetName $rpName -EncryptClaims $RelyingParty.EncryptClaims
                Set-ADFSRelyingPartyTrust -TargetName $rpName -EncryptionCertificate $RelyingParty.EncryptionCertificate
                Set-ADFSRelyingPartyTrust -TargetName $rpName -MetadataUrl $RelyingParty.MetadataUrl
                Set-ADFSRelyingPartyTrust -TargetName $rpName -MonitoringEnabled $RelyingParty.MonitoringEnabled
                Set-ADFSRelyingPartyTrust -TargetName $rpName -NotBeforeSkew $RelyingParty.NotBeforeSkew
                Set-ADFSRelyingPartyTrust -TargetName $rpName -ImpersonationAuthorizationRules $RelyingParty.ImpersonationAuthorizationRules
                Set-ADFSRelyingPartyTrust -TargetName $rpName -ProtocolProfile $RelyingParty.ProtocolProfile
                Set-ADFSRelyingPartyTrust -TargetName $rpName -RequestSigningCertificate $RelyingParty.RequestSigningCertificate
                Set-ADFSRelyingPartyTrust -TargetName $rpName -EncryptedNameIdRequired $RelyingParty.EncryptedNameIdRequired
                Set-ADFSRelyingPartyTrust -TargetName $rpName -SignedSamlRequestsRequired $RelyingParty.SignedSamlRequestsRequired  
            
                $newSamlEndPoints = @()
                foreach ($SamlEndpoint in $RelyingParty.SamlEndpoints)
                {
                    # Is ResponseLocation defined?
                    if ($SamlEndpoint.ResponseLocation)
                    {
                    # ResponseLocation is not null or empty
                    $newSamlEndPoint = New-ADFSSamlEndpoint -Binding $SamlEndpoint.Binding `
                        -Protocol $SamlEndpoint.Protocol `
                        -Uri $SamlEndpoint.Location -Index $SamlEndpoint.Index `
                        -IsDefault $SamlEndpoint.IsDefault
                    }
                    else
                    {
                    $newSamlEndPoint = New-ADFSSamlEndpoint -Binding $SamlEndpoint.Binding `
                        -Protocol $SamlEndpoint.Protocol `
                        -Uri $SamlEndpoint.Location -Index $SamlEndpoint.Index `
                        -IsDefault $SamlEndpoint.IsDefault `
                        -ResponseUri $SamlEndpoint.ResponseLocation
                    }
                    $newSamlEndPoints += $newSamlEndPoint
                }
                Set-ADFSRelyingPartyTrust -TargetName $rpName -SamlEndpoint $newSamlEndPoints
                Set-ADFSRelyingPartyTrust -TargetName $rpName -SamlResponseSignature $RelyingParty.SamlResponseSignature
                Set-ADFSRelyingPartyTrust -TargetName $rpName -SignatureAlgorithm $RelyingParty.SignatureAlgorithm
                Set-ADFSRelyingPartyTrust -TargetName $rpName -TokenLifetime $RelyingParty.TokenLifetime

                # check if using custom plocy and test if exists
                if ($RelyingParty.AccessControlPolicyName -eq $samplePolicy) {
                    if (Get-AdfsAccessControlPolicy -Name $samplePolicy) {
                        Set-AdfsRelyingPartyTrust -TargetName $rpName -AccessControlPolicyName $RelyingParty.AccessControlPolicyName
                    }
                    else {
                        Write-Warning "The Access Control Policy '$($samplePolicy)' is missing, run 'Import-MsIdAdfsSamplePolicies' to create."
                    }
                }
                else {
                    Set-AdfsRelyingPartyTrust -TargetName $rpName -AccessControlPolicyName $RelyingParty.AccessControlPolicyName
                }
            }
        }            
        Catch {
            Write-Error $_
        }
    }
    else {
        Write-Error "The Import-MsIdAdfsSampleApps cmdlet requires the ADFS module installed to work."
    }
}
#endregion

#region Import-MsIdAdfsSamplePolicy.ps1

<#
.SYNOPSIS
    Imports the 'MsId Block Off Corp and VPN' sample AD FS access control policy. This policy is meant to be used as test policy.
.DESCRIPTION
    Imports the 'MsId Block Off Corp and VPN' sample AD FS access control policy. Pass locations in the format of range (205.143.204.1-205.143.205.250) or CIDR (12.159.168.1/24).

    This policy is meant to be used as test policy!
.EXAMPLE
    PS >Import-MsIdAdfsSamplePolicy -Locations 205.143.204.1-205.143.205.250,12.159.168.1/24,12.35.175.1/26

    Create the policy to the local AD FS server.

.EXAMPLE
    PS >Import-MsIdAdfsSamplePolicy -Locations 205.143.204.1-205.143.205.250 -ApplyTo App1,App2
    
    Create the policy to the local AD FS server and apply it to to the list of applications.
    
#>

function Import-MsIdAdfsSamplePolicy {
    [CmdletBinding()]
    param(
      # Network locations
      [Parameter(Mandatory=$true)]
      [string[]]$Locations,
      # Relying party names to apply the policy
      [Parameter(Mandatory=$false)]
      [string[]]$ApplyTo
    )

    $name = "MsId Block Off Corp and VPN"

    if (Import-AdfsModule) {
        Try {

            # build <Value> for each location
            $values = ""
            foreach ($location in $Locations) {
                $values += "<Value>$($location)</Value>"
            }

            # load and update metadata file
            $metadataBase = Get-Content "$($PSScriptRoot)\internal\AdfsSamples\AdfsAccessControlPolicy.xml" -Raw
            $metadataStr = $metadataBase -replace '<Values>.*</Values>',"<Values>$values</Values>"
            $metadata = New-Object -TypeName Microsoft.IdentityServer.PolicyModel.Configuration.PolicyTemplate.PolicyMetadata -ArgumentList $metadataStr

            $policy = Get-AdfsAccessControlPolicy -Name $name
            if ($null -eq $policy) {
                Write-Verbose "Creating Access Control Policy $($name)"
                $null = New-AdfsAccessControlPolicy -Name $name -Identifier "DenyNonCorporateandNonVPN" -PolicyMetadata $metadata
            }
            else {
                throw "The policy '" + $name + "' already exists."
            }

            if ($null -ne $ApplyTo) {
                foreach ($app in $ApplyTo) {
                    Set-AdfsRelyingPartyTrust -TargetName $app -AccessControlPolicyName $name
                }
            }
        }            
        Catch {
            Write-Error $_
        }
    }
    else {
        Write-Error "The Import-MsIdAdfsSampleApps cmdlet requires the ADFS module installed to work."
    }
}
#endregion

#region Get-MsIdAdfsSampleApp.ps1

<#
.SYNOPSIS
    Returns the list of availabe sample AD FS relyng party trust applications available in this module. These applications do NOT use real endpoints and are meant to be used as test applications.
.EXAMPLE
    PS > Get-MsIdAdfsSampleApps

    Get the full list of sample AD FS apps.

.EXAMPLE
    PS > Get-MsIdAdfsSampleApps SampleAppName

    Get only SampleAppName sample AD FS app (replace SampleAppName by one of the available apps).

#>

function Get-MsIdAdfsSampleApp {
    [CmdletBinding()]
    [OutputType([object[]])]
    param (
        # Sample applications name
        [Parameter(Mandatory = $false)]
        [string] $Name
    )

    $result = [System.Collections.ArrayList]@()

    if (Import-AdfsModule) {
        $apps = Get-ChildItem -Path "$($PSScriptRoot)\internal\AdfsSamples\"

        if ($Name -ne '') {
            $apps = $apps | Where-Object { $_.Name -eq $Name + '.json' } 
        }
    
        ForEach ($app in $apps) {
            Try {
                Write-Verbose "Loading app: $($app.Name)"
                if ($app.Name -notlike '*.xml') {
                    $rp = Get-Content $app.FullName | ConvertFrom-json
                    $null = $result.Add($rp)
                }
            }
            catch {
                Write-Warning "Error while loading app '$($app.Name)': ($_)"
            }
        }

        return ,$result
    }
}
#endregion

#region Get-MsIdInactiveSignInUser.ps1

<#
.SYNOPSIS
    Retrieve Users who have not had interactive sign ins since XX days ago

.EXAMPLE
    PS > Get-MsIdInactiveSignInUser -LastSignInBeforeDaysAgo 30

    Retrieve Users who have not signed in since 30 days ago from today

.INPUTS
    System.Int
.NOTES
    - Updating Role Assignable Groups or Privileged Access Groups require PrivilegedAccess.ReadWrite.AzureADGroup permission scope
    THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
    FITNESS FOR A PARTICULAR PURPOSE.
    This sample is not supported under any Microsoft standard support program or service.
    The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
    implied warranties including, without limitation, any implied warranties of merchantability
    or of fitness for a particular purpose. The entire risk arising out of the use or performance
    of the sample and documentation remains with you. In no event shall Microsoft, its authors,
    or anyone else involved in the creation, production, or delivery of the script be liable for
    any damages whatsoever (including, without limitation, damages for loss of business profits,
    business interruption, loss of business information, or other pecuniary loss) arising out of
    the use of or inability to use the sample or documentation, even if Microsoft has been advised
    of the possibility of such damages, rising out of the use of or inability to use the sample script,
    even if Microsoft has been advised of the possibility of such damages.

#>

function Get-MsIdInactiveSignInUser {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # User Last Sign In Activity is before Days ago
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [Alias("BeforeDaysAgo")]
        [int] $LastSignInBeforeDaysAgo = 30,
        # Return results for All, Member, or Guest userTypes
        [ValidateSet("All", "Member", "Guest")]
        [string]
        $UserType = "All"
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgUser' -MinimumVersion 2.8.0 -RequireListPermissions -ErrorVariable CriticalError)) { return }
    }

    process {
        if ($CriticalError) { return }

        $queryDate = Get-Date (Get-Date).AddDays($(0 - $LastSignInBeforeDaysAgo)) -UFormat %Y-%m-%dT00:00:00Z

        $inactiveFilter = ("(signInActivity/lastSignInDateTime le {0})" -f $queryDate)

        $queryFilter = $inactiveFilter

        # Using Date scope here, since conflict with service side odata filter on userType.
        Write-Debug ("Retrieving Users with Filter {0}" -f $queryFilter)
        $queryUsers = Get-MgUser -Filter $queryFilter -All:$true -Property signInActivity, UserPrincipalName, Id, DisplayName, mail, userType, createdDateTime

        switch ($UserType) {
            "Member" {
                $users = $queryUsers | Where-Object -FilterScript { $_.userType -eq 'Member' }
            }
            "Guest" {
                $users = $queryUsers | Where-Object -FilterScript { $_.userType -eq 'Guest' }

            }
            "All" {
                $users = $queryUsers
            }
        }

        foreach ($userObject in $users) {
            $checkedUser = [ordered] @{}
            $checkedUser.UserID = $userObject.Id
            $checkedUser.DisplayName = $userObject.DisplayName
            $checkedUser.UserPrincipalName = $userObject.UserPrincipalName
            $checkedUser.Mail = $userObject.Mail
            $checkedUser.UserType = $userObject.UserType

            If ($null -eq $userObject.signInActivity.LastSignInDateTime) {
                $checkedUser.LastSignInDateTime = "Unknown"
                $checkedUser.LastSigninDaysAgo = "Unknown"
                $checkedUser.lastNonInteractiveSignInDateTime = "Unknown"
            }
            else {
                $checkedUser.LastSignInDateTime = $userObject.signInActivity.LastSignInDateTime
                $checkedUser.LastSigninDaysAgo = (New-TimeSpan -Start $checkedUser.LastSignInDateTime -End (Get-Date)).Days
                $checkedUser.lastSignInRequestId = $userObject.signInActivity.lastSignInRequestId

                #lastNonInteractiveSignInDateTime is NULL
                If ($null -eq $userObject.signInActivity.lastNonInteractiveSignInDateTime){
                    $checkedUser.lastNonInteractiveSignInDateTime = "Unknown"
                    $checkedUser.LastNonInteractiveSigninDaysAgo = "Unknown"

                } else {
                    $checkedUser.lastNonInteractiveSignInDateTime = $userObject.signInActivity.lastNonInteractiveSignInDateTime
                    $checkedUser.LastNonInteractiveSigninDaysAgo = (New-TimeSpan -Start $checkedUser.lastNonInteractiveSignInDateTime -End (Get-Date)).Days
                    $checkedUser.lastNonInteractiveSignInRequestId = $userObject.signInActivity.lastNonInteractiveSignInRequestId
                }
            }
            If ($null -eq $userObject.CreatedDateTime) {
                $checkedUser.CreatedDateTime = "Unknown"
                $checkedUser.CreatedDaysAgo = "Unknown"
            }
            else {
                $checkedUser.CreatedDateTime = $userObject.CreatedDateTime
                $checkedUser.CreatedDaysAgo = (New-TimeSpan -Start $userObject.CreatedDateTime -End (Get-Date)).Days
            }

            Write-Output ([pscustomobject]$checkedUser)
        }

    }

    end {
        if ($CriticalError) { return }
    }
}

#endregion

#region Set-MsIdServicePrincipalVisibleInMyApps.ps1


    function Set-MsIdServicePrincipalVisibleInMyApps {
    <#
    .SYNOPSIS
        Toggles whether application service principals are visible when launching myapplications.microsoft.com (MyApps)

    .DESCRIPTION
        For each provided service principal ID, this cmdlet will add (or remove) the 'HideApp' tag to (or from) its list of tags.

        MyApps reads this tag to determine whether to show the service principal in the UX.

        -Verbose will give insight into the cmdlet's activities.

        Requires Application.ReadWrite.All (to manage service principals), i.e. Connect-MgGraph -Scopes Application.ReadWrite.All

    .PARAMETER Visible
        Whether to show or hide the SP. Supply $true or $false.

    .PARAMETER InFile
        A file specifying the list of SP IDs to process. Provide one guid per line with no other characters.

    .PARAMETER OutFile
        (Optional) The list of changed SPs is written to a file at this location for easy recovery. A default file will be generated if a path is not provided.

    .PARAMETER WhatIf
        (Optional) When set, shows which SPs would be changed without changing them.

    .PARAMETER Top
        (Optional) The number of SPs to process from the list with each request. Default 100.

    .PARAMETER Skip
        (Optional) Determines where in the list to begin executing.

    .PARAMETER Continue
        (Optional) After a failure due to request throttling, set this to the number of inputs that were evaluated before throttling began on the previous request.

    .EXAMPLE
        Set-MsIdServicePrincipalVisibleInMyApps -Visible $false -InFile .\sps.txt -OutFile .\output.txt -Verbose

        Adds the 'HideApp' tag for each Service Principal listed by guid in the sps.txt file. This ensures that the app is no longer visible in the MyApps portal.

        Creates a list of changed SPs, written to output.txt, at the script execution directory.

        Provides verbose output to assist with monitoring.

    .EXAMPLE
        Set-MsIdServicePrincipalVisibleInMyApps -Visible $true -InFile .\sps.txt -OutFile .\output.txt -Verbose

        Removes the 'HideApp' tag for each Service Principal listed by guid in the sps.txt file. This ensures that the app is visible in the MyApps portal.

        Creates a list of changed SPs, written to output.txt, at the script execution directory.

        Provides verbose output to assist with monitoring.

    .EXAMPLE
        Set-MsIdServicePrincipalVisibleInMyApps -Visible $true -InFile .\sps.txt -WhatIf

        Removes the 'HideApp' tag for each Service Principal listed by guid in the sps.txt file. This ensures that the app is visible in the MyApps portal.

        Provides a 'whatif' analysis to show what would've been updated without the -WhatIf switch.

    .EXAMPLE
        Set-MsIdServicePrincipalVisibleInMyApps -Visible $true -InFile .\sps.txt -WhatIf -Top 200

        Removes the 'HideApp' tag for each Service Principal listed by guid in the sps.txt file. This ensures that the app is visible in the MyApps portal.

        Provides a 'whatif' analysis to show what would've been updated without the -WhatIf switch. Processes 200 service principals.

    .NOTES
        THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
        OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR
        FITNESS FOR A PARTICULAR PURPOSE.

        This sample is not supported under any Microsoft standard support program or service.
        The script is provided AS IS without warranty of any kind. Microsoft further disclaims all
        implied warranties including, without limitation, any implied warranties of merchantability
        or of fitness for a particular purpose. The entire risk arising out of the use or performance
        of the sample and documentation remains with you. In no event shall Microsoft, its authors,
        or anyone else involved in the creation, production, or delivery of the script be liable for
        any damages whatsoever (including, without limitation, damages for loss of business profits,
        business interruption, loss of business information, or other pecuniary loss) arising out of
        the use of or inability to use the sample or documentation, even if Microsoft has been advised
        of the possibility of such damages, rising out of the use of or inability to use the sample script,
        even if Microsoft has been advised of the possibility of such damages.


    #>

    [CmdletBinding()]
    Param(

        [Parameter(Mandatory=$true)]
        [bool]$Visible,

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

        [Parameter(Mandatory=$false)]
        [string]$OutFile,

        [Parameter(Mandatory=$false)]
        [switch]$WhatIf,

        [Parameter(Mandatory=$false)]
        [int]$Top=100,

        [Parameter(Mandatory=$false)]
        [int]$Skip=0,

        [Parameter(Mandatory=$false)]
        [int]$Continue=0
    )

    begin {
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-MgServicePrincipal', 'Update-MgServicePrincipal' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }

        function ConvertTo-ValidGuid {
            Param(
                [Parameter(ValueFromPipeline=$true, Mandatory=$true)]
                [string]$Value
            )

            try {
                $id = [System.Guid]::Parse($value)
                return $id
            } catch {
                Write-Warning "$(Get-Date -f T) - Failed to parse SP id: $($value)"
                return $null
            }
        }

        function ConvertTo-ServicePrincipal {
            Param(
                [Parameter(ValueFromPipeline=$true, Mandatory=$true)]
                [string]$Value
            )

            try {
                $sp = Get-MgServicePrincipal -ServicePrincipalId $Value
                if ($null -eq $sp) {
                    Write-Warning "$(Get-Date -f T) - SP not found for id: $($Value)"
                }
                else {
                    Write-Verbose "$(Get-Date -f T) - SP found for id: $($Value)"
                }
                return $sp

            } catch {
                # 429 means we are being throttled
                # so we want to back off from additional calls as well
                if ($_ -Contains '429') {
                    throw $_
                }
                Write-Warning "$(Get-Date -f T) - Error retrieving SP: $($Value)"
                return $null
            }
        }

        function Assert-IsHidden {
            Param(
                [Parameter(Mandatory=$true)]
                [Object]$Sp,

                [Parameter(Mandatory=$true)]
                [bool]$Value
            )

            return ($Sp.Tags -Contains $Tag_HideApp) -eq $Value
        }

        function Set-IsHidden {
            Param(
                [Parameter(Mandatory=$true)]
                [Object]$Sp,

                [Parameter(Mandatory=$true)]
                [bool]$Value
            )

            if ($Value) {
                $tags = $Sp.Tags + $Tag_HideApp
            } else {
                if (($Sp.Tags.count -eq 1) -and ($Sp.Tags -Contains $Tag_HideApp)) {
                    $tags = @()
                } else {
                    $tags = $Sp.Tags | Where-Object {$_ -ne $Tag_HideApp}
                }
            }

            try {
                Update-MgServicePrincipal -ServicePrincipalId $Sp.Id -Tags $tags
                return $true
            } catch {
                # 429 means we are being throttled
                # so we want to back off from additional calls as well
                if ($_ -Contains '429') {
                    throw $_
                }
                Write-Warning "$(Get-Date -f T) - Error setting SP tags: $($Sp.Id)"
                return $false
            }
        }

        function New-OutFile {
            Param(
                [Parameter(Mandatory=$true)]
                [string]$Path
            )

            if (Test-Path -Path $Path) {
                Clear-Content -Path $Path
            } else {
                New-Item -Path $Path -ItemType "file" > $null
            }
        }
    }

    process {

        ## Return immediately on critical error
        if ($CriticalError) { return }

        #Define outfile
        if ([string]::IsNullOrEmpty($OutFile))
        {
            $OutFile = "sp-backup-$((New-Guid).ToString()).txt"
        }

        #Hide app tag
        $Tag_HideApp = 'HideApp'

        #Count variables
        $i = -1
        $updated = @()
        $count_NotParsed = 0
        $count_NotFound = 0
        $count_NotChanged = 0
        $count_NotSaved = 0
        $throttled = $false

        #Get the list of SPs to be processed
        $sps = Get-Content $InFile
        $total = $sps.Count
        Write-Verbose -Message "$(Get-Date -f T) - $($total) inputs to process"

        for ($i = $Continue; $i -lt $Top -and $i+$Skip -lt $total; $i++) {
            Write-Verbose -Message "$(Get-Date -f T) - Processing $($i)"
            if ($total -eq 1) {
                $value = $sps
            } else {
                $value = $sps[$i+$Skip]
            }
            Write-Verbose -Message "$(Get-Date -f T) - Input: $($value)"

            $id = $value | ConvertTo-ValidGuid
            if ($null -eq $id) {
                $count_NotParsed++
                continue
            }

            try {
                $sp = $id | ConvertTo-ServicePrincipal
                if ($null -eq $sp) {
                    $count_NotFound++
                    continue
                }
            } catch {
                $throttled = $true
                break
            }

            if (Assert-IsHidden -Sp $sp -Value (!$Visible)) {
                $count_NotChanged++
                continue
            }

            if ($WhatIf) {
                $updated += $sp.Id
            } else {
                try {
                    if (Set-IsHidden -Sp $sp -Value (!$Visible)) {
                        $updated += $sp.Id
                    } else {
                        $count_NotSaved++
                        continue
                    }
                } catch {
                    $throttled = $true
                    break
                }
            }
        }

        Write-Verbose -Message "$(Get-Date -f T) - Generating output"

        if ($Continue -eq 0 -and $Skip -eq 0) {
            New-OutFile -Path $OutFile
        }

        $updated | ForEach-Object { $_ | Add-Content -Path $OutFile }

        Write-Verbose -Message "$(Get-Date -f T) - $($count_NotParsed) inputs not parseable as guids"
        Write-Verbose -Message "$(Get-Date -f T) - $($count_NotFound) guids do not map to SP Ids"
        Write-Verbose -Message "$(Get-Date -f T) - $($count_NotChanged) SPs were already in the desired state"
        if ($WhatIf) {
            Write-Verbose -Message "$(Get-Date -f T) - $($updated.Count) SPs would be changed. A list of guids has been written to $($OutFile)"
        } else {
            Write-Verbose -Message "$(Get-Date -f T) - $($count_NotSaved) SPs had an error trying to save the change"
            Write-Verbose -Message "$(Get-Date -f T) - $($updated.Count) SPs were changed. A list of guids has been written to $($OutFile)"
        }

        if ($throttled) {
            Write-Warning "Operation throttled after processing $($i) items"
            if (!$WhatIf) {
                Write-Warning "$(Get-Date -f T) - Please wait 5 minutes then execute the following script to continue:"
                Write-Warning "$(Get-Date -f T) - Set-MsIdServicePrincipalVisibleInMyApps -InFile $($InFile) -OutFile $($OutFile) -Visible `$$($Visible) -Top $($Top) -Skip $($Skip) -Continue $($i)"
            }
        } elseif ($sps.Count -gt $Skip+$Top) {
            if (!$WhatIf) {
                Write-Verbose -Message "$(Get-Date -f T) - Run the following script to process the next batch of $($Top):"
                Write-Verbose -Message "$(Get-Date -f T) - Set-MsIdServicePrincipalVisibleInMyApps -InFile $($InFile) -OutFile $($OutFile) -Visible `$$($Visible) -Top $($Top) -Skip $($Skip+$Top)"
            }
        }
        if (!$WhatIf) {
                Write-Verbose -Message "$(Get-Date -f T) - Run the following script to roll back this operation:"
                Write-Verbose -Message "$(Get-Date -f T) - Set-MsIdServicePrincipalVisibleInMyApps -InFile $($OutFile) -Visible `$$(!$Visible)"

        }
    }
}

#endregion

#region Split-MsIdEntitlementManagementConnectedOrganization.ps1

<#
.Synopsis
Split elements of a connectedOrganization
.Description
Split elements of one or more Azure AD entitlement management connected organizations, returned by Get-MgEntitlementManagementConnectedOrganization, to simplify reporting.
.Inputs
Microsoft.Graph.PowerShell.Models.MicrosoftGraphConnectedOrganization

.EXAMPLE
    PS > Get-MgEntitlementManagementConnectedOrganization -All | Split-MsIdEntitlementManagementConnectedOrganization -ByIdentitySource | ft ConnectedOrganizationId,tenantId,domainName

    Display one row for each identity source in all the connected organizations with the tenant id or domain name of the identity source.

#>

function Split-MsIdEntitlementManagementConnectedOrganization {
    [CmdletBinding(DefaultParameterSetName = 'SplitByIdentitySource', PositionalBinding = $false, ConfirmImpact = 'Medium')]
    param(

        [Parameter(ValueFromPipeline = $true, ParameterSetName = 'SplitByIdentitySource')]
        [object[]]
        # The connected organization.
        ${ConnectedOrganization},
        # Flag to indicate that the output should be split by identity source.
        [Parameter(Mandatory = $true, ParameterSetName = 'SplitByIdentitySource')]
        [switch]
        ${ByIdentitySource}

    )

    begin {

    }

    process {
        if ($ByIdentitySource) {

            if ($null -ne $ConnectedOrganization.IdentitySources) {
                foreach ($is in $ConnectedOrganization.IdentitySources) {
                    # identity sources, as an abstract class, does not have any properties

                    $aObj = [pscustomobject]@{
                        ConnectedOrganizationId = $ConnectedOrganization.Id
                    }

                    $addl = $is.AdditionalProperties
                    foreach ($k in $addl.Keys) {
                        $isk = $k
                        $aObj | Add-Member -MemberType NoteProperty -Name $isk -Value $addl[$k] -Force
                    }

                    Write-Output $aObj
                }
            }
        }
    }

    end {

    }
}

#endregion

#region Update-InvitedUserSponsorsFromInvitedBy.ps1

<#
.Synopsis
   Update the Sponsors attribute to include the user who initially invited them to the tenant using the InvitedBy property. This script can be used to backfill Sponsors attribute for existing users.
.DESCRIPTION
   Update the Sponsors attribute to include the user who initially invited them to the tenant

.LINK
Feature Page: https://learn.microsoft.com/en-us/azure/active-directory/external-identities/b2b-sponsors
EM: https://learn.microsoft.com/en-us/azure/active-directory/governance/entitlement-management-access-package-create\

API Docs:
Sponsors api - https://learn.microsoft.com/en-us/graph/api/user-post-sponsors?view=graph-rest-beta
Invite api - https://learn.microsoft.com/en-us/graph/api/resources/invitation?view=graph-rest-beta
ELM - https://learn.microsoft.com/en-us/graph/api/resources/entitlementmanagement-overview?view=graph-rest-beta
Invited BY - https://learn.microsoft.com/en-us/graph/api/user-list-invitedby?view=graph-rest-beta

.EXAMPLE
   Update-InvitedUserSponsorsFromInvitedBy -All
   
   Enumerate all invited users in the Tenant and update Sponsors using InvitedBy value
.EXAMPLE
   Update-InvitedUserSponsorsFromInvitedBy -UserId user1@contoso.com,user2@contoso.com

   For only specified users in the tenant update Sponsors using InvitedBy value
#>

function Update-InvitedUserSponsorsFromInvitedBy {
    [CmdletBinding(SupportsShouldProcess,
        ConfirmImpact = 'High',
        DefaultParameterSetName = 'AllInvitedGuests')]
    param (

        # UserId of Guest User
        [Parameter(ParameterSetName = 'ByUsers')]
        [String[]]
        $UserId,
        # Enumerate and Update All Guest Users
        [Parameter(ParameterSetName = 'AllInvitedGuests')]
        [switch]
        $All
    )

    begin {
    
        ## Initialize Critical Dependencies
        $CriticalError = $null
        if (!(Test-MgCommandPrerequisites 'Get-Mguser', 'Update-Mguser' -MinimumVersion 2.8.0 -ErrorVariable CriticalError)) { return }
        
        $guestFilter = "(CreationType eq 'Invitiation')"

    }

    process {
        if ($CriticalError) { return }
        if ($All) {

            $InvitedUsers = Get-MgUser -Filter $guestFilter -All -ExpandProperty Sponsors
        }
        else {
            foreach ($user in $userId) {

                $InvitedUsers += Get-MgUser -UserId $user -ExpandProperty Sponsors
            }
        }

        if ($null -eq $InvitedUsers) {
            Write-Information "No Guest Users to Process!"
        }
        else {
            foreach ($InvitedUser in $InvitedUsers) {
                $invitedBy = $null

                $splatArgumentsGetInvitedBy = @{

                    Method = 'Get'
                    Uri    = ((Get-MgEnvironment -Name (Get-MgContext).Environment).GraphEndpoint +
                        "/beta/users/" + $InvitedUser.Id + "/invitedBy")


                }

                $invitedBy = Invoke-MgGraphRequest @splatArgumentsGetInvitedBy

                Write-Verbose ($invitedBy | ConvertTo-Json) 
                
                if ($null -ne $invitedBy -and $null -ne $invitedBy.value -and $null -ne (Get-ObjectPropertyValue $invitedBy.value -Property 'id')) {
                    Write-Verbose ("InvitedBy for Guest User {0}: {1}" -f $InvitedUser.DisplayName, $invitedBy.value.id)

                    if ($InvitedUser.Sponsors.id -notcontains $invitedBy.value.id) {
                        Write-Verbose ("Sponsors does not contain the user who invited them!")

                        if ($PSCmdlet.ShouldProcess(("{0} - {1}" -f $InvitedUser.displayName, $InvitedUser.id), "Update Sponsors")) {
                            try {

                                $sponsosUrl = $null
                                $dirobj = $null
                                $sponsorsRequestBody = $null

                                $sponsorUrl = ("https://graph.microsoft.com/beta/users/{0}" -f $invitedBy.value.id)
                                $dirObj = @{"sponsors@odata.bind" = @($sponsorUrl) }
                                $sponsorsRequestBody = $dirObj | ConvertTo-Json


                                Update-MgUser -UserId $InvitedUser.Id -BodyParameter $sponsorsRequestBody
                                Write-Verbose ("Sponsors Updated for {0}" -f $InvitedUser.DisplayName)

                            }
                            catch {

                                Write-Error $_

                            }
                        }


                    }
                    else {
                        Write-Verbose ("------------> Sponsors already contains the user who invited them!")   
                    }
                }
                else {
                    write-verbose ("------->InvitedBy is not available for this user!")
                }


            }
        }

    }

    end {

        Write-Verbose "Complete!"

    }
}

#endregion

#endregion

## Set Strict Mode for Module. https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/set-strictmode
Set-StrictMode -Version 3.0

## Display Warning on old PowerShell versions. https://docs.microsoft.com/en-us/powershell/scripting/install/PowerShell-Support-Lifecycle#powershell-end-of-support-dates
if ($PSVersionTable.PSVersion -lt [version]'7.0') {
    Write-Warning 'It is recommended to use this module with the latest version of PowerShell which can be downloaded here: https://aka.ms/install-powershell'
}

#Write-Warning 'It is recommended to update Microsoft Graph PowerShell SDK modules frequently because many commands in this module depend on them.'

class SamlMessage : xml {}

Export-ModuleMember -Function @('Add-MsIdServicePrincipal','Confirm-MsIdJwtTokenSignature','ConvertFrom-MsIdAadcAadConnectorSpaceDn','ConvertFrom-MsIdAadcSourceAnchor','ConvertFrom-MsIdUniqueTokenIdentifier','ConvertFrom-MsIdJwtToken','ConvertFrom-MsIdSamlMessage','Expand-MsIdJwtTokenPayload','Export-MsIdAppConsentGrantReport','Export-MsIdAzureMfaReport','Find-MsIdUnprotectedUsersWithAdminRoles','Get-MsIdProvisioningLogStatistics','Get-MsIdAdfsSamlToken','Get-MsIdAdfsWsFedToken','Get-MsIdAdfsWsTrustToken','Get-MsIdApplicationIdByAppId','Get-MsIdAuthorityUri','Get-MsIdAzureIpRange','Get-MsIdAzureUsers','Get-MsIdCrossTenantAccessActivity','Get-MsIdGroupWithExpiration','Get-MsIdMsftIdentityAssociation','Get-MsIdO365Endpoints','Get-MsIdOpenIdProviderConfiguration','Get-MsIdSamlFederationMetadata','Get-MsIdServicePrincipalIdByAppId','Get-MsIdUnmanagedExternalUser','Invoke-MsIdAzureAdSamlRequest','New-MsIdWsTrustRequest','New-MsIdClientSecret','New-MsIdSamlRequest','New-MsIdTemporaryUserPassword','Remove-MsidUserAuthenticationMethod','Reset-MsIdExternalUser','Resolve-MsIdTenant','Revoke-MsIdServicePrincipalConsent','Set-MsIdWindowsTlsSettings','Resolve-MsIdAzureIpAddress','Show-MsIdJwtToken','Show-MsIdSamlToken','Test-MsIdAzureAdDeviceRegConnectivity','Test-MsIdCBATrustStoreConfiguration','Get-MsIdSigningKeyThumbprint','Update-MsIdApplicationSigningKeyThumbprint','Get-MsIdIsViralUser','Get-MsIdHasMicrosoftAccount','Get-MsIdGroupWritebackConfiguration','Update-MsIdGroupWritebackConfiguration','Get-MsIdUnredeemedInvitedUser','Get-MsIdAdfsSampleApp','Import-MsIdAdfsSampleApp','Import-MsIdAdfsSamplePolicy','Get-MsIdInactiveSignInUser','Set-MsIdServicePrincipalVisibleInMyApps','Split-MsIdEntitlementManagementConnectedOrganization','Update-InvitedUserSponsorsFromInvitedBy') -Cmdlet @() -Variable @() -Alias @('Revoke-MsIdApplicationConsent','ConvertFrom-MsIdAzureAdImmutableId','Get-MsIdWsFedFederationMetadata','ConvertFrom-MsIdSamlRequest','ConvertFrom-MsIdSamlResponse')


# SIG # Begin signature block
# MIInwQYJKoZIhvcNAQcCoIInsjCCJ64CAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDtQbuNvrviqcHB
# 3HXGFH6flzRyRTvn0xIVjhTEhe3MLKCCDXYwggX0MIID3KADAgECAhMzAAADrzBA
# DkyjTQVBAAAAAAOvMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD
# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy
# b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p
# bmcgUENBIDIwMTEwHhcNMjMxMTE2MTkwOTAwWhcNMjQxMTE0MTkwOTAwWjB0MQsw
# CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u
# ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy
# b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
# AQDOS8s1ra6f0YGtg0OhEaQa/t3Q+q1MEHhWJhqQVuO5amYXQpy8MDPNoJYk+FWA
# hePP5LxwcSge5aen+f5Q6WNPd6EDxGzotvVpNi5ve0H97S3F7C/axDfKxyNh21MG
# 0W8Sb0vxi/vorcLHOL9i+t2D6yvvDzLlEefUCbQV/zGCBjXGlYJcUj6RAzXyeNAN
# xSpKXAGd7Fh+ocGHPPphcD9LQTOJgG7Y7aYztHqBLJiQQ4eAgZNU4ac6+8LnEGAL
# go1ydC5BJEuJQjYKbNTy959HrKSu7LO3Ws0w8jw6pYdC1IMpdTkk2puTgY2PDNzB
# tLM4evG7FYer3WX+8t1UMYNTAgMBAAGjggFzMIIBbzAfBgNVHSUEGDAWBgorBgEE
# AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQURxxxNPIEPGSO8kqz+bgCAQWGXsEw
# RQYDVR0RBD4wPKQ6MDgxHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEW
# MBQGA1UEBRMNMjMwMDEyKzUwMTgyNjAfBgNVHSMEGDAWgBRIbmTlUAXTgqoXNzci
# tW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vd3d3Lm1pY3Jvc29mdC5j
# b20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3JsMGEG
# CCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDovL3d3dy5taWNyb3NvZnQu
# Y29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3J0
# MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIBAISxFt/zR2frTFPB45Yd
# mhZpB2nNJoOoi+qlgcTlnO4QwlYN1w/vYwbDy/oFJolD5r6FMJd0RGcgEM8q9TgQ
# 2OC7gQEmhweVJ7yuKJlQBH7P7Pg5RiqgV3cSonJ+OM4kFHbP3gPLiyzssSQdRuPY
# 1mIWoGg9i7Y4ZC8ST7WhpSyc0pns2XsUe1XsIjaUcGu7zd7gg97eCUiLRdVklPmp
# XobH9CEAWakRUGNICYN2AgjhRTC4j3KJfqMkU04R6Toyh4/Toswm1uoDcGr5laYn
# TfcX3u5WnJqJLhuPe8Uj9kGAOcyo0O1mNwDa+LhFEzB6CB32+wfJMumfr6degvLT
# e8x55urQLeTjimBQgS49BSUkhFN7ois3cZyNpnrMca5AZaC7pLI72vuqSsSlLalG
# OcZmPHZGYJqZ0BacN274OZ80Q8B11iNokns9Od348bMb5Z4fihxaBWebl8kWEi2O
# PvQImOAeq3nt7UWJBzJYLAGEpfasaA3ZQgIcEXdD+uwo6ymMzDY6UamFOfYqYWXk
# ntxDGu7ngD2ugKUuccYKJJRiiz+LAUcj90BVcSHRLQop9N8zoALr/1sJuwPrVAtx
# HNEgSW+AKBqIxYWM4Ev32l6agSUAezLMbq5f3d8x9qzT031jMDT+sUAoCw0M5wVt
# CUQcqINPuYjbS1WgJyZIiEkBMIIHejCCBWKgAwIBAgIKYQ6Q0gAAAAAAAzANBgkq
# hkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x
# EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv
# bjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5
# IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEwOTA5WjB+MQswCQYDVQQG
# EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG
# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQg
# Q29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
# CgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+laUKq4BjgaBEm6f8MMHt03
# a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc6Whe0t+bU7IKLMOv2akr
# rnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4Ddato88tt8zpcoRb0Rrrg
# OGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+lD3v++MrWhAfTVYoonpy
# 4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nkkDstrjNYxbc+/jLTswM9
# sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6A4aN91/w0FK/jJSHvMAh
# dCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmdX4jiJV3TIUs+UsS1Vz8k
# A/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL5zmhD+kjSbwYuER8ReTB
# w3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zdsGbiwZeBe+3W7UvnSSmn
# Eyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3T8HhhUSJxAlMxdSlQy90
# lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS4NaIjAsCAwEAAaOCAe0w
# ggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRIbmTlUAXTgqoXNzcitW2o
# ynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD
# VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBDuRQFTuHqp8cx0SOJNDBa
# BgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny
# bC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3JsMF4GCCsG
# AQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3dy5taWNyb3NvZnQuY29t
# L3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3J0MIGfBgNV
# HSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEFBQcCARYzaHR0cDovL3d3
# dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1hcnljcHMuaHRtMEAGCCsG
# AQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkAYwB5AF8AcwB0AGEAdABl
# AG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn8oalmOBUeRou09h0ZyKb
# C5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7v0epo/Np22O/IjWll11l
# hJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0bpdS1HXeUOeLpZMlEPXh6
# I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/KmtYSWMfCWluWpiW5IP0
# wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvyCInWH8MyGOLwxS3OW560
# STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBpmLJZiWhub6e3dMNABQam
# ASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJihsMdYzaXht/a8/jyFqGa
# J+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYbBL7fQccOKO7eZS/sl/ah
# XJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbSoqKfenoi+kiVH6v7RyOA
# 9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sLgOppO6/8MO0ETI7f33Vt
# Y5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtXcVZOSEXAQsmbdlsKgEhr
# /Xmfwb1tbWrJUnMTDXpQzTGCGaEwghmdAgEBMIGVMH4xCzAJBgNVBAYTAlVTMRMw
# EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN
# aWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNp
# Z25pbmcgUENBIDIwMTECEzMAAAOvMEAOTKNNBUEAAAAAA68wDQYJYIZIAWUDBAIB
# BQCggbAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO
# MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIO7MJ1UgSchVL+heOVSVAM9W
# 8jqDSJyZuPC0YOEniIOcMEQGCisGAQQBgjcCAQwxNjA0oBSAEgBNAGkAYwByAG8A
# cwBvAGYAdKEcgBpodHRwczovL3d3dy5taWNyb3NvZnQuY29tIDANBgkqhkiG9w0B
# AQEFAASCAQA4A7GTUs+Wz1rOjIbCpj8OfA3b054qRh8PSmrVUYroR8itytEwhULf
# yGmf0QmczpX12j+3W1xmYpjp6uO5Zbo1mi3xnHPkgmWdlckZtM357V+bmrZOcPam
# BKBg9ElEZi/zfsuPq3IyW/ZwYRL+FOqxyrIwy/RQyT7GagPy3a9F8m2xBnpaG1Ja
# Z+i33oKoTjVteiD1Mn+9Jv1tyfn3OE4wQaxbNXOYCSqOFJ7LYyoiZrNJTWAISi7m
# Ijqe7neO5W6k1Y37JRFXR5FrOZbUbQkTYcoIWhYZw151z49/vwv730SQbwVz3jGA
# IxM/yY8+6rqK6zHixlNKP8c95M0cCKiIoYIXKTCCFyUGCisGAQQBgjcDAwExghcV
# MIIXEQYJKoZIhvcNAQcCoIIXAjCCFv4CAQMxDzANBglghkgBZQMEAgEFADCCAVkG
# CyqGSIb3DQEJEAEEoIIBSASCAUQwggFAAgEBBgorBgEEAYRZCgMBMDEwDQYJYIZI
# AWUDBAIBBQAEIMlEyP8l66diQT+U1cHWs/zLQn+xYdyPQR+djk9X0hszAgZmctWw
# UoYYEzIwMjQwNzExMDU0NzEwLjE5NVowBIACAfSggdikgdUwgdIxCzAJBgNVBAYT
# AlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xLTArBgNVBAsTJE1pY3Jvc29mdCBJ
# cmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEmMCQGA1UECxMdVGhhbGVzIFRTUyBF
# U046M0JENC00QjgwLTY5QzMxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1w
# IFNlcnZpY2WgghF4MIIHJzCCBQ+gAwIBAgITMwAAAeWPasDzPbQLowABAAAB5TAN
# BgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv
# bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0
# aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAeFw0y
# MzEwMTIxOTA3MzVaFw0yNTAxMTAxOTA3MzVaMIHSMQswCQYDVQQGEwJVUzETMBEG
# A1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWlj
# cm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBP
# cGVyYXRpb25zIExpbWl0ZWQxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOjNCRDQt
# NEI4MC02OUMzMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNl
# MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAqXvgOtq7Y7osusk7cfJO
# 871pdqL/943I/kwtZmuZQY04kw/AwjTxX3MF9E81y5yt4hhLIkeOQwhQaa6HSs9X
# n/b5QIsas3U/vuf1+r+Z3Ncw3UXOpo8d0oSUqd4lDxHpw/h2u7YbKaa3WusZw17z
# TQJwPp3812iiaaR3X3pWo62NkUDVda74awUF5YeJ7P8+WWpwz95ae2RAyzSUrTOY
# J8f4G7uLWH4UNFHwXtbNSv/szeOFV0+kB+rbNgIDxlUs2ASLNj68WaDH7MO65T8Y
# KEMruSUNwLD7+BWgS5I6XlyVCzJ1ZCMklftnbJX7UoLobUlWqk/d2ko8A//i502q
# lHkch5vxNrUl+NFTNK/epKN7nL1FhP8CNY1hDuCx7O4NYz/xxnXWRyjUm9TI5DzH
# 8kOQwWpJHCPW/6ZzosoqWP/91YIb8fD2ml2VYlfqmwN6xC5BHsVXt4KpX+V9qOgu
# k83H/3MXV2/zJcF3OZYk94KJ7ZdpCesAoOqUbfNe7H201CbPYv3pN3Gcg7Y4aZjE
# EABkBagpua1gj4KLPgJjI7QWblndPjRrl3som5+0XoJOhxxz9Sn+OkV9CK0t+N3v
# VxL5wsJ6hD6rSfQgAu9X5pxsQ2i5I6uO/9C1jgUiMeUjnN0nMmcayRUnmjOGOLRh
# Gxy/VbAkUC7LIIxC8t2Y910CAwEAAaOCAUkwggFFMB0GA1UdDgQWBBTf/5+Hu01z
# MSJ8ReUJCAU5eAyHqjAfBgNVHSMEGDAWgBSfpxVdAF5iXYP05dJlpxtTNRnpcjBf
# BgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3Bz
# L2NybC9NaWNyb3NvZnQlMjBUaW1lLVN0YW1wJTIwUENBJTIwMjAxMCgxKS5jcmww
# bAYIKwYBBQUHAQEEYDBeMFwGCCsGAQUFBzAChlBodHRwOi8vd3d3Lm1pY3Jvc29m
# dC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0El
# MjAyMDEwKDEpLmNydDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUF
# BwMIMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAM/rCE4WMPVp3
# waQQn2gsG69+Od0zIZD1HgeAEpKU+3elrRdUtyKasmUOcoaAUGJbAjpc6DDzaF2i
# UOIwMEstZExMkdZV5RjWBThcHw44jEFz39DzfNvVzRFYS6mALjwj5v7bHZU2AYlS
# xAjI9HY+JdCFPk/J6syBqD05Kh1CMXCk10aKudraulXbcRTAV47n7ehJfgl4I1m+
# DJQ7MqnIy+pVq5uj4aV/+mx9bm0hwyNlW3R6WzB+rSok1CChiKltpO+/vGaLFQkZ
# NuLFiJ9PACK89wo116Kxma22zs4dsAzv3lm8otISpeJFSMNhnJ4fIDKwwQAtsiF1
# eAcSHrQqhnLOUFfPdXESKsTueG5w3Aza1WI6XAjsSR5TmG51y2dcIbnkm4zD/Bvt
# zvVEqKZkD8peVamYG+QmQHQFkRLw4IYN37Nj9P0GdOnyyLfpOqXzhV+lh72IebLs
# +qrGowXYKfirZrSYQyekGu4MYT+BH1zxJUnae2QBHLlJ+W64n8wHrXJG9PWZTHeX
# Kmk7bZ4+MGOfCgS9XFsONPWOF0w116864N4kbNEsr0c2ZMML5N1lCWP5UyAibxl4
# QhE0XShq+IX5BlxRktbNZtirrIOiTwRkoWJFHmi0GgYu9pgWnEFlQTyacsq4OVih
# uOvGHuWfCvFX98zLQX19KjYnEWa0uC0wggdxMIIFWaADAgECAhMzAAAAFcXna54C
# m0mZAAAAAAAVMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UE
# CBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9z
# b2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZp
# Y2F0ZSBBdXRob3JpdHkgMjAxMDAeFw0yMTA5MzAxODIyMjVaFw0zMDA5MzAxODMy
# MjVaMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQH
# EwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNV
# BAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMIICIjANBgkqhkiG9w0B
# AQEFAAOCAg8AMIICCgKCAgEA5OGmTOe0ciELeaLL1yR5vQ7VgtP97pwHB9KpbE51
# yMo1V/YBf2xK4OK9uT4XYDP/XE/HZveVU3Fa4n5KWv64NmeFRiMMtY0Tz3cywBAY
# 6GB9alKDRLemjkZrBxTzxXb1hlDcwUTIcVxRMTegCjhuje3XD9gmU3w5YQJ6xKr9
# cmmvHaus9ja+NSZk2pg7uhp7M62AW36MEBydUv626GIl3GoPz130/o5Tz9bshVZN
# 7928jaTjkY+yOSxRnOlwaQ3KNi1wjjHINSi947SHJMPgyY9+tVSP3PoFVZhtaDua
# Rr3tpK56KTesy+uDRedGbsoy1cCGMFxPLOJiss254o2I5JasAUq7vnGpF1tnYN74
# kpEeHT39IM9zfUGaRnXNxF803RKJ1v2lIH1+/NmeRd+2ci/bfV+AutuqfjbsNkz2
# K26oElHovwUDo9Fzpk03dJQcNIIP8BDyt0cY7afomXw/TNuvXsLz1dhzPUNOwTM5
# TI4CvEJoLhDqhFFG4tG9ahhaYQFzymeiXtcodgLiMxhy16cg8ML6EgrXY28MyTZk
# i1ugpoMhXV8wdJGUlNi5UPkLiWHzNgY1GIRH29wb0f2y1BzFa/ZcUlFdEtsluq9Q
# BXpsxREdcu+N+VLEhReTwDwV2xo3xwgVGD94q0W29R6HXtqPnhZyacaue7e3Pmri
# Lq0CAwEAAaOCAd0wggHZMBIGCSsGAQQBgjcVAQQFAgMBAAEwIwYJKwYBBAGCNxUC
# BBYEFCqnUv5kxJq+gpE8RjUpzxD/LwTuMB0GA1UdDgQWBBSfpxVdAF5iXYP05dJl
# pxtTNRnpcjBcBgNVHSAEVTBTMFEGDCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIB
# FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9y
# eS5odG0wEwYDVR0lBAwwCgYIKwYBBQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUA
# YgBDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU
# 1fZWy4/oolxiaNE9lJBb186aGMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2Ny
# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIw
# MTAtMDYtMjMuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDov
# L3d3dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0w
# Ni0yMy5jcnQwDQYJKoZIhvcNAQELBQADggIBAJ1VffwqreEsH2cBMSRb4Z5yS/yp
# b+pcFLY+TkdkeLEGk5c9MTO1OdfCcTY/2mRsfNB1OW27DzHkwo/7bNGhlBgi7ulm
# ZzpTTd2YurYeeNg2LpypglYAA7AFvonoaeC6Ce5732pvvinLbtg/SHUB2RjebYIM
# 9W0jVOR4U3UkV7ndn/OOPcbzaN9l9qRWqveVtihVJ9AkvUCgvxm2EhIRXT0n4ECW
# OKz3+SmJw7wXsFSFQrP8DJ6LGYnn8AtqgcKBGUIZUnWKNsIdw2FzLixre24/LAl4
# FOmRsqlb30mjdAy87JGA0j3mSj5mO0+7hvoyGtmW9I/2kQH2zsZ0/fZMcm8Qq3Uw
# xTSwethQ/gpY3UA8x1RtnWN0SCyxTkctwRQEcb9k+SS+c23Kjgm9swFXSVRk2XPX
# fx5bRAGOWhmRaw2fpCjcZxkoJLo4S5pu+yFUa2pFEUep8beuyOiJXk+d0tBMdrVX
# VAmxaQFEfnyhYWxz/gq77EFmPWn9y8FBSX5+k77L+DvktxW/tM4+pTFRhLy/AsGC
# onsXHRWJjXD+57XQKBqJC4822rpM+Zv/Cuk0+CQ1ZyvgDbjmjJnW4SLq8CdCPSWU
# 5nR0W2rRnj7tfqAxM328y+l7vzhwRNGQ8cirOoo6CGJ/2XBjU02N7oJtpQUQwXEG
# ahC0HVUzWLOhcGbyoYIC1DCCAj0CAQEwggEAoYHYpIHVMIHSMQswCQYDVQQGEwJV
# UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJl
# bGFuZCBPcGVyYXRpb25zIExpbWl0ZWQxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNO
# OjNCRDQtNEI4MC02OUMzMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBT
# ZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQD3jaIa5gWuwTjDNYN3zkSkzpGLCqCBgzCB
# gKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQH
# EwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNV
# BAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3DQEBBQUA
# AgUA6jmqLTAiGA8yMDI0MDcxMTA4NDc0MVoYDzIwMjQwNzEyMDg0NzQxWjB0MDoG
# CisGAQQBhFkKBAExLDAqMAoCBQDqOaotAgEAMAcCAQACAgJVMAcCAQACAhHaMAoC
# BQDqOvutAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKgCjAIAgEA
# AgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQEFBQADgYEAk4EleRBbGBrwE2IV
# g+thcb1YVKkhrXCMA+g138SwjvPEFaaLRt2jkC82o55t3CKyzuwjdjEEq8WK+jBt
# lltU8Pt1MsfmP6MG3wqonTjUswehi3OIAM1+S1lyq5tu/OXLOMifibxsnmfhZK7b
# gigubLnCVAr2rEPMLe/OAXC149wxggQNMIIECQIBATCBkzB8MQswCQYDVQQGEwJV
# UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt
# ZS1TdGFtcCBQQ0EgMjAxMAITMwAAAeWPasDzPbQLowABAAAB5TANBglghkgBZQME
# AgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJ
# BDEiBCB/ipBhbBCv2KV5wYrk/fJFgi/dnSl7FDf+fYK4cFjAwDCB+gYLKoZIhvcN
# AQkQAi8xgeowgecwgeQwgb0EIBWp0//+qPEYWF7ZhugRd5vwj+kCh/TULCFvFQf1
# Tr3tMIGYMIGApH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x
# EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv
# bjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAHl
# j2rA8z20C6MAAQAAAeUwIgQgpGpRKdlPXKD7VK24QfW65OEe5jMFfbTmRdwmrf1D
# 6ogwDQYJKoZIhvcNAQELBQAEggIAihSN6TDmKn3KcZi2hCYachp97TSU3DdLp7Ik
# Yhp/dnOPIt6mOUKaZDm4hzvx2fGWZAQz1iO/t2f3hJ/5iOzeATKYwaNAcNF1kDWI
# brNGEGoZmNpdHJu/1j83QXNvwqVdnDePZsxUbvNfFb+2mkcBOpQTDEh4QU8tzsJo
# JARRb5ylK0RKsZOykKt67OHJ8ZUDcyUVjUUYBgCtOVb0/EJ3v/QDuGHgnvYOdkXp
# 11f2lMxzv7bOaXpSx730PIrrZEWHxE0Gy6nN53o5K/anvt9ex/pPDWMEZN95HtzC
# vGoqY94CCCLeTAZAxXLn1mzQAp2fYsiYEoFx2GXEWBbn8x+bIV29W9QDK8JBHFgZ
# 7zVlJGzktynG/QRJULxTQQ3inTCmROO92fnk1gE+BiC2wRbfe8VGtWGgQdeZdD43
# eUkgkW9qJYn9CtXaiRD4OBzSCJAI9cdGy/CXBE06LBcF9ZkqTygBH+HUYGYRMq/q
# fRpFhWiKObT7h1DzwGVX8b+Dqln02zVMhlmbCMTnakg69IHwhm79d8gkxSmaB79O
# CeQxs/5c8ddCsjOqI0XQdaL7JKx62I75x5zPZOkNhmMIHLKrHjiynPJnaWUpMjsS
# aHlY5985vtkbM6huaZRdHmuN+HUJqVuddpVwKwN91cGf9cdGgySbrIah5e+0PHQR
# jK9u+lE=
# SIG # End signature block