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
    Reads the claims of a JSON Web Token.
 
.PARAMETER Token
    The access token, with or without the 'Bearer ' prefix.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. The claims, or $null when the token is not a JSON Web Token.
#>

function Get-MSCloudLoginAccessTokenClaims
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param
    (
        [Parameter()]
        [System.String]
        $Token
    )

    if ([System.String]::IsNullOrEmpty($Token))
    {
        return $null
    }

    $segments = ($Token -replace '^Bearer\s+', '').Split('.')
    if ($segments.Count -ne 3)
    {
        return $null
    }

    try
    {
        $payload = $segments[1].Replace('-', '+').Replace('_', '/')
        $payload = $payload.PadRight($payload.Length + (4 - $payload.Length % 4) % 4, '=')
        return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | ConvertFrom-Json
    }
    catch
    {
        return $null
    }
}

<#
.SYNOPSIS
    Reads the expiry time from the exp claim of a JSON Web Token.
 
.PARAMETER Token
    The access token, with or without the 'Bearer ' prefix.
 
.OUTPUTS
    System.DateTime. The local expiry time, or $null when the token is not a JSON Web Token with an exp claim.
#>

function Get-MSCloudLoginAccessTokenExpiry
{
    [CmdletBinding()]
    [OutputType([System.DateTime])]
    param
    (
        [Parameter()]
        [System.String]
        $Token
    )

    $claims = Get-MSCloudLoginAccessTokenClaims -Token $Token
    if ($null -eq $claims -or $null -eq $claims.exp)
    {
        return $null
    }

    return [System.DateTimeOffset]::FromUnixTimeSeconds([System.Int64]$claims.exp).LocalDateTime
}

<#
.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
    Resolves the tenant GUID.
 
.DESCRIPTION
    Returns a GUID TenantId as is. Otherwise, reads the GUID from the cache or the OpenID configuration.
 
.PARAMETER TenantId
    Tenant GUID or tenant name, e.g. contoso.onmicrosoft.com.
 
.OUTPUTS
    System.String. Tenant GUID, or $null if not resolvable.
#>

function Get-MSCloudLoginTenantGuid
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $TenantId
    )

    $tenantGuid = [System.Guid]::Empty
    if ([System.Guid]::TryParse($TenantId, [ref]$tenantGuid))
    {
        return $TenantId
    }

    if (-not $Script:MSCloudLoginTenantGuidCache.ContainsKey($TenantId))
    {
        $null = Get-CloudEnvironmentInfo -TenantId $TenantId
    }

    return $Script:MSCloudLoginTenantGuidCache[$TenantId]
}

<#
.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
    Re-imports the loaded proxy modules that export the specified command into the global scope.
 
.DESCRIPTION
    The re-imported commands take precedence over commands with the same name from other modules.
    The modules are not reloaded.
 
.PARAMETER ProbeCommand
    A command name that identifies the proxy module (e.g. 'Get-AcceptedDomain').
 
.PARAMETER Source
    The event source to use for logging.
 
.OUTPUTS
    System.Boolean. $true when the proxy module was found and re-imported, $false otherwise.
#>

function Restore-MSCloudLoginProxyModule
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ProbeCommand,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Source
    )

    [array]$proxyModules = Get-Module | Where-Object -FilterScript {
        $_.ExportedCommands.Keys.Contains($ProbeCommand)
    }

    if ($proxyModules.Count -eq 0)
    {
        Add-MSCloudLoginAssistantEvent -Message "No loaded proxy module exports {$ProbeCommand}" -Source $Source
        return $false
    }

    foreach ($proxyModule in $proxyModules)
    {
        try
        {
            Add-MSCloudLoginAssistantEvent -Message "Restoring command precedence of proxy module {$($proxyModule.Name)}" -Source $Source
            # No -Force: re-imports the commands without reloading the module.
            Import-Module -ModuleInfo $proxyModule -Global -DisableNameChecking -Verbose:$false -ErrorAction Stop
        }
        catch
        {
            Add-MSCloudLoginAssistantEvent -Message "Failed to restore proxy module {$($proxyModule.Name)}: $($_.Exception.Message)" -Source $Source
            return $false
        }
    }

    return $true
}

<#
.SYNOPSIS
    Disconnects either the Exchange Online or the Security & Compliance connections.
 
.DESCRIPTION
    Connections of the ExchangeOnlineManagement module are shared by all runspaces of the process.
    Only connections of the requested kind are disconnected.
 
.PARAMETER SecurityCompliance
    Disconnects the Security & Compliance connections instead of the Exchange Online connections.
 
.PARAMETER Source
    The event source to use for logging.
#>

function Disconnect-MSCloudLoginExchangeConnection
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [switch]
        $SecurityCompliance,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Source
    )

    # IsEopSession marks Security & Compliance connections.
    [array]$connectionIds = Get-ConnectionInformation | Where-Object -FilterScript {
        $null -ne $_.ConnectionId -and [System.Boolean]$_.IsEopSession -eq $SecurityCompliance.IsPresent
    } | ForEach-Object -Process { $_.ConnectionId.ToString() }

    if ($connectionIds.Count -eq 0)
    {
        return
    }

    Add-MSCloudLoginAssistantEvent -Message "Disconnecting connection(s) {$($connectionIds -join ', ')}" -Source $Source
    Disconnect-ExchangeOnline -ConnectionId $connectionIds -Confirm:$false
}

<#
.SYNOPSIS
    Finds a certificate by thumbprint in the My store of a store location.
 
.DESCRIPTION
    Reads the store through X509Store instead of the Cert: drive. An object returned by the
    Cert: drive carries PowerShell properties that reference the session state of the calling
    runspace, and the Microsoft Graph SDK keeps the certificate in a static client.
 
.PARAMETER StoreLocation
    The store location to search.
 
.PARAMETER CertificateThumbprint
    The thumbprint of the certificate.
 
.OUTPUTS
    System.Security.Cryptography.X509Certificates.X509Certificate2. The certificate, or $null when it is not found.
#>

function Find-MSCloudLoginStoreCertificate
{
    [CmdletBinding()]
    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.StoreLocation]
        $StoreLocation,

        [Parameter(Mandatory = $true)]
        [System.String]
        $CertificateThumbprint
    )

    $store = [System.Security.Cryptography.X509Certificates.X509Store]::new(
        [System.Security.Cryptography.X509Certificates.StoreName]::My,
        $StoreLocation)
    try
    {
        $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]'ReadOnly, OpenExistingOnly')
        $found = $store.Certificates.Find(
            [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,
            $CertificateThumbprint,
            $false)
        if ($found.Count -gt 0)
        {
            return $found[0]
        }
    }
    catch [System.Security.Cryptography.CryptographicException]
    {
        return $null
    }
    finally
    {
        $store.Close()
    }

    return $null
}

<#
.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 = Find-MSCloudLoginStoreCertificate -StoreLocation 'CurrentUser' -CertificateThumbprint $CertificateThumbprint
        if ($null -eq $certificate)
        {
            $certificate = Find-MSCloudLoginStoreCertificate -StoreLocation 'LocalMachine' -CertificateThumbprint $CertificateThumbprint
        }
        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.')
    }
    elseif ($TenantId.Contains('.onsovcloud.'))
    {
        $domain = $TenantId.Replace('.onsovcloud.', '-admin.sovcloud-sharepoint.')
    }
    else
    {
        throw 'TenantId must be in format contoso.onmicrosoft.com'
    }

    return @{
        AdminUrl      = "https://$domain"
        ConnectionUrl = ("https://$domain").Replace('-admin', '')
    }
}

<#
.SYNOPSIS
    Returns the identity a workload profile connects with.
 
.PARAMETER WorkloadProfile
    The workload connection profile.
 
.OUTPUTS
    System.String. Authentication type, tenant, application id and user name of the profile. For access
    tokens, the application and object id of the first token.
#>

function Get-MSCloudLoginConnectionIdentity
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Object]
        $WorkloadProfile
    )

    $identity = '{0}|{1}|{2}|{3}' -f $WorkloadProfile.AuthenticationType, $WorkloadProfile.TenantId, $WorkloadProfile.ApplicationId, $WorkloadProfile.Credentials.UserName
    if ($WorkloadProfile.AuthenticationType -eq 'AccessTokens' -and $WorkloadProfile.AccessTokens.Count -gt 0)
    {
        $claims = Get-MSCloudLoginAccessTokenClaims -Token (Get-MSCloudLoginAccessTokenValue -Token $WorkloadProfile.AccessTokens[0])
        $identity += '|{0}{1}|{2}' -f $claims.appid, $claims.azp, $claims.oid
    }
    return $identity
}

<#
.SYNOPSIS
    Gets the identity a process-wide SDK session was last connected with.
 
.DESCRIPTION
    Stored as AppDomain data, which Windows PowerShell and PowerShell 7 share across all runspaces
    of the process.
 
.PARAMETER Workload
    The workload name.
 
.OUTPUTS
    System.String. $null when no connection is recorded.
#>

function Get-MSCloudLoginProcessConnectionIdentity
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Workload
    )

    return [System.AppDomain]::CurrentDomain.GetData("MSCloudLoginAssistant.ConnectionIdentity.$Workload")
}

<#
.SYNOPSIS
    Records the identity a process-wide SDK session is connected with.
 
.PARAMETER Workload
    The workload name.
 
.PARAMETER Identity
    The identity from Get-MSCloudLoginConnectionIdentity. Omitted after a disconnect.
#>

function Set-MSCloudLoginProcessConnectionIdentity
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Workload,

        [Parameter()]
        [System.String]
        $Identity
    )

    [System.AppDomain]::CurrentDomain.SetData("MSCloudLoginAssistant.ConnectionIdentity.$Workload", $Identity)
}

<#
.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 the access token expires within the renewal window, when a
    token-based authentication type without a known token expiry 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 without a known token expiry is considered expired.
 
.PARAMETER TokenRenewalMinutes
    The number of minutes before the known token expiry at which the connection is renewed.
    Not applied to the AccessTokens authentication type, whose tokens cannot be renewed.
 
.PARAMETER TokenBasedAuthTypes
    The authentication types whose tokens expire and require renewal when the token expiry is unknown.
 
.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.Int32]
        $TokenRenewalMinutes = 5,

        [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 ($null -ne $WorkloadProfile.TokenExpiresOn)
    {
        $renewalMinutes = $TokenRenewalMinutes
        if ($WorkloadProfile.AuthenticationType -eq 'AccessTokens')
        {
            $renewalMinutes = 0
        }

        if ($WorkloadProfile.TokenExpiresOn -le [System.DateTime]::Now.AddMinutes($renewalMinutes))
        {
            Add-MSCloudLoginAssistantEvent -Message "Token expires at {$($WorkloadProfile.TokenExpiresOn)}, renewing" -Source $Source
            $WorkloadProfile.Connected = $false
            return $false
        }
    }
    elseif ($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 $WorkloadProfile
        }
        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)
}