Helpers.ps1
|
<#
.SYNOPSIS Shared internal helper functions for the MSCloudLoginAssistant module. .DESCRIPTION This file contains helper functions that are shared between the main module and the individual workload connection scripts. None of these functions are exported from the module. #> <# .SYNOPSIS Converts a SecureString to its plain text representation. .DESCRIPTION Converts a SecureString to its plain text representation using BSTR marshalling, which works on both Windows PowerShell 5.1 and PowerShell 7+. The unmanaged memory is zeroed and freed after the conversion. .PARAMETER SecureString The SecureString to convert. .OUTPUTS System.String. The plain text value. #> function ConvertFrom-SecureStringToPlainText { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Security.SecureString] $SecureString ) $bstr = [System.IntPtr]::Zero try { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { if ($bstr -ne [System.IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } <# .SYNOPSIS Extracts the plain text value from an access token in any of its supported representations. .DESCRIPTION Access tokens can reach the module as plain strings, SecureStrings or PSCredentials. This function normalizes all three representations to the plain text token value. .PARAMETER Token The token as a string, SecureString or PSCredential. .OUTPUTS System.String. The plain text token value. #> function Get-MSCloudLoginAccessTokenValue { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Object] $Token ) if ($Token -is [System.Security.SecureString]) { return (ConvertFrom-SecureStringToPlainText -SecureString $Token) } if ($Token -is [System.Management.Automation.PSCredential]) { return (ConvertFrom-SecureStringToPlainText -SecureString $Token.Password) } return [System.String]$Token } <# .SYNOPSIS Extracts the tenant domain from the UserName of a credential. .DESCRIPTION Extracts the tenant domain (the part after the '@') from the UserName of the provided credential and throws a clear error if the UserName is not a UPN. .PARAMETER Credentials The credential whose UserName is evaluated. .OUTPUTS System.String. The tenant domain. #> function Get-MSCloudLoginTenantDomainFromCredentials { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSCredential] $Credentials ) if ($Credentials.UserName -notmatch '@') { throw "Unable to determine the tenant domain: the credential UserName '$($Credentials.UserName)' is not a user principal name (user@domain)." } return $Credentials.UserName.Split('@')[1] } <# .SYNOPSIS Removes a loaded implicit remoting proxy module that exports the specified command. .DESCRIPTION Exchange Online and Security & Compliance connections generate temporary proxy modules. Before establishing a new connection, an existing proxy module that exports the given probe command needs to be removed so that a new session can be created. .PARAMETER ProbeCommand A command name that identifies the proxy module (e.g. 'Get-AcceptedDomain'). .PARAMETER Source The event source to use for logging. #> function Remove-MSCloudLoginProxyModule { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $ProbeCommand, [Parameter(Mandatory = $true)] [System.String] $Source ) $loadedModules = Get-Module $modulesToRemove = $loadedModules | Where-Object -FilterScript { $_.ExportedCommands.Keys.Contains($ProbeCommand) } foreach ($moduleToRemove in $modulesToRemove) { Add-MSCloudLoginAssistantEvent -Message "Removing proxy module {$($moduleToRemove.Name)}" -Source $Source # Temporarily set ErrorActionPreference because a failure to remove the in-use # proxy module must not abort the connection attempt. $currentErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue' Remove-Module -Name $moduleToRemove -Force -Verbose:$false | Out-Null $ErrorActionPreference = $currentErrorActionPreference } } <# .SYNOPSIS Resolves a certificate either by thumbprint from the certificate stores or from a PFX file. .DESCRIPTION When a thumbprint is provided, the CurrentUser\My store is searched first, then LocalMachine\My. When a path is provided, the certificate is loaded from the PFX file with the optional password. A clear error is thrown when the certificate cannot be found. .PARAMETER CertificateThumbprint The thumbprint of the certificate to look up in the certificate stores. .PARAMETER CertificatePath The path to a PFX file to load the certificate from. .PARAMETER CertificatePassword The password of the PFX file. .OUTPUTS System.Security.Cryptography.X509Certificates.X509Certificate2. The resolved certificate. #> function Get-MSCloudLoginCertificate { [CmdletBinding(DefaultParameterSetName = 'Thumbprint')] [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')] [System.String] $CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'Path')] [System.String] $CertificatePath, [Parameter(ParameterSetName = 'Path')] [System.Security.SecureString] $CertificatePassword ) if ($PSCmdlet.ParameterSetName -eq 'Thumbprint') { $certificate = Get-Item -Path "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue if ($null -eq $certificate) { $certificate = Get-Item -Path "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction SilentlyContinue } if ($null -eq $certificate) { throw "Certificate with thumbprint '$CertificateThumbprint' was not found in the CurrentUser\My nor the LocalMachine\My certificate store." } return $certificate } if (-not (Test-Path -Path $CertificatePath)) { throw "Certificate path '$CertificatePath' was not found." } $resolvedPath = (Resolve-Path -Path $CertificatePath).ProviderPath if ($null -ne $CertificatePassword) { return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( $resolvedPath, $CertificatePassword, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet) } return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($resolvedPath) } <# .SYNOPSIS Determines whether an error indicates that multi-factor authentication is required. .DESCRIPTION Normalizes the detection of MFA-required errors across all workloads by checking the ErrorDetails message, the exception message and the string representation of the error record against the known MFA-related error patterns. .PARAMETER ErrorRecord The error record to inspect. .PARAMETER AdditionalPatterns Additional workload-specific wildcard patterns that also indicate an MFA requirement. .OUTPUTS System.Boolean. $true when the error indicates that MFA is required. #> function Test-MSCloudLoginMFARequiredError { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [Parameter()] [System.String[]] $AdditionalPatterns = @() ) $texts = @( $ErrorRecord.ErrorDetails.Message $ErrorRecord.Exception.Message $ErrorRecord.ToString() ) | Where-Object -FilterScript { -not [System.String]::IsNullOrEmpty($_) } # AADSTS50076: MFA required. AADSTS50079: MFA enrollment required. # AADSTS50158: conditional access / external security challenge. $patterns = @( '*AADSTS50076*' '*AADSTS50079*' '*AADSTS50158*' '*multi-factor authentication*' ) + $AdditionalPatterns foreach ($text in $texts) { foreach ($pattern in $patterns) { if ($text -like $pattern) { return $true } } } return $false } <# .SYNOPSIS Resolves the endpoint information of a workload for a given cloud environment. .DESCRIPTION Reads the per-workload, per-environment endpoint table from WorkloadEndpoints.psd1 (cached after the first load) and returns a hashtable with the resolved endpoint values. Environments without an explicit entry fall back to the workload's 'default' entry. For the 'Custom' environment, the table values are the names of keys in the custom environment configuration and are resolved against $Script:CustomEnvConfig. '{Placeholder}' tokens are replaced with the values provided via the Replacements parameter. .PARAMETER Workload The workload name as used in the endpoint table (e.g. 'AdminAPI'). .PARAMETER EnvironmentName The cloud environment name (e.g. 'AzureCloud', 'AzureDOD', 'Custom'). .PARAMETER Replacements Optional hashtable of placeholder names to values, e.g. @{ Resource = '...' } replaces '{Resource}' in all endpoint values. .OUTPUTS System.Collections.Hashtable. The resolved endpoint values. #> function Get-MSCloudLoginEndpointInfo { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.String] $Workload, [Parameter(Mandatory = $true)] [System.String] $EnvironmentName, [Parameter()] [System.Collections.Hashtable] $Replacements = @{} ) if ($null -eq $Script:WorkloadEndpointData) { $Script:WorkloadEndpointData = Import-PowerShellDataFile -Path "$PSScriptRoot\WorkloadEndpoints.psd1" -ErrorAction Stop } $workloadTable = $Script:WorkloadEndpointData[$Workload] if ($null -eq $workloadTable) { throw "No endpoint information is defined for workload '$Workload'." } $entry = $workloadTable[$EnvironmentName] if ($null -eq $entry) { $entry = $workloadTable['default'] } if ($null -eq $entry) { throw "No endpoint information is defined for workload '$Workload' in environment '$EnvironmentName' and the workload has no default entry." } $result = @{} foreach ($property in $entry.Keys) { $value = $entry[$property] if ($EnvironmentName -eq 'Custom') { # Custom entries hold the key names of the custom environment configuration. $value = $Script:CustomEnvConfig[$value] } elseif ($value -is [System.String]) { foreach ($placeholder in $Replacements.Keys) { $value = $value.Replace("{$placeholder}", [System.String]$Replacements[$placeholder]) } } $result[$property] = $value } return $result } <# .SYNOPSIS Derives the SharePoint Online admin and connection URLs from an onmicrosoft tenant name. .DESCRIPTION Central implementation of the tenant-name-to-SharePoint-URL mapping used by the PnP and SharePointOnlineREST workloads. Supports commercial, GCC High, DoD, China and .onms. tenants. .PARAMETER TenantId The tenant name, e.g. contoso.onmicrosoft.com, contoso.partner.onmschina.cn or contoso.onms.tld. .PARAMETER EnvironmentName The cloud environment name (e.g. AzureCloud, AzureUSGovernment, AzureDOD). .OUTPUTS System.Collections.Hashtable with the keys AdminUrl and ConnectionUrl. #> function Get-MSCloudLoginSPOUrlFromTenantId { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.String] $TenantId, [Parameter(Mandatory = $true)] [System.String] $EnvironmentName ) if ($TenantId.Contains('onmicrosoft')) { if ($EnvironmentName -eq 'AzureDOD') { $domain = $TenantId.Replace('.onmicrosoft.', '-admin.sharepoint-mil.') } else { $domain = $TenantId.Replace('.onmicrosoft.', '-admin.sharepoint.') } if ($EnvironmentName -in @('AzureUSGovernment', 'AzureDOD')) { # If the tenant id is in the format of contoso.onmicrosoft.com, replace the .com with .us for sovereign clouds $domain = $domain.Replace('.com', '.us') } } elseif ($TenantId.Contains('.onmschina.')) { $domain = $TenantId.Replace('.partner.onmschina.', '-admin.sharepoint.') } elseif ($TenantId.Contains('.onms.')) { $domain = $TenantId.Replace('.onms.', '-admin.spo.') } else { throw 'TenantId must be in format contoso.onmicrosoft.com' } return @{ AdminUrl = "https://$domain" ConnectionUrl = ("https://$domain").Replace('-admin', '') } } <# .SYNOPSIS Determines whether an existing workload connection can be reused. .DESCRIPTION Central connection-freshness check for all workloads. The connection is NOT reusable when the profile is not connected, when the connection timestamp is missing, when a token-based authentication type has exceeded its expiration window or when the optional probe script indicates that the underlying SDK context is gone. In all of those cases the profile is marked as disconnected so that a reconnect is performed. .PARAMETER WorkloadProfile The workload connection profile to check. .PARAMETER TokenExpirationMinutes The number of minutes after which a token-based connection is considered expired. .PARAMETER TokenBasedAuthTypes The authentication types whose tokens expire and require renewal. .PARAMETER ProbeScript Optional script block that returns the SDK context (e.g. { Get-MgContext }). A $null result marks the connection as not reusable. .PARAMETER Source The event source to use for logging. .OUTPUTS System.Boolean. $true when the existing connection can be reused. #> function Test-MSCloudLoginConnectionReusable { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Object] $WorkloadProfile, [Parameter()] [System.Int32] $TokenExpirationMinutes = 50, [Parameter()] [System.String[]] $TokenBasedAuthTypes = @('ServicePrincipalWithSecret', 'Identity'), [Parameter()] [System.Management.Automation.ScriptBlock] $ProbeScript, [Parameter(Mandatory = $true)] [System.String] $Source ) if (-not $WorkloadProfile.Connected) { return $false } if ([System.String]::IsNullOrEmpty($WorkloadProfile.ConnectedDateTime)) { Add-MSCloudLoginAssistantEvent -Message 'Connection has no timestamp, reconnecting' -Source $Source $WorkloadProfile.Connected = $false return $false } if ($WorkloadProfile.AuthenticationType -in $TokenBasedAuthTypes -and ` (Get-Date -Date $WorkloadProfile.ConnectedDateTime) -lt [System.DateTime]::Now.AddMinutes(-$TokenExpirationMinutes)) { Add-MSCloudLoginAssistantEvent -Message 'Token is about to expire, renewing' -Source $Source $WorkloadProfile.Connected = $false return $false } if ($null -ne $ProbeScript) { $probeResult = $null try { $probeResult = & $ProbeScript } catch { # Liveness probe: a failure only means that the SDK context is gone. Add-MSCloudLoginAssistantEvent -Message "Connection probe failed: $($_.Exception.Message)" -Source $Source } if ($null -eq $probeResult) { $WorkloadProfile.Connected = $false return $false } } return $true } <# .SYNOPSIS Determines whether a parameter value is considered empty. .DESCRIPTION Used by the connection parameter comparison to treat absent, $null, empty string, unset switch, $false, empty SecureString, empty collection and empty dictionary values as equivalent. .PARAMETER Value The value to test. .OUTPUTS System.Boolean. $true when the value is considered empty. #> function Test-MSCloudLoginParameterValueEmpty { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter()] [System.Object] $Value ) if ($null -eq $Value) { return $true } if ($Value -is [System.String]) { return [System.String]::IsNullOrEmpty($Value) } if ($Value -is [System.Management.Automation.SwitchParameter]) { return -not $Value.IsPresent } if ($Value -is [System.Boolean]) { return -not $Value } if ($Value -is [System.Security.SecureString]) { return $Value.Length -eq 0 } if ($Value -is [System.Collections.IDictionary]) { return $Value.Count -eq 0 } if ($Value -is [System.Collections.ICollection]) { return $Value.Count -eq 0 } return $false } <# .SYNOPSIS Compares two parameter values for equality with type-aware semantics. .DESCRIPTION Compares two values of a connection parameter. SecureStrings are decrypted in memory only and compared ordinally, PSCredentials compare user name (case-insensitive) and password, dictionaries are compared recursively per key, collections are compared element-wise (order-sensitive except for CmdletsToLoad) and strings are compared case-insensitively for identifiers and case-sensitively for secrets. .PARAMETER KeyName The canonical name of the parameter being compared. Determines the comparison semantics. .PARAMETER Left The first value. .PARAMETER Right The second value. .OUTPUTS System.Boolean. $true when both values are considered equal. #> function Test-MSCloudLoginParameterValueEqual { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.String] $KeyName, [Parameter()] [System.Object] $Left, [Parameter()] [System.Object] $Right ) # SecureString: decrypt in memory only, compare ordinally. Values are never logged. if ($Left -is [System.Security.SecureString] -or $Right -is [System.Security.SecureString]) { if (-not ($Left -is [System.Security.SecureString] -and $Right -is [System.Security.SecureString])) { return $false } $leftPlain = ConvertFrom-SecureStringToPlainText -SecureString $Left $rightPlain = ConvertFrom-SecureStringToPlainText -SecureString $Right $result = [System.String]::Equals($leftPlain, $rightPlain, [System.StringComparison]::Ordinal) $leftPlain = $null $rightPlain = $null return $result } # PSCredential: user name case-insensitive AND password (delegated to the SecureString branch). if ($Left -is [System.Management.Automation.PSCredential] -or $Right -is [System.Management.Automation.PSCredential]) { if (-not ($Left -is [System.Management.Automation.PSCredential] -and $Right -is [System.Management.Automation.PSCredential])) { return $false } if (-not [System.String]::Equals($Left.UserName, $Right.UserName, [System.StringComparison]::OrdinalIgnoreCase)) { return $false } return (Test-MSCloudLoginParameterValueEqual -KeyName "$KeyName.Password" -Left $Left.Password -Right $Right.Password) } # Dictionaries (e.g. Endpoints): key-wise recursive comparison. if ($Left -is [System.Collections.IDictionary] -or $Right -is [System.Collections.IDictionary]) { if (-not ($Left -is [System.Collections.IDictionary] -and $Right -is [System.Collections.IDictionary])) { return $false } if ($Left.Count -ne $Right.Count) { return $false } foreach ($dictKey in $Left.Keys) { if (-not $Right.Contains($dictKey)) { return $false } if (-not (Test-MSCloudLoginParameterValueEqual -KeyName "$KeyName.$dictKey" -Left $Left[$dictKey] -Right $Right[$dictKey])) { return $false } } return $true } # Collections: AccessTokens are positional and therefore order-sensitive, # CmdletsToLoad are cmdlet names and therefore order- and case-insensitive. $leftIsCollection = ($Left -is [System.Collections.IEnumerable] -and $Left -isnot [System.String]) $rightIsCollection = ($Right -is [System.Collections.IEnumerable] -and $Right -isnot [System.String]) if ($leftIsCollection -or $rightIsCollection) { $leftArray = @($Left) $rightArray = @($Right) if ($leftArray.Count -ne $rightArray.Count) { return $false } if ($KeyName -eq 'CmdletsToLoad') { $leftArray = @($leftArray | Sort-Object) $rightArray = @($rightArray | Sort-Object) } for ($i = 0; $i -lt $leftArray.Count; $i++) { if (-not (Test-MSCloudLoginParameterValueEqual -KeyName $KeyName -Left $leftArray[$i] -Right $rightArray[$i])) { return $false } } return $true } # Booleans / switches. if ($Left -is [System.Boolean] -or $Left -is [System.Management.Automation.SwitchParameter] -or ` $Right -is [System.Boolean] -or $Right -is [System.Management.Automation.SwitchParameter]) { return ([System.Boolean]$Left) -eq ([System.Boolean]$Right) } # Strings: secrets are case-sensitive, identifiers are not. $comparison = [System.StringComparison]::OrdinalIgnoreCase if ($KeyName -in @('ApplicationSecret', 'AccessTokens') -or $KeyName -like '*.Password') { $comparison = [System.StringComparison]::Ordinal } return [System.String]::Equals([System.String]$Left, [System.String]$Right, $comparison) } |