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 |